import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import Link from '@tiptap/extension-link'
import { Color } from '@tiptap/extension-color'
import TextStyle from '@tiptap/extension-text-style'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import { FontSize } from './FontSize'
import type { EditorView } from '@tiptap/pm/view'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
import { SuggestionCard } from './SuggestionCard'
import { SuggestionRail, type RailItem } from './SuggestionRail'
import { SuggestionHighlight, setSuggestions, setActiveSuggestion, findRange } from './SuggestionHighlight'
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
import { MisspellCard } from './MisspellCard'
import { WordCard } from './WordCard'
import { GlossTip } from './GlossTip'
import { SelectionBubble } from './SelectionBubble'
import { SearchHighlight } from './SearchHighlight'
import { FindReplace } from './FindReplace'
import { Typography } from './Typography'
import { RewritePreview, type RewriteStatus } from './RewritePreview'
import { api, type Suggestion, type WordInfo } from '../../api/client'
import { speak, speechSupported } from '../../audio/speech'
import type { SpellChecker } from '../../hooks/useSpellChecker'

export interface EditorChange {
  content: string // Tiptap JSON, stringified
  content_text: string // flattened plain text for the LLM
  word_count: number
}

interface Props {
  // Changing docId reloads the editor with that document's content.
  docId: string
  initialContent: string
  onChange: (change: EditorChange) => void
  // LLM suggestions to highlight; accept/dismiss notify the parent for the API
  // call + state removal (accept's text replacement happens here in the editor).
  suggestions: Suggestion[]
  onAccept: (s: Suggestion) => void
  onDismiss: (s: Suggestion) => void
  // Triggers the whole-document voice-consistency pass; `voicing` is true while
  // it runs (drives the toolbar button's loading state).
  onVoiceCheck: () => void
  voicing: boolean
  // Triggers the whole-document collocation coach; `collocating` is true while it
  // runs (drives the toolbar button's loading state).
  onCollocationCheck: () => void
  collocating: boolean
  // Fired when the editor gains focus, so the app can enter distraction-free mode.
  onFocusMode?: () => void
  // Browser-side spell checker (null until the dictionary loads). Adding a word
  // to the personal dictionary is bubbled up so it persists app-wide.
  spellChecker: SpellChecker | null
  onAddWord: (word: string) => void
}

interface MisspellState {
  word: string
  from: number
  to: number
  suggestions: string[]
  top: number
  left: number
}

// The open right-click word popover (definition + synonyms), or null. `info` is
// null while the offline lookup is in flight (`loading`); the card shows a
// looking-up state until it resolves.
interface WordInfoState {
  word: string
  from: number
  to: number
  top: number
  left: number
  loading: boolean
  info: WordInfo | null
  // Garden state: the captured word's id (null until the auto-capture returns or
  // after it's removed) and whether it's currently in the garden.
  vocabId: string | null
  saved: boolean
}

// sentenceAround pulls the sentence containing `word` out of a block of text, so
// a captured vocab word carries the context it was met in. Falls back to the
// whole (trimmed, length-capped) text when no sentence boundary is found.
function sentenceAround(text: string, wordStart: number): string {
  const stops = /[.!?。！？\n]/
  let start = 0
  for (let i = wordStart - 1; i >= 0; i--) {
    if (stops.test(text[i])) {
      start = i + 1
      break
    }
  }
  let end = text.length
  for (let i = wordStart; i < text.length; i++) {
    if (stops.test(text[i])) {
      end = i + 1
      break
    }
  }
  const s = text.slice(start, end).trim()
  return s.length > 240 ? s.slice(0, 240).trim() + '…' : s
}

// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
// spray up-and-out from a point; each reads its direction from --dx/--dy.
const CONFETTI_DOTS = [
  { dx: -20, dy: -26, color: 'var(--color-accent)' },
  { dx: 18, dy: -30, color: 'var(--color-mint)' },
  { dx: -6, dy: -34, color: 'var(--color-peach)' },
  { dx: 24, dy: -18, color: 'var(--color-lavender)' },
]

function Confetti({ top, left }: { top: number; left: number }) {
  return (
    <div className="petal-confetti pointer-events-none absolute z-30" style={{ top, left }} aria-hidden>
      {CONFETTI_DOTS.map((d, i) => (
        <span
          key={i}
          className="petal-confetti-dot"
          style={{ '--dx': `${d.dx}px`, '--dy': `${d.dy}px`, background: d.color } as React.CSSProperties}
        />
      ))}
    </div>
  )
}

// parseDoc turns the stored content string into a Tiptap doc node, tolerating
// the DB's '{}' default and any malformed value by falling back to empty.
function parseDoc(raw: string): object | undefined {
  if (!raw) return undefined
  try {
    const parsed = JSON.parse(raw)
    if (parsed && typeof parsed === 'object' && parsed.type === 'doc') return parsed
  } catch {
    /* fall through to empty */
  }
  return undefined
}

// uploadImageInto sends an image file to the store and inserts the returned URL
// as an image node — at `pos` if given (a drop point), otherwise at the current
// selection (a paste). Shared by the paste/drop handlers and the toolbar button.
export async function uploadImageInto(view: EditorView, file: File, pos?: number) {
  try {
    const { url } = await api.uploadImage(file)
    const { schema } = view.state
    const node = schema.nodes.image.create({ src: url })
    const at = pos ?? view.state.selection.from
    view.dispatch(view.state.tr.insert(at, node))
  } catch (err) {
    console.error('image upload failed', err)
  }
}

interface HoverState {
  suggestion: Suggestion
  top: number
  left: number
}

// The inline hover gloss: the word under the resting pointer, its Chinese
// translation, the PM range it covers (to dedupe re-fetches), and where to anchor.
interface GlossState {
  word: string
  gloss: string
  from: number
  to: number
  top: number
  left: number
}

// A non-empty text selection that the rewrite bubble hovers over.
interface SelectionState {
  from: number
  to: number
  text: string
  top: number
  left: number
}

// An in-flight or completed tone-rewrite preview, pinned over the selection it
// came from. `from`/`to` are the PM range the accepted rewrite replaces.
interface RewriteState {
  from: number
  to: number
  original: string
  style: string
  top: number
  left: number
  status: RewriteStatus
  rewrite: string
}

// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, character counting, and the suggestion-highlight
// decoration layer. Hovering a highlight opens its SuggestionCard.
export function EditorCore({
  docId,
  initialContent,
  onChange,
  suggestions,
  onAccept,
  onDismiss,
  onVoiceCheck,
  voicing,
  onCollocationCheck,
  collocating,
  onFocusMode,
  spellChecker,
  onAddWord,
}: Props) {
  const wrapperRef = useRef<HTMLDivElement>(null)
  const [hover, setHover] = useState<HoverState | null>(null)
  // The open spelling popover (click a red-underlined word), or null.
  const [misspell, setMisspell] = useState<MisspellState | null>(null)
  // The open right-click word popover (definition + synonyms), or null.
  const [wordInfo, setWordInfo] = useState<WordInfoState | null>(null)
  // Token to discard a word lookup whose popover has since closed/changed.
  const wordReqRef = useRef(0)
  // Transient confetti burst played at the last accept location.
  const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
  const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
  // Delays card close so the pointer can travel from highlight to card.
  const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
  // While the Ask Petal panel is expanded the card is pinned: the hover-close
  // timer is suppressed so chatting doesn't dismiss it. A click outside closes.
  const [pinned, setPinned] = useState(false)
  // Inline hover gloss (rest the pointer on a word → its Chinese meaning).
  const [gloss, setGloss] = useState<GlossState | null>(null)
  const glossTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
  const glossReqRef = useRef(0)
  // The rewrite affordances: a bubble over the current selection, and the
  // preview that replaces it once a style is chosen.
  const [selection, setSelection] = useState<SelectionState | null>(null)
  // True while a pointer is held down (a drag-select in progress). The rewrite
  // bubble stays hidden until release so it never appears — or re-anchors — under
  // a still-moving cursor, which is where the user is trying to click.
  const [dragging, setDragging] = useState(false)
  const [rewrite, setRewrite] = useState<RewriteState | null>(null)
  const rewriteReqRef = useRef(0)
  // The in-document Find & Replace bar (Ctrl/Cmd+F).
  const [findOpen, setFindOpen] = useState(false)
  // The right-margin comment rail. `railItems` carries each anchored suggestion's
  // vertical offset; `railEnabled` is true only when the viewport has room for the
  // column beside the editor (otherwise we fall back to the inline hover cards).
  // `railExpandedId` is the card showing its full explanation + Ask Petal, and
  // `activeId` is the suggestion currently emphasized (hovered text or card).
  const [railItems, setRailItems] = useState<RailItem[]>([])
  const [railEnabled, setRailEnabled] = useState(false)
  const [railExpandedId, setRailExpandedId] = useState<string | null>(null)
  const [activeId, setActiveId] = useState<string | null>(null)
  // A stable handle to the latest recompute so the editor's onUpdate (captured
  // once at construction) can trigger a re-measure without stale closures.
  const recomputeRailRef = useRef<() => void>(() => {})

  const editor = useEditor({
    extensions: [
      StarterKit,
      Underline,
      TextStyle,
      Color,
      FontSize,
      Highlight.configure({ multicolor: true }),
      Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: 'noopener noreferrer nofollow' } }),
      Image.configure({ inline: false, HTMLAttributes: { class: 'petal-image' } }),
      Table.configure({ resizable: true, HTMLAttributes: { class: 'petal-table' } }),
      TableRow,
      TableHeader,
      TableCell,
      TextAlign.configure({ types: ['heading', 'paragraph'] }),
      Placeholder.configure({ placeholder: 'Start writing…' }),
      CharacterCount,
      SuggestionHighlight,
      SpellCheck,
      SearchHighlight,
      Typography,
    ],
    content: parseDoc(initialContent),
    editorProps: {
      attributes: { class: 'petal-prose focus:outline-none' },
      // Dropping or pasting an image file uploads it and inserts it at the drop
      // point (or the cursor for a paste). Returns true to consume the event so
      // ProseMirror doesn't also try to handle the raw file. Non-image pastes
      // fall through to the default handler.
      handleDrop: (view, event) => {
        const files = (event as DragEvent).dataTransfer?.files
        const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
        if (!image) return false
        event.preventDefault()
        const coords = view.posAtCoords({ left: (event as DragEvent).clientX, top: (event as DragEvent).clientY })
        uploadImageInto(view, image, coords?.pos)
        return true
      },
      handlePaste: (view, event) => {
        const files = event.clipboardData?.files
        const image = files && Array.from(files).find((f) => f.type.startsWith('image/'))
        if (!image) return false
        event.preventDefault()
        uploadImageInto(view, image)
        return true
      },
    },
    onFocus: () => onFocusMode?.(),
    onUpdate: ({ editor }) => {
      // Any edit shifts positions, stranding the popover anchors.
      setMisspell(null)
      setWordInfo(null)
      setGloss(null)
      setSelection(null)
      // A stale rewrite preview points at a range that just moved — drop it.
      rewriteReqRef.current++
      setRewrite(null)
      onChange({
        content: JSON.stringify(editor.getJSON()),
        content_text: editor.getText(),
        word_count: editor.storage.characterCount.words(),
      })
      // Edits reflow the text, so the rail anchors need re-measuring.
      recomputeRailRef.current()
    },
    onSelectionUpdate: ({ editor }) => {
      // A selection supersedes the hover gloss; an empty one clears the bubble.
      setGloss(null)
      const { from, to, empty } = editor.state.selection
      const wrapper = wrapperRef.current
      if (empty || !wrapper) {
        setSelection(null)
        return
      }
      const text = editor.state.doc.textBetween(from, to, ' ').trim()
      if (!text) {
        setSelection(null)
        return
      }
      const start = editor.view.coordsAtPos(from)
      const wrapRect = wrapper.getBoundingClientRect()
      const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 360))
      const top = start.top - wrapRect.top
      setSelection({ from, to, text, top, left })
    },
  })

  // When the selected document changes, swap in its content without emitting an
  // update (false) so loading a doc doesn't trigger a spurious save.
  useEffect(() => {
    if (!editor) return
    editor.commands.setContent(parseDoc(initialContent) ?? '', false)
    setHover(null)
    setMisspell(null)
    setWordInfo(null)
    setGloss(null)
    setSelection(null)
    rewriteReqRef.current++
    setRewrite(null)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [docId, editor])

  // Push the spell checker into its decoration plugin once the dictionary loads
  // (and again whenever the personal dictionary changes its identity).
  useEffect(() => {
    if (!editor) return
    setSpellChecker(editor.state, editor.view.dispatch, spellChecker)
  }, [editor, spellChecker])

  // Push the current suggestion list into the decoration plugin.
  useEffect(() => {
    if (!editor) return
    setSuggestions(editor.state, editor.view.dispatch, suggestions)
    // Close the card if its suggestion is gone.
    setHover((h) => (h && suggestions.some((s) => s.id === h.suggestion.id) ? h : null))
    // Drop any rail expand/emphasis that points at a now-removed suggestion.
    setRailExpandedId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
    setActiveId((id) => (id && suggestions.some((s) => s.id === id) ? id : null))
  }, [editor, suggestions])

  // Re-measure the margin rail: whether there's room for the column beside the
  // editor, and where each suggestion's highlight sits vertically. Anchors are
  // taken from the live decoration DOM (keyed by data-suggestion-id) relative to
  // the wrapper — stable under scroll since text and wrapper scroll together.
  // Deferred a frame so decoration DOM and reflow have settled.
  const recomputeRail = useCallback(() => {
    requestAnimationFrame(() => {
      const wrapper = wrapperRef.current
      if (!wrapper) return
      const wrapRect = wrapper.getBoundingClientRect()
      // Need room for the 300px column + its 32px gutter (see .petal-rail), plus
      // a little breathing space to the viewport edge.
      setRailEnabled(window.innerWidth - wrapRect.right >= 348)
      const seen = new Set<string>()
      const items: RailItem[] = []
      wrapper.querySelectorAll<HTMLElement>('.petal-suggestion[data-suggestion-id]').forEach((el) => {
        const id = el.getAttribute('data-suggestion-id')
        if (!id || seen.has(id)) return
        const s = suggestions.find((x) => x.id === id)
        if (!s) return
        seen.add(id)
        items.push({ suggestion: s, anchorTop: el.getBoundingClientRect().top - wrapRect.top })
      })
      setRailItems(items)
    })
  }, [suggestions])

  useEffect(() => {
    recomputeRailRef.current = recomputeRail
  }, [recomputeRail])

  // Re-anchor when the suggestion set changes (after the decorations repaint),
  // and keep the rail in sync with viewport/editor width changes (room + reflow).
  useEffect(() => {
    recomputeRail()
    const wrapper = wrapperRef.current
    const ro = wrapper ? new ResizeObserver(() => recomputeRail()) : null
    if (wrapper && ro) ro.observe(wrapper)
    window.addEventListener('resize', recomputeRail)
    return () => {
      ro?.disconnect()
      window.removeEventListener('resize', recomputeRail)
    }
  }, [recomputeRail])

  // Emphasize the flagged text for the active suggestion, mirroring the rail
  // card ↔ text link both ways. Driven through the decoration plugin (not an
  // imperative DOM class) so it survives the repaints that fire on every edit.
  useEffect(() => {
    if (!editor) return
    setActiveSuggestion(editor.state, editor.view.dispatch, railEnabled ? activeId : null)
  }, [editor, activeId, railEnabled])

  // Scroll a suggestion's highlight to the middle of the viewport (clicking its
  // rail card jumps the editor to the flagged text).
  const scrollToSuggestion = useCallback((id: string) => {
    const el = wrapperRef.current?.querySelector(
      `.petal-suggestion[data-suggestion-id="${CSS.escape(id)}"]`,
    )
    el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
  }, [])

  // Clicking a rail card's body jumps to the text and toggles its detail panel.
  const activateRailCard = useCallback(
    (id: string) => {
      setActiveId(id)
      scrollToSuggestion(id)
      setRailExpandedId((cur) => (cur === id ? null : id))
    },
    [scrollToSuggestion],
  )

  const toggleRailExpand = useCallback((id: string) => {
    setRailExpandedId((cur) => (cur === id ? null : id))
  }, [])

  const openCardFor = useCallback(
    (id: string, el: HTMLElement) => {
      const wrapper = wrapperRef.current
      if (!wrapper) return
      const suggestion = suggestions.find((s) => s.id === id)
      if (!suggestion) return
      const elRect = el.getBoundingClientRect()
      const wrapRect = wrapper.getBoundingClientRect()
      const cardWidth = 300
      const left = Math.max(
        0,
        Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth),
      )
      const top = elRect.bottom - wrapRect.top + 6
      // A suggestion card and the word popover shouldn't stack.
      setWordInfo(null)
      setHover((prev) => {
        // Moving to a different highlight resets any Ask Petal pin.
        if (prev && prev.suggestion.id !== suggestion.id) setPinned(false)
        return { suggestion, top, left }
      })
    },
    [suggestions],
  )

  const handleMouseOver = useCallback(
    (e: React.MouseEvent) => {
      const target = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
      if (!target) return
      const id = target.getAttribute('data-suggestion-id')
      if (!id) return
      // With the rail open the card already lives in the margin — hovering the
      // text just emphasizes its card (and the highlight) rather than popping a
      // second, redundant floating card.
      if (railEnabled) {
        setActiveId(id)
        return
      }
      clearTimeout(closeTimer.current)
      openCardFor(id, target)
    },
    [openCardFor, railEnabled],
  )

  const scheduleClose = useCallback(() => {
    if (pinned) return // Ask Petal open — keep the card until an explicit close.
    clearTimeout(closeTimer.current)
    closeTimer.current = setTimeout(() => setHover(null), 160)
  }, [pinned])

  // Fully close the card and drop any pin (used on accept/dismiss/click-away).
  const closeCard = useCallback(() => {
    clearTimeout(closeTimer.current)
    setPinned(false)
    setHover(null)
  }, [])

  // Leaving a highlight schedules a close; entering the card cancels it, so the
  // pointer can bridge the small gap from text to card.
  const handleMouseOut = useCallback(
    (e: React.MouseEvent) => {
      if (!(e.target as HTMLElement).closest('.petal-suggestion')) return
      if (railEnabled) {
        setActiveId(null)
        return
      }
      scheduleClose()
    },
    [scheduleClose, railEnabled],
  )

  const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])

  // Accept applies the replacement to the document, plays a little confetti
  // burst over the flagged text, then notifies the parent. The confetti is
  // anchored to the highlight itself (captured before the replacement removes it),
  // so it fires in the right spot whether the accept came from the hover card or
  // the margin rail.
  const handleAccept = useCallback(
    (s: Suggestion) => {
      const wrapper = wrapperRef.current
      const el = wrapper?.querySelector(
        `.petal-suggestion[data-suggestion-id="${CSS.escape(s.id)}"]`,
      ) as HTMLElement | null
      let burst: { top: number; left: number } | null = null
      if (wrapper && el) {
        const wrapRect = wrapper.getBoundingClientRect()
        const elRect = el.getBoundingClientRect()
        burst = { top: elRect.top - wrapRect.top, left: elRect.right - wrapRect.left }
      }
      if (editor && s.replacement.trim() !== '') {
        const range = findRange(editor.state.doc, s.original)
        if (range) {
          editor.chain().focus().insertContentAt(range, s.replacement).run()
        }
      }
      // Fall back to the hover card's position if the highlight wasn't found.
      if (!burst && hover) burst = { top: hover.top, left: hover.left + 16 }
      if (burst) {
        setConfetti(burst)
        clearTimeout(confettiTimer.current)
        confettiTimer.current = setTimeout(() => setConfetti(null), 720)
      }
      closeCard()
      setRailExpandedId(null)
      onAccept(s)
    },
    [editor, onAccept, closeCard, hover],
  )

  const handleDismiss = useCallback(
    (s: Suggestion) => {
      closeCard()
      onDismiss(s)
    },
    [onDismiss, closeCard],
  )

  // Click a red-underlined word to open its spelling popover, anchored under the
  // word. posAtCoords→wordAt resolves the exact PM span (robust to the same
  // misspelling appearing elsewhere); nspell supplies the corrections.
  //
  // Tapping an AI-suggestion highlight also opens its card here — on touch there's
  // no hover, so the tap is the only way in (mouse users still get hover).
  const handleSpellClick = useCallback(
    (e: React.MouseEvent) => {
      const suggestionEl = (e.target as HTMLElement).closest('.petal-suggestion') as HTMLElement | null
      if (suggestionEl) {
        const id = suggestionEl.getAttribute('data-suggestion-id')
        if (id) {
          // With the rail open, a tap emphasizes and expands its margin card
          // instead of opening a floating one.
          if (railEnabled) {
            setActiveId(id)
            setRailExpandedId(id)
          } else {
            openCardFor(id, suggestionEl)
          }
        }
        return
      }
      if (!editor || !spellChecker) return
      const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
      if (!target) return
      const wrapper = wrapperRef.current
      if (!wrapper) return
      const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
      if (!coords) return
      const range = wordAt(editor.state.doc, coords.pos)
      if (!range) return
      const elRect = target.getBoundingClientRect()
      const wrapRect = wrapper.getBoundingClientRect()
      const cardWidth = 240
      const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
      const top = elRect.bottom - wrapRect.top + 6
      // Opening a spelling popover supersedes any AI-suggestion hover card.
      closeCard()
      setWordInfo(null)
      setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
    },
    [editor, spellChecker, closeCard, openCardFor, railEnabled],
  )

  const replaceMisspelling = useCallback(
    (correction: string) => {
      if (editor && misspell) {
        editor.chain().focus().insertContentAt({ from: misspell.from, to: misspell.to }, correction).run()
      }
      setMisspell(null)
    },
    [editor, misspell],
  )

  const addMisspellingToDict = useCallback(() => {
    if (misspell) onAddWord(misspell.word)
    setMisspell(null)
  }, [misspell, onAddWord])

  // exampleAt pulls the sentence containing the position out of its block, for
  // review context in the garden. textBetween with a single-char leaf/break
  // placeholder keeps the string indices aligned with ProseMirror's parentOffset
  // (so a hard break or inline atom before the word doesn't desync the slice).
  const exampleAt = useCallback(
    (pos: number): string => {
      if (!editor) return ''
      const $pos = editor.state.doc.resolve(pos)
      const text = $pos.parent.textBetween(0, $pos.parent.content.size, '\n', ' ')
      return sentenceAround(text, Math.max(0, $pos.parentOffset))
    },
    [editor],
  )

  // openWordLookup resolves the exact word span at a document position, anchors a
  // popover beneath it, and kicks off the offline lookup. The card opens
  // immediately in a loading state and fills in when the (local) lookup returns.
  // Shared by right-click, the keyboard shortcut (caret), and touch long-press.
  const openWordLookup = useCallback(
    (pos: number) => {
      if (!editor) return
      const range = wordAt(editor.state.doc, pos)
      if (!range) return
      const wrapper = wrapperRef.current
      if (!wrapper) return
      // Anchor under the word itself (not the click point) so the card lines up
      // with the text the way the spelling popover does.
      const start = editor.view.coordsAtPos(range.from)
      const end = editor.view.coordsAtPos(range.to)
      const wrapRect = wrapper.getBoundingClientRect()
      const cardWidth = 300
      const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
      const top = end.bottom - wrapRect.top + 6
      // Opening a word lookup supersedes any suggestion/spelling card.
      closeCard()
      setMisspell(null)
      const token = ++wordReqRef.current
      setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null, vocabId: null, saved: false })
      // The sentence the word sits in, for review context in the garden.
      const example = exampleAt(range.from)
      api
        .lookupWord(range.word)
        .then((info) => {
          if (token !== wordReqRef.current) return
          // Auto-capture into the vocabulary garden — only words the dictionary
          // actually knows (a real gloss or definition), so accidental lookups of
          // typos or proper nouns don't clutter the garden. Looking words up IS
          // the data source; this costs the writer nothing.
          const known = !!info.gloss || info.definitions.length > 0
          // Reflect the saved state optimistically so the heart shows 💚 the
          // moment a known word loads, rather than flashing 🤍 until the capture
          // round-trips. vocabId is filled in when recordVocab returns.
          setWordInfo((w) => (w ? { ...w, loading: false, info, saved: known } : null))
          if (!known) return
          api
            .recordVocab({
              word: range.word,
              gloss: info.gloss,
              definition: info.definitions[0]?.definition ?? '',
              phonetic: info.phonetic,
              example,
              doc_id: docId,
            })
            .then((row) => {
              // Discard a late capture if the card has since been superseded (a
              // new lookup, navigation, or an explicit remove all bump the token),
              // so it can't resurrect a word the writer just removed.
              if (token !== wordReqRef.current) return
              setWordInfo((w) => (w && w.word === range.word ? { ...w, vocabId: row.id, saved: true } : w))
            })
            .catch((err) => console.error('vocab capture failed', err))
        })
        .catch((err) => {
          console.error('word lookup failed', err)
          if (token === wordReqRef.current) {
            setWordInfo((w) => (w ? { ...w, loading: false, info: null } : null))
          }
        })
    },
    [editor, closeCard, docId],
  )

  // Toggle a looked-up word in/out of the vocabulary garden from the WordCard
  // heart. Auto-capture saves it on lookup; this lets the writer remove a word
  // she already knows (or re-add one she removed by mistake).
  const toggleSaveWord = useCallback(() => {
    // Read the current card and do the network side effects OUTSIDE the state
    // updater — an updater must be pure (React StrictMode double-invokes it,
    // which would otherwise fire each request twice).
    const w = wordInfo
    if (!w || !w.info) return
    if (w.vocabId) {
      // Already in the garden — remove it, and invalidate any in-flight capture
      // for this card so a late auto-capture can't resurrect the removed word.
      const id = w.vocabId
      wordReqRef.current++
      setWordInfo((cur) => (cur ? { ...cur, saved: false, vocabId: null } : cur))
      api.deleteVocab(id).catch((err) => console.error('vocab remove failed', err))
      return
    }
    // Not in the garden yet — save it (idempotent upsert keyed on the word).
    const word = w.word
    const example = exampleAt(w.from)
    setWordInfo((cur) => (cur ? { ...cur, saved: true } : cur))
    api
      .recordVocab({
        word,
        gloss: w.info.gloss,
        definition: w.info.definitions[0]?.definition ?? '',
        phonetic: w.info.phonetic,
        example,
        doc_id: docId,
      })
      .then((row) => setWordInfo((cur) => (cur && cur.word === word ? { ...cur, vocabId: row.id, saved: true } : cur)))
      .catch((err) => console.error('vocab save failed', err))
  }, [wordInfo, exampleAt, docId])

  // Right-click a word to look it up. Right-clicking off any word falls through
  // to the native menu (so copy/paste-by-menu still works — see the Selection fix).
  const handleContextMenu = useCallback(
    (e: React.MouseEvent) => {
      if (!editor) return
      const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
      if (!coords) return
      if (!wordAt(editor.state.doc, coords.pos)) return
      e.preventDefault()
      openWordLookup(coords.pos)
    },
    [editor, openWordLookup],
  )

  // Touch has no hover or right-click, so a long-press (~500ms without moving)
  // opens the word lookup at the finger — the touch equivalent of right-click.
  const longPressTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
  const handleTouchStart = useCallback(
    (e: React.TouchEvent) => {
      if (!editor || e.touches.length !== 1) return
      const { clientX, clientY } = e.touches[0]
      clearTimeout(longPressTimer.current)
      longPressTimer.current = setTimeout(() => {
        const coords = editor.view.posAtCoords({ left: clientX, top: clientY })
        if (coords) openWordLookup(coords.pos)
      }, 500)
    },
    [editor, openWordLookup],
  )
  const cancelLongPress = useCallback(() => clearTimeout(longPressTimer.current), [])

  const replaceWord = useCallback(
    (synonym: string) => {
      if (editor && wordInfo) {
        editor.chain().focus().insertContentAt({ from: wordInfo.from, to: wordInfo.to }, synonym).run()
      }
      setWordInfo(null)
    },
    [editor, wordInfo],
  )

  // Hover gloss: rest the pointer on an English word and, after a short delay,
  // show its Chinese meaning from the offline gloss dataset. Suppressed while a
  // selection, preview, or another popover is active, or over a word that has its
  // own affordance (a suggestion highlight / a misspelling).
  const handleMouseMove = useCallback(
    (e: React.MouseEvent) => {
      if (!editor) return
      if (selection || rewrite || misspell || wordInfo || pinned) return
      if (!editor.state.selection.empty) return
      const t = e.target as HTMLElement
      const clear = () => {
        clearTimeout(glossTimer.current)
        glossReqRef.current++
        setGloss(null)
      }
      if (t.closest('.petal-suggestion') || t.closest('.petal-misspelling')) {
        clear()
        return
      }
      const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
      if (!coords) {
        clear()
        return
      }
      const range = wordAt(editor.state.doc, coords.pos)
      if (!range) {
        clear()
        return
      }
      // Already showing this exact word — leave it be (no flicker on micro-moves).
      if (gloss && gloss.from === range.from && gloss.to === range.to) return
      clearTimeout(glossTimer.current)
      const token = ++glossReqRef.current
      glossTimer.current = setTimeout(() => {
        api
          .glossWord(range.word)
          .then((g) => {
            if (token !== glossReqRef.current) return
            const wrapper = wrapperRef.current
            if (!g.gloss || !wrapper) {
              setGloss(null)
              return
            }
            const start = editor.view.coordsAtPos(range.from)
            const end = editor.view.coordsAtPos(range.to)
            const wrapRect = wrapper.getBoundingClientRect()
            const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 280))
            const top = end.bottom - wrapRect.top + 6
            setGloss({ word: range.word, gloss: g.gloss, from: range.from, to: range.to, top, left })
          })
          .catch(() => {
            if (token === glossReqRef.current) setGloss(null)
          })
      }, 350)
    },
    [editor, selection, rewrite, misspell, wordInfo, pinned, gloss],
  )

  // Leaving the editor surface drops any pending/shown gloss.
  const handleMouseLeave = useCallback(() => {
    clearTimeout(glossTimer.current)
    glossReqRef.current++
    setGloss(null)
  }, [])

  // runRewrite fires the LLM rewrite for a captured range + style and tracks it
  // through loading → ready/error. A request token discards a response whose
  // preview has since been cancelled or superseded.
  const runRewrite = useCallback(
    (from: number, to: number, original: string, style: string, top: number, left: number) => {
      const token = ++rewriteReqRef.current
      setRewrite({ from, to, original, style, top, left, status: 'loading', rewrite: '' })
      api
        .rewriteSelection(docId, original, style)
        .then((res) => {
          if (token === rewriteReqRef.current) {
            setRewrite((r) => (r ? { ...r, status: 'ready', rewrite: res.rewrite } : null))
          }
        })
        .catch((err) => {
          console.error('rewrite failed', err)
          if (token === rewriteReqRef.current) {
            setRewrite((r) => (r ? { ...r, status: 'error' } : null))
          }
        })
    },
    [docId],
  )

  // Picking a style in the selection bubble: capture the selection's range +
  // text, anchor a preview below it, and kick off the rewrite.
  const handleRewrite = useCallback(
    (style: string) => {
      if (!editor || !selection) return
      const wrapper = wrapperRef.current
      if (!wrapper) return
      const start = editor.view.coordsAtPos(selection.from)
      const end = editor.view.coordsAtPos(selection.to)
      const wrapRect = wrapper.getBoundingClientRect()
      const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - 340))
      const top = end.bottom - wrapRect.top + 6
      setSelection(null)
      setGloss(null)
      runRewrite(selection.from, selection.to, selection.text, style, top, left)
    },
    [editor, selection, runRewrite],
  )

  const acceptRewrite = useCallback(() => {
    if (editor && rewrite && rewrite.rewrite) {
      editor.chain().focus().insertContentAt({ from: rewrite.from, to: rewrite.to }, rewrite.rewrite).run()
    }
    rewriteReqRef.current++
    setRewrite(null)
  }, [editor, rewrite])

  const cancelRewrite = useCallback(() => {
    rewriteReqRef.current++
    setRewrite(null)
  }, [])

  const retryRewrite = useCallback(() => {
    if (rewrite) runRewrite(rewrite.from, rewrite.to, rewrite.original, rewrite.style, rewrite.top, rewrite.left)
  }, [rewrite, runRewrite])

  // A pointer-down outside the rewrite preview dismisses it.
  useEffect(() => {
    if (!rewrite) return
    const onDown = (e: MouseEvent) => {
      if ((e.target as HTMLElement).closest('.petal-rewrite-card')) return
      rewriteReqRef.current++
      setRewrite(null)
    }
    document.addEventListener('mousedown', onDown)
    return () => document.removeEventListener('mousedown', onDown)
  }, [rewrite])

  // A pointer-down outside the word popover (and not on another word, which would
  // reopen it via the context menu) closes it.
  useEffect(() => {
    if (!wordInfo) return
    const onDown = (e: MouseEvent) => {
      if ((e.target as HTMLElement).closest('.petal-word-card')) return
      setWordInfo(null)
    }
    document.addEventListener('mousedown', onDown)
    return () => document.removeEventListener('mousedown', onDown)
  }, [wordInfo])

  // A pointer-down outside the popover (and not on another misspelling, which
  // would reopen it) closes the spelling card.
  useEffect(() => {
    if (!misspell) return
    const onDown = (e: MouseEvent) => {
      const t = e.target as HTMLElement
      if (t.closest('.petal-misspell-card') || t.closest('.petal-misspelling')) return
      setMisspell(null)
    }
    document.addEventListener('mousedown', onDown)
    return () => document.removeEventListener('mousedown', onDown)
  }, [misspell])

  // A pointer-down in the editor starts a (possible) drag-select, which keeps the
  // rewrite bubble hidden until release. A pointer-down on the bubble itself is
  // excluded so its buttons stay clickable. Release always re-arms the bubble.
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
    if ((e.target as HTMLElement).closest('.petal-selection-bubble')) return
    setDragging(true)
  }, [])

  useEffect(() => {
    const onUp = () => setDragging(false)
    document.addEventListener('pointerup', onUp)
    return () => document.removeEventListener('pointerup', onUp)
  }, [])

  // Editor keyboard shortcuts, so the ESL helpers aren't mouse-only:
  //   Ctrl/Cmd+F — Find & Replace (overrides the browser find, which can't see
  //                into the editor's content anyway)
  //   Ctrl/Cmd+D — look up the word at the caret (the keyboard "right-click")
  //   Ctrl/Cmd+J — rewrite the current selection more naturally (✨更自然)
  useEffect(() => {
    if (!editor) return
    const onKey = (e: KeyboardEvent) => {
      if (!(e.ctrlKey || e.metaKey) || e.altKey) return
      const k = e.key.toLowerCase()
      if (k === 'f') {
        e.preventDefault()
        setFindOpen(true)
      } else if (k === 'd') {
        e.preventDefault()
        openWordLookup(editor.state.selection.from)
      } else if (k === 'j') {
        if (!editor.state.selection.empty) {
          e.preventDefault()
          handleRewrite('natural')
        }
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [editor, openWordLookup, handleRewrite])

  useEffect(() => () => {
    clearTimeout(closeTimer.current)
    clearTimeout(confettiTimer.current)
    clearTimeout(glossTimer.current)
    clearTimeout(longPressTimer.current)
  }, [])

  // While pinned (Ask Petal open), a pointer-down outside the card closes it —
  // the only way to dismiss a pinned card without accept/dismiss.
  useEffect(() => {
    if (!pinned) return
    const onDown = (e: MouseEvent) => {
      if (!(e.target as HTMLElement).closest('.petal-suggestion-card')) closeCard()
    }
    document.addEventListener('mousedown', onDown)
    return () => document.removeEventListener('mousedown', onDown)
  }, [pinned, closeCard])

  // Touch has no hover, so the card is opened by a tap and can't close itself on
  // mouse-leave. Whenever a card is open, a pointer-down outside both the card and
  // any highlight dismisses it. Excluding `.petal-suggestion` lets a tap on
  // another highlight reopen for that one (via the click handler) without flicker.
  useEffect(() => {
    if (!hover) return
    const onDown = (e: PointerEvent) => {
      const t = e.target as HTMLElement
      if (!t.closest('.petal-suggestion-card') && !t.closest('.petal-suggestion')) closeCard()
    }
    document.addEventListener('pointerdown', onDown)
    return () => document.removeEventListener('pointerdown', onDown)
  }, [hover, closeCard])

  return (
    <div className="flex flex-1 flex-col">
      <Toolbar
        editor={editor}
        onVoiceCheck={onVoiceCheck}
        voicing={voicing}
        onCollocationCheck={onCollocationCheck}
        collocating={collocating}
      />
      <div
        ref={wrapperRef}
        className="relative flex-1"
        onMouseOver={handleMouseOver}
        onMouseOut={handleMouseOut}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        onPointerDown={handlePointerDown}
        onClick={handleSpellClick}
        onContextMenu={handleContextMenu}
        onTouchStart={handleTouchStart}
        onTouchMove={cancelLongPress}
        onTouchEnd={cancelLongPress}
      >
        <EditorContent editor={editor} className="h-full" />
        {findOpen && editor && <FindReplace editor={editor} onClose={() => setFindOpen(false)} />}
        {confetti && <Confetti top={confetti.top} left={confetti.left} />}
        {gloss && <GlossTip gloss={gloss.gloss} style={{ top: gloss.top, left: gloss.left }} />}
        {selection && !rewrite && !dragging && (
          <SelectionBubble
            style={{ top: selection.top, left: selection.left, transform: 'translateY(calc(-100% - 8px))' }}
            onRewrite={handleRewrite}
            onSpeak={speechSupported() ? () => speak(selection.text) : null}
          />
        )}
        {rewrite && (
          <RewritePreview
            style={rewrite.style}
            status={rewrite.status}
            original={rewrite.original}
            rewrite={rewrite.rewrite}
            cardStyle={{ top: rewrite.top, left: rewrite.left }}
            onAccept={acceptRewrite}
            onCancel={cancelRewrite}
            onRetry={retryRewrite}
          />
        )}
        {wordInfo && (
          <WordCard
            word={wordInfo.word}
            info={wordInfo.info}
            loading={wordInfo.loading}
            saved={wordInfo.saved}
            onToggleSave={toggleSaveWord}
            style={{ top: wordInfo.top, left: wordInfo.left }}
            onReplace={replaceWord}
          />
        )}
        {misspell && (
          <MisspellCard
            word={misspell.word}
            suggestions={misspell.suggestions}
            style={{ top: misspell.top, left: misspell.left }}
            onReplace={replaceMisspelling}
            onAdd={addMisspellingToDict}
          />
        )}
        {hover && !railEnabled && (
          <SuggestionCard
            suggestion={hover.suggestion}
            style={{ top: hover.top, left: hover.left }}
            onAccept={handleAccept}
            onDismiss={handleDismiss}
            onPointerEnter={keepOpen}
            onPointerLeave={scheduleClose}
            onExpandChange={setPinned}
          />
        )}
        {railEnabled && railItems.length > 0 && (
          <SuggestionRail
            items={railItems}
            activeId={activeId}
            expandedId={railExpandedId}
            onAccept={handleAccept}
            onDismiss={handleDismiss}
            onHover={setActiveId}
            onActivate={activateRailCard}
            onToggleExpand={toggleRailExpand}
          />
        )}
      </div>
    </div>
  )
}
