import type { InputEvent, Key } from '@hermes/ink'
import * as Ink from '@hermes/ink'
import { useEffect, useMemo, useRef, useState } from 'react'

import { setInputSelection } from '../app/inputSelectionStore.js'
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'

type InkExt = typeof Ink & {
  stringWidth: (s: string) => number
  useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void
  useTerminalFocus: () => boolean
}

const ink = Ink as unknown as InkExt
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink

const ESC = '\x1b'
const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
const DIM = `${ESC}[2m`
const DIM_OFF = `${ESC}[22m`
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')

const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF

let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
const stopCache = new Map<string, number[]>()

function graphemeStops(s: string) {
  const hit = stopCache.get(s)

  if (hit) {
    return hit
  }

  const stops = [0]

  for (const { index } of seg().segment(s)) {
    if (index > 0) {
      stops.push(index)
    }
  }

  if (stops.at(-1) !== s.length) {
    stops.push(s.length)
  }

  stopCache.set(s, stops)

  if (stopCache.size > STOP_CACHE_MAX) {
    const oldest = stopCache.keys().next().value

    if (oldest !== undefined) {
      stopCache.delete(oldest)
    }
  }

  return stops
}

function snapPos(s: string, p: number) {
  const pos = Math.max(0, Math.min(p, s.length))
  let last = 0

  for (const stop of graphemeStops(s)) {
    if (stop > pos) {
      break
    }

    last = stop
  }

  return last
}

function prevPos(s: string, p: number) {
  const pos = snapPos(s, p)
  let prev = 0

  for (const stop of graphemeStops(s)) {
    if (stop >= pos) {
      return prev
    }

    prev = stop
  }

  return prev
}

function nextPos(s: string, p: number) {
  const pos = snapPos(s, p)

  for (const stop of graphemeStops(s)) {
    if (stop > pos) {
      return stop
    }
  }

  return s.length
}

function wordLeft(s: string, p: number) {
  let i = snapPos(s, p) - 1

  while (i > 0 && /\s/.test(s[i]!)) {
    i--
  }

  while (i > 0 && !/\s/.test(s[i - 1]!)) {
    i--
  }

  return Math.max(0, i)
}

function wordRight(s: string, p: number) {
  let i = snapPos(s, p)

  while (i < s.length && !/\s/.test(s[i]!)) {
    i++
  }

  while (i < s.length && /\s/.test(s[i]!)) {
    i++
  }

  return i
}

/**
 * Move cursor one logical line up or down inside `s` while preserving the
 * column offset from the current line's start. Returns `null` when the cursor
 * is already on the first line (up) or last line (down) — callers use that
 * signal to fall through to history cycling instead of eating the arrow key.
 */
export function lineNav(s: string, p: number, dir: -1 | 1): null | number {
  const pos = snapPos(s, p)
  const curStart = s.lastIndexOf('\n', pos - 1) + 1
  const col = pos - curStart

  if (dir < 0) {
    if (curStart === 0) {
      return null
    }

    const prevStart = s.lastIndexOf('\n', curStart - 2) + 1

    return snapPos(s, Math.min(prevStart + col, curStart - 1))
  }

  const nextBreak = s.indexOf('\n', pos)

  if (nextBreak < 0) {
    return null
  }

  const nextEnd = s.indexOf('\n', nextBreak + 1)
  const lineEnd = nextEnd < 0 ? s.length : nextEnd

  return snapPos(s, Math.min(nextBreak + 1 + col, lineEnd))
}

// mirrors wrap-ansi(..., { wordWrap: false, hard: true }) so the declared
// cursor lines up with what <Text wrap="wrap-char"> actually renders
export function cursorLayout(value: string, cursor: number, cols: number) {
  const pos = Math.max(0, Math.min(cursor, value.length))
  const w = Math.max(1, cols)

  let col = 0,
    line = 0

  for (const { segment, index } of seg().segment(value)) {
    if (index >= pos) {
      break
    }

    if (segment === '\n') {
      line++
      col = 0

      continue
    }

    const sw = stringWidth(segment)

    if (!sw) {
      continue
    }

    if (col + sw > w) {
      line++
      col = 0
    }

    col += sw
  }

  // trailing cursor-cell overflows to the next row at the wrap column
  if (col >= w) {
    line++
    col = 0
  }

  return { column: col, line }
}

export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
  if (!value.length) {
    return 0
  }

  const targetRow = Math.max(0, Math.floor(row))
  const targetCol = Math.max(0, Math.floor(col))
  const w = Math.max(1, cols)

  let line = 0
  let column = 0
  let lastOffset = 0

  for (const { segment, index } of seg().segment(value)) {
    lastOffset = index

    if (segment === '\n') {
      if (line === targetRow) {
        return index
      }

      line++
      column = 0

      continue
    }

    const sw = Math.max(1, stringWidth(segment))

    if (column + sw > w) {
      if (line === targetRow) {
        return index
      }

      line++
      column = 0
    }

    if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) {
      return index
    }

    column += sw
  }

  if (targetRow >= line) {
    return value.length
  }

  return lastOffset
}

function renderWithCursor(value: string, cursor: number) {
  const pos = Math.max(0, Math.min(cursor, value.length))

  let out = '',
    done = false

  for (const { segment, index } of seg().segment(value)) {
    if (!done && index >= pos) {
      out += invert(index === pos && segment !== '\n' ? segment : ' ')
      done = true

      if (index === pos && segment !== '\n') {
        continue
      }
    }

    out += segment
  }

  return done ? out : out + invert(' ')
}

function renderWithSelection(value: string, start: number, end: number) {
  if (start >= end) {
    return value
  }

  return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
}

function useFwdDelete(active: boolean) {
  const ref = useRef(false)
  const { inputEmitter: ee } = useStdin()

  useEffect(() => {
    if (!active) {
      return
    }

    const h = (d: string) => {
      ref.current = FWD_DEL_RE.test(d)
    }

    ee.prependListener('input', h)

    return () => {
      ee.removeListener('input', h)
    }
  }, [active, ee])

  return ref
}

type PasteResult = { cursor: number; value: string } | null

const isPasteResultPromise = (
  value: PasteResult | Promise<PasteResult> | null | undefined
): value is Promise<PasteResult> => !!value && typeof (value as PromiseLike<PasteResult>).then === 'function'

export function TextInput({
  columns = 80,
  value,
  onChange,
  onPaste,
  onSubmit,
  mask,
  placeholder = '',
  focus = true
}: TextInputProps) {
  const [cur, setCur] = useState(value.length)
  const [sel, setSel] = useState<null | { end: number; start: number }>(null)
  const fwdDel = useFwdDelete(focus)
  const termFocus = useTerminalFocus()

  const curRef = useRef(cur)
  const selRef = useRef<null | { end: number; start: number }>(null)
  const vRef = useRef(value)
  const self = useRef(false)
  const pasteBuf = useRef('')
  const pasteEnd = useRef<null | number>(null)
  const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const pastePos = useRef(0)
  const editVersionRef = useRef(0)
  const undo = useRef<{ cursor: number; value: string }[]>([])
  const redo = useRef<{ cursor: number; value: string }[]>([])

  const cbChange = useRef(onChange)
  const cbSubmit = useRef(onSubmit)
  const cbPaste = useRef(onPaste)
  cbChange.current = onChange
  cbSubmit.current = onSubmit
  cbPaste.current = onPaste

  const raw = self.current ? vRef.current : value
  const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw

  const selected = useMemo(
    () =>
      sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null,
    [sel]
  )

  const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])

  const boxRef = useDeclaredCursor({
    line: layout.line,
    column: layout.column,
    active: focus && termFocus && !selected
  })

  const rendered = useMemo(() => {
    if (!focus) {
      return display || dim(placeholder)
    }

    if (!display && placeholder) {
      return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
    }

    if (selected) {
      return renderWithSelection(display, selected.start, selected.end)
    }

    return renderWithCursor(display, cur)
  }, [cur, display, focus, placeholder, selected])

  useEffect(() => {
    if (self.current) {
      self.current = false
    } else {
      setCur(value.length)
      setSel(null)
      curRef.current = value.length
      selRef.current = null
      vRef.current = value
      undo.current = []
      redo.current = []
    }
  }, [value])

  useEffect(() => {
    if (!focus) {
      return
    }

    setInputSelection({
      clear: () => {
        if (selRef.current) {
          selRef.current = null
          setSel(null)
        }
      },
      end: selected?.end ?? curRef.current,
      start: selected?.start ?? curRef.current,
      value: vRef.current
    })

    return () => setInputSelection(null)
  }, [cur, focus, selected])

  useEffect(
    () => () => {
      if (pasteTimer.current) {
        clearTimeout(pasteTimer.current)
      }
    },
    []
  )

  const commit = (next: string, nextCur: number, track = true) => {
    const prev = vRef.current
    const c = snapPos(next, nextCur)
    editVersionRef.current += 1

    if (selRef.current) {
      selRef.current = null
      setSel(null)
    }

    if (track && next !== prev) {
      undo.current.push({ cursor: curRef.current, value: prev })

      if (undo.current.length > 200) {
        undo.current.shift()
      }

      redo.current = []
    }

    setCur(c)
    curRef.current = c
    vRef.current = next

    if (next !== prev) {
      self.current = true
      cbChange.current(next)
    }
  }

  const swap = (from: typeof undo, to: typeof redo) => {
    const entry = from.current.pop()

    if (!entry) {
      return
    }

    to.current.push({ cursor: curRef.current, value: vRef.current })
    commit(entry.value, entry.cursor, false)
  }

  const emitPaste = (e: PasteEvent) => {
    const startVersion = editVersionRef.current
    const h = cbPaste.current?.(e)

    if (isPasteResultPromise(h)) {
      const fallbackText = e.text

      void h
        .then(result => {
          if (result && editVersionRef.current === startVersion) {
            commit(result.value, result.cursor)
          } else if (result && fallbackText && PRINTABLE.test(fallbackText)) {
            // User typed while async paste was in-flight — fall back to raw text insert
            // so the pasted content is not silently lost.
            const cur = curRef.current
            const v = vRef.current
            commit(v.slice(0, cur) + fallbackText + v.slice(cur), cur + fallbackText.length)
          }
        })
        .catch(() => {})

      return true
    }

    if (h) {
      commit(h.value, h.cursor)
    }

    return !!h
  }

  const flushPaste = () => {
    const text = pasteBuf.current
    const at = pastePos.current
    const end = pasteEnd.current ?? at
    pasteBuf.current = ''
    pasteEnd.current = null
    pasteTimer.current = null

    if (!text) {
      return
    }

    if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
      commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
    }
  }

  const clearSel = () => {
    if (!selRef.current) {
      return
    }

    selRef.current = null
    setSel(null)
  }

  const selectAll = () => {
    const end = vRef.current.length

    if (!end) {
      return
    }

    const next = { end, start: 0 }
    selRef.current = next
    setSel(next)
    setCur(end)
    curRef.current = end
  }

  const selRange = () => {
    const range = selRef.current

    return range && range.start !== range.end
      ? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
      : null
  }

  const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)

  const pastePlainText = (text: string) => {
    const cleaned = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')

    if (!cleaned) {
      return
    }

    const range = selRange()

    const nextValue = range
      ? vRef.current.slice(0, range.start) + cleaned + vRef.current.slice(range.end)
      : vRef.current.slice(0, curRef.current) + cleaned + vRef.current.slice(curRef.current)

    const nextCursor = range ? range.start + cleaned.length : curRef.current + cleaned.length

    commit(nextValue, nextCursor)
  }

  useInput(
    (inp: string, k: Key, event: InputEvent) => {
      const eventRaw = event.keypress.raw

      if (
        eventRaw === '\x1bv' ||
        eventRaw === '\x1bV' ||
        eventRaw === '\x16' ||
        (isMac && isActionMod(k) && inp.toLowerCase() === 'v')
      ) {
        if (cbPaste.current) {
          return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
        }

        if (isMac) {
          void readClipboardText().then(text => {
            if (text) {
              pastePlainText(text)
            }
          })
        }

        return
      }

      if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') {
        const range = selRange()

        if (range) {
          const text = vRef.current.slice(range.start, range.end)

          void writeClipboardText(text)
        }

        return
      }

      if (k.upArrow || k.downArrow) {
        const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1)

        if (next !== null) {
          clearSel()
          setCur(next)
          curRef.current = next

          return
        }

        return
      }

      // Ctrl+B is the documented voice-recording toggle (see platform.ts →
      // isVoiceToggleKey). Pass it through so the app-level handler in
      // useInputHandlers receives it instead of being swallowed here as
      // either backward-word nav (line below) or a literal 'b' insertion.
      if (
        (k.ctrl && inp === 'c') ||
        (k.ctrl && inp === 'b') ||
        k.tab ||
        (k.shift && k.tab) ||
        k.pageUp ||
        k.pageDown ||
        k.escape
      ) {
        return
      }

      if (k.return) {
        k.shift || (isMac ? isActionMod(k) : k.meta)
          ? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1)
          : cbSubmit.current?.(vRef.current)

        return
      }

      let c = curRef.current
      let v = vRef.current
      const mod = isActionMod(k)
      const wordMod = mod || k.meta
      const actionHome = k.home || (!isMac && mod && inp === 'a') || isMacActionFallback(k, inp, 'a')
      const actionEnd = k.end || (mod && inp === 'e') || isMacActionFallback(k, inp, 'e')
      const actionDeleteToStart = (mod && inp === 'u') || isMacActionFallback(k, inp, 'u')
      const actionKillToEnd = (mod && inp === 'k') || isMacActionFallback(k, inp, 'k')
      const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w')
      const range = selRange()
      const delFwd = k.delete || fwdDel.current

      if (mod && inp === 'z') {
        return swap(undo, redo)
      }

      if ((mod && inp === 'y') || (mod && k.shift && inp === 'z')) {
        return swap(redo, undo)
      }

      if (isMac && mod && inp === 'a') {
        return selectAll()
      }

      if (actionHome) {
        clearSel()
        c = 0
      } else if (actionEnd) {
        clearSel()
        c = v.length
      } else if (k.leftArrow) {
        if (range && !wordMod) {
          clearSel()
          c = range.start
        } else {
          clearSel()
          c = wordMod ? wordLeft(v, c) : prevPos(v, c)
        }
      } else if (k.rightArrow) {
        if (range && !wordMod) {
          clearSel()
          c = range.end
        } else {
          clearSel()
          c = wordMod ? wordRight(v, c) : nextPos(v, c)
        }
      } else if (wordMod && inp === 'b') {
        clearSel()
        c = wordLeft(v, c)
      } else if (wordMod && inp === 'f') {
        clearSel()
        c = wordRight(v, c)
      } else if (range && (k.backspace || delFwd)) {
        v = v.slice(0, range.start) + v.slice(range.end)
        c = range.start
      } else if (k.backspace && c > 0) {
        if (wordMod) {
          const t = wordLeft(v, c)
          v = v.slice(0, t) + v.slice(c)
          c = t
        } else {
          const t = prevPos(v, c)
          v = v.slice(0, t) + v.slice(c)
          c = t
        }
      } else if (delFwd && c < v.length) {
        if (wordMod) {
          const t = wordRight(v, c)
          v = v.slice(0, c) + v.slice(t)
        } else {
          v = v.slice(0, c) + v.slice(nextPos(v, c))
        }
      } else if (actionDeleteWord) {
        if (range) {
          v = v.slice(0, range.start) + v.slice(range.end)
          c = range.start
        } else if (c > 0) {
          clearSel()
          const t = wordLeft(v, c)
          v = v.slice(0, t) + v.slice(c)
          c = t
        } else {
          return
        }
      } else if (actionDeleteToStart) {
        if (range) {
          v = v.slice(0, range.start) + v.slice(range.end)
          c = range.start
        } else {
          v = v.slice(c)
          c = 0
        }
      } else if (actionKillToEnd) {
        if (range) {
          v = v.slice(0, range.start) + v.slice(range.end)
          c = range.start
        } else {
          v = v.slice(0, c)
        }
      } else if (inp.length > 0) {
        const bracketed = inp.includes('[200~')
        const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')

        if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
          return
        }

        if (!text) {
          return
        }

        if (text === '\n') {
          return commit(ins(v, c, '\n'), c + 1)
        }

        if (text.length > 1 || text.includes('\n')) {
          if (!pasteBuf.current) {
            pastePos.current = range ? range.start : c
            pasteEnd.current = range ? range.end : pastePos.current
          }

          pasteBuf.current += text

          if (pasteTimer.current) {
            clearTimeout(pasteTimer.current)
          }

          pasteTimer.current = setTimeout(flushPaste, 50)

          return
        }

        if (PRINTABLE.test(text)) {
          if (range) {
            v = v.slice(0, range.start) + text + v.slice(range.end)
            c = range.start + text.length
          } else {
            v = v.slice(0, c) + text + v.slice(c)
            c += text.length
          }
        } else {
          return
        }
      } else {
        return
      }

      commit(v, c)
    },
    { isActive: focus }
  )

  return (
    <Box
      onClick={(e: { localRow?: number; localCol?: number }) => {
        if (!focus) {
          return
        }

        clearSel()
        const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
        setCur(next)
        curRef.current = next
      }}
      onMouseDown={(e: { button: number }) => {
        // Right-click to paste: route through the same hotkey path as
        // Alt+V so the composer's clipboard RPC (text or image) handles it.
        if (!focus || e.button !== 2) {
          return
        }

        emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
      }}
      ref={boxRef}
    >
      <Text wrap="wrap-char">{rendered}</Text>
    </Box>
  )
}

export interface PasteEvent {
  bracketed?: boolean
  cursor: number
  hotkey?: boolean
  text: string
  value: string
}

interface TextInputProps {
  columns?: number
  focus?: boolean
  mask?: string
  onChange: (v: string) => void
  onPaste?: (
    e: PasteEvent
  ) => { cursor: number; value: string } | Promise<{ cursor: number; value: string } | null> | null
  onSubmit?: (v: string) => void
  placeholder?: string
  value: string
}
