import { useEditor, EditorContent, Extension, Editor } from '@tiptap/react'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { Decoration, DecorationSet, EditorView } from '@tiptap/pm/view'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import { TableKit, renderTableToMarkdown } from '@tiptap/extension-table'
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/react'
import { ImageUploadPlaceholderExtension, createImageUploadHandler } from '@/extensions/image-upload'
import { TaskBlockExtension } from '@/extensions/task-block'
import { PromptBlockExtension } from '@/extensions/prompt-block'
import { ImageBlockExtension } from '@/extensions/image-block'
import { EmbedBlockExtension } from '@/extensions/embed-block'
import { IframeBlockExtension } from '@/extensions/iframe-block'
import { ChartBlockExtension } from '@/extensions/chart-block'
import { TableBlockExtension } from '@/extensions/table-block'
import { CalendarBlockExtension } from '@/extensions/calendar-block'
import { EmailBlockExtension, EmailsBlockExtension } from '@/extensions/email-block'
import { TranscriptBlockExtension } from '@/extensions/transcript-block'
import { MermaidBlockExtension } from '@/extensions/mermaid-block'
import { Markdown } from 'tiptap-markdown'
import { useEffect, useCallback, useMemo, useRef, useState, forwardRef, useImperativeHandle } from 'react'
import { Calendar, ChevronDown, ExternalLink } from 'lucide-react'

// Zero-width space used as invisible marker for blank lines
const BLANK_LINE_MARKER = '\u200B'

// Pre-process markdown to preserve blank lines before parsing
function preprocessMarkdown(markdown: string): string {
  // Convert sequences of 3+ newlines to paragraphs with zero-width space
  // - 2 newlines = normal paragraph break (0 empty paragraphs)
  // - 3 newlines = 1 blank line = 1 empty paragraph
  // - 4 newlines = 2 blank lines = 2 empty paragraphs
  // Formula: emptyParagraphs = totalNewlines - 2
  return markdown.replace(/\n{3,}/g, (match) => {
    const totalNewlines = match.length
    const emptyParagraphs = totalNewlines - 2
    let result = '\n\n'
    for (let i = 0; i < emptyParagraphs; i++) {
      result += BLANK_LINE_MARKER + '\n\n'
    }
    return result
  })
}

// Post-process to clean up any zero-width spaces in the output
function postprocessMarkdown(markdown: string): string {
  // Remove lines that contain only the zero-width space marker
  return markdown.split('\n').map(line => {
    if (line === BLANK_LINE_MARKER || line.trim() === BLANK_LINE_MARKER) {
      return ''
    }
    // Also remove zero-width spaces from other content
    return line.replace(new RegExp(BLANK_LINE_MARKER, 'g'), '')
  }).join('\n')
}

type JsonNode = {
  type?: string
  content?: JsonNode[]
  text?: string
  marks?: Array<{ type: string; attrs?: Record<string, unknown> }>
  attrs?: Record<string, unknown>
}

// Convert a node's inline content (text + marks + wikiLinks + hardBreaks) to markdown text
function nodeToText(node: JsonNode): string {
  if (!node.content) return ''
  return node.content.map(child => {
    if (child.type === 'text') {
      let text = child.text || ''
      if (child.marks) {
        for (const mark of child.marks) {
          if (mark.type === 'bold') text = `**${text}**`
          else if (mark.type === 'italic') text = `*${text}*`
          else if (mark.type === 'code') text = `\`${text}\``
          else if (mark.type === 'link' && mark.attrs?.href) text = `[${text}](${mark.attrs.href})`
        }
      }
      return text
    } else if (child.type === 'wikiLink') {
      const path = (child.attrs?.path as string) || ''
      const label = (child.attrs?.label as string | null | undefined) || ''
      return path ? `[[${path}${label ? `|${label}` : ''}]]` : ''
    } else if (child.type === 'hardBreak') {
      return '\n'
    }
    return ''
  }).join('')
}

// Recursively serialize a list node (one line per item; nested lists indented two spaces)
function serializeList(listNode: JsonNode, indent: number): string[] {
  const lines: string[] = []
  const items = (listNode.content || []) as JsonNode[]
  items.forEach((item, index) => {
    const indentStr = '  '.repeat(indent)
    let prefix: string
    if (listNode.type === 'taskList') {
      const checked = item.attrs?.checked ? 'x' : ' '
      prefix = `- [${checked}] `
    } else if (listNode.type === 'orderedList') {
      prefix = `${index + 1}. `
    } else {
      prefix = '- '
    }
    const itemContent = (item.content || []) as JsonNode[]
    let firstPara = true
    itemContent.forEach(child => {
      if (child.type === 'bulletList' || child.type === 'orderedList' || child.type === 'taskList') {
        lines.push(...serializeList(child, indent + 1))
      } else {
        const text = nodeToText(child)
        if (firstPara) {
          lines.push(indentStr + prefix + text)
          firstPara = false
        } else {
          lines.push(indentStr + '  ' + text)
        }
      }
    })
  })
  return lines
}

// Adapter for tiptap's first-party renderTableToMarkdown. Only renderChildren is
// actually invoked — the other helpers are stubs to satisfy the type.
const tableRenderHelpers: MarkdownRendererHelpers = {
  renderChildren: (nodes) => {
    const arr = Array.isArray(nodes) ? nodes : [nodes]
    return arr.map(n => n.type === 'paragraph' ? nodeToText(n as JsonNode) : '').join('')
  },
  wrapInBlock: (prefix, content) => prefix + content,
  indent: (content) => content,
}

// Serialize a single top-level block to its markdown string. Empty paragraphs (or blank-marker
// paragraphs) return '' to signal "blank line slot" for the join logic in serializeBlocksToMarkdown.
function blockToMarkdown(node: JsonNode): string {
  switch (node.type) {
    case 'paragraph': {
      const text = nodeToText(node)
      if (!text || text === BLANK_LINE_MARKER || text.trim() === BLANK_LINE_MARKER) return ''
      return text
    }
    case 'heading': {
      const level = (node.attrs?.level as number) || 1
      return '#'.repeat(level) + ' ' + nodeToText(node)
    }
    case 'bulletList':
    case 'orderedList':
    case 'taskList':
      return serializeList(node, 0).join('\n')
    case 'taskBlock':
      return '```task\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'promptBlock':
      return '```prompt\n' + (node.attrs?.data as string || '') + '\n```'
    case 'imageBlock':
      return '```image\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'embedBlock':
      return '```embed\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'iframeBlock':
      return '```iframe\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'chartBlock':
      return '```chart\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'tableBlock':
      return '```table\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'calendarBlock':
      return '```calendar\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'emailBlock':
      return '```email\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'transcriptBlock':
      return '```transcript\n' + (node.attrs?.data as string || '{}') + '\n```'
    case 'mermaidBlock':
      return '```mermaid\n' + (node.attrs?.data as string || '') + '\n```'
    case 'table':
      return renderTableToMarkdown(node as JSONContent, tableRenderHelpers).trim()
    case 'codeBlock': {
      const lang = (node.attrs?.language as string) || ''
      return '```' + lang + '\n' + nodeToText(node) + '\n```'
    }
    case 'blockquote': {
      const content = (node.content || []) as JsonNode[]
      return content.map(para => '> ' + nodeToText(para)).join('\n')
    }
    case 'horizontalRule':
      return '---'
    case 'wikiLink': {
      const path = (node.attrs?.path as string) || ''
      const label = (node.attrs?.label as string | null | undefined) || ''
      return `[[${path}${label ? `|${label}` : ''}]]`
    }
    case 'image': {
      const src = (node.attrs?.src as string) || ''
      const alt = (node.attrs?.alt as string) || ''
      return `![${alt}](${src})`
    }
    default:
      return ''
  }
}

// Pure helper: serialize a slice of top-level block nodes to markdown.
// Custom join: content blocks get \n\n before them, empty blocks add \n each.
// 1 empty paragraph = 3 newlines on disk (1 blank line).
function serializeBlocksToMarkdown(blocks: JsonNode[]): string {
  if (blocks.length === 0) return ''
  let result = ''
  for (let i = 0; i < blocks.length; i++) {
    const block = blockToMarkdown(blocks[i])
    const isContent = block !== ''
    if (i === 0) {
      result = block
    } else if (isContent) {
      result += '\n\n' + block
    } else {
      result += '\n'
    }
  }
  return result
}

// Custom function to get markdown that preserves empty paragraphs as blank lines
function getMarkdownWithBlankLines(editor: Editor): string {
  const json = editor.getJSON() as JsonNode
  if (!json.content) return ''
  return serializeBlocksToMarkdown(json.content as JsonNode[])
}

// Compute the cursor's 1-indexed line number in the markdown that getMarkdownWithBlankLines
// would produce. Used to attach precise line-references when inserting editor-context mentions.
function getCursorContextLine(editor: Editor): number {
  const $from = editor.state.selection.$from
  const json = editor.getJSON() as JsonNode
  const blocks = (json.content ?? []) as JsonNode[]
  if (blocks.length === 0) return 1

  const blockIndex = $from.index(0)
  if (blockIndex < 0 || blockIndex >= blocks.length) return 1

  // Line where the cursor's top-level block starts.
  // Joining: prefix + '\n\n' + nextContentBlock → next block sits two lines below the prefix's last line.
  let blockStartLine: number
  if (blockIndex === 0) {
    blockStartLine = 1
  } else {
    const prefix = serializeBlocksToMarkdown(blocks.slice(0, blockIndex))
    const prefixLineCount = prefix === '' ? 0 : prefix.split('\n').length
    blockStartLine = prefixLineCount + 2
  }

  return blockStartLine + computeWithinBlockOffset(blocks[blockIndex], $from)
}

// Lines into the cursor's top-level block. 0 for the common single-line cases (paragraph/heading);
// for multi-line containers, computed against how the block serializes.
function computeWithinBlockOffset(
  block: JsonNode,
  $from: { parentOffset: number; depth: number; index: (depth: number) => number }
): number {
  switch (block.type) {
    case 'paragraph':
    case 'heading': {
      // Each hardBreak before the cursor moves us down one rendered line.
      const offset = $from.parentOffset
      let pos = 0
      let hbCount = 0
      for (const child of (block.content ?? [])) {
        if (pos >= offset) break
        const size = child.type === 'text' ? (child.text?.length ?? 0) : 1
        if (child.type === 'hardBreak' && pos < offset) hbCount++
        pos += size
      }
      return hbCount
    }
    case 'bulletList':
    case 'orderedList':
    case 'taskList':
    case 'blockquote':
      // Item index within the container = lines into the block (one item per line for shallow lists/quotes).
      return $from.depth >= 1 ? $from.index(1) : 0
    case 'codeBlock': {
      // +1 for the opening ``` fence line, plus newlines within the code text before the cursor.
      const text = block.content?.[0]?.text ?? ''
      const before = text.substring(0, $from.parentOffset)
      return 1 + (before.match(/\n/g)?.length ?? 0)
    }
    default:
      return 0
  }
}
import { EditorToolbar, type GoogleDocToolbarState, type LivePillState } from './editor-toolbar'
import { useLiveNoteForPath } from '@/hooks/use-live-note-for-path'
import { formatRelativeTime } from '@/lib/relative-time'
import { FrontmatterProperties } from './frontmatter-properties'
import { WikiLink } from '@/extensions/wiki-link'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
import { ensureMarkdownExtension, normalizeWikiPath, splitWikiFragment, wikiLabel } from '@/lib/wiki-links'
import { extractAllFrontmatterValues, buildFrontmatter } from '@/lib/frontmatter'
import { RowboatMentionPopover } from './rowboat-mention-popover'
import '@/styles/editor.css'

type RowboatMentionMatch = {
  range: { from: number; to: number }
}

type RowboatBlockEdit = {
  /** ProseMirror position of the taskBlock node */
  nodePos: number
  /** Existing instruction text */
  existingText: string
}

type WikiLinkConfig = {
  files: string[]
  recent: string[]
  onOpen: (path: string) => void
  onCreate: (path: string) => void | Promise<void>
}

// --- Meeting Event Banner ---

interface ParsedCalendarEvent {
  summary?: string
  start?: string
  end?: string
  location?: string
  htmlLink?: string
  conferenceLink?: string
  source?: string
}

function parseCalendarEvent(raw: string | undefined): ParsedCalendarEvent | null {
  if (!raw) return null
  // Strip surrounding quotes if present (YAML single-quoted string)
  let json = raw
  if ((json.startsWith("'") && json.endsWith("'")) || (json.startsWith('"') && json.endsWith('"'))) {
    json = json.slice(1, -1)
  }
  // Unescape doubled single quotes from YAML
  json = json.replace(/''/g, "'")
  try {
    return JSON.parse(json) as ParsedCalendarEvent
  } catch {
    return null
  }
}

function formatEventTime(start?: string, end?: string): string {
  if (!start) return ''
  const s = new Date(start)
  const startStr = s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
  const startTime = s.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
  if (!end) return `${startStr} \u00b7 ${startTime}`
  const e = new Date(end)
  const endTime = e.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
  return `${startStr} \u00b7 ${startTime} \u2013 ${endTime}`
}

function formatEventDate(start?: string): string {
  if (!start) return ''
  const s = new Date(start)
  const today = new Date()
  const yesterday = new Date(today)
  yesterday.setDate(yesterday.getDate() - 1)
  const tomorrow = new Date(today)
  tomorrow.setDate(tomorrow.getDate() + 1)

  if (s.toDateString() === today.toDateString()) return 'Today'
  if (s.toDateString() === yesterday.toDateString()) return 'Yesterday'
  if (s.toDateString() === tomorrow.toDateString()) return 'Tomorrow'
  return s.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' })
}

function MeetingEventBanner({ frontmatter }: { frontmatter: string | null | undefined }) {
  const [open, setOpen] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!open) return
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
    }
    document.addEventListener('mousedown', handler)
    return () => document.removeEventListener('mousedown', handler)
  }, [open])

  if (!frontmatter) return null
  const fields = extractAllFrontmatterValues(frontmatter)
  if (fields.type !== 'meeting') return null

  const calStr = typeof fields.calendar_event === 'string' ? fields.calendar_event : undefined
  const cal = parseCalendarEvent(calStr)
  if (!cal) return null

  return (
    <div className="meeting-event-banner" ref={ref}>
      <button
        className="meeting-event-pill"
        onClick={() => setOpen(!open)}
      >
        <Calendar size={13} />
        {formatEventDate(cal.start)}
        <ChevronDown size={12} className={`meeting-event-chevron ${open ? 'meeting-event-chevron-open' : ''}`} />
      </button>
      {open && (
        <div className="meeting-event-dropdown">
          <div className="meeting-event-dropdown-header">
            <span className="meeting-event-dropdown-dot" />
            <div className="meeting-event-dropdown-info">
              <div className="meeting-event-dropdown-title">{cal.summary || 'Meeting'}</div>
              <div className="meeting-event-dropdown-time">{formatEventTime(cal.start, cal.end)}</div>
            </div>
          </div>
          {cal.htmlLink && (
            <button
              className="meeting-event-dropdown-link"
              onClick={() => window.open(cal.htmlLink, '_blank')}
            >
              <ExternalLink size={14} />
              Open in Google Calendar
            </button>
          )}
        </div>
      )}
    </div>
  )
}

// --- Editor ---

interface MarkdownEditorProps {
  content: string
  onChange: (markdown: string) => void
  onPrimaryHeadingCommit?: () => void
  preserveUntitledTitleHeading?: boolean
  placeholder?: string
  wikiLinks?: WikiLinkConfig
  onImageUpload?: (file: File) => Promise<string | null>
  editorSessionKey?: number
  onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
  editable?: boolean
  frontmatter?: string | null
  onFrontmatterChange?: (raw: string | null) => void
  onExport?: (format: 'md' | 'pdf' | 'docx') => void
  notePath?: string
  googleDoc?: GoogleDocToolbarState
}

type WikiLinkMatch = {
  range: { from: number; to: number }
  query: string
}

type SelectionHighlightRange = { from: number; to: number } | null

// Plugin key for the selection highlight
const selectionHighlightKey = new PluginKey('selectionHighlight')

// Create the selection highlight extension
const createSelectionHighlightExtension = (getRange: () => SelectionHighlightRange) => {
  return Extension.create({
    name: 'selectionHighlight',
    addProseMirrorPlugins() {
      return [
        new Plugin({
          key: selectionHighlightKey,
          props: {
            decorations(state) {
              const range = getRange()
              if (!range) return DecorationSet.empty

              const { from, to } = range
              if (from >= to || from < 0 || to > state.doc.content.size) {
                return DecorationSet.empty
              }

              const decoration = Decoration.inline(from, to, {
                class: 'selection-highlight',
              })
              return DecorationSet.create(state.doc, [decoration])
            },
          },
        }),
      ]
    },
  })
}

const TabIndentExtension = Extension.create({
  name: 'tabIndent',
  addKeyboardShortcuts() {
    const indentText = '  '
    return {
      Tab: () => {
        // Always handle Tab so focus never leaves the editor.
        // First try list indentation; otherwise insert spaces.
        if (this.editor.can().sinkListItem('taskItem')) {
          void this.editor.commands.sinkListItem('taskItem')
          return true
        }
        if (this.editor.can().sinkListItem('listItem')) {
          void this.editor.commands.sinkListItem('listItem')
          return true
        }
        void this.editor.commands.insertContent(indentText)
        return true
      },
      'Shift-Tab': () => {
        // Always handle Shift+Tab so focus never leaves the editor.
        if (this.editor.can().liftListItem('taskItem')) {
          void this.editor.commands.liftListItem('taskItem')
          return true
        }
        if (this.editor.can().liftListItem('listItem')) {
          void this.editor.commands.liftListItem('listItem')
          return true
        }
        return true
      },
    }
  },
})

const slugifyHeading = (text: string) =>
  text
    .trim()
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')

const decodeLinkTarget = (target: string) => {
  try {
    return decodeURIComponent(target)
  } catch {
    return target
  }
}

const scrollToHeading = (view: EditorView, rawTarget: string) => {
  const target = decodeLinkTarget(rawTarget.replace(/^#/, '')).trim()
  if (!target) return false

  const targetSlug = slugifyHeading(target)
  let foundPos: number | null = null
  view.state.doc.descendants((node, pos) => {
    if (node.type.name !== 'heading') return true
    const headingText = node.textContent.trim()
    if (
      headingText.toLowerCase() === target.toLowerCase()
      || slugifyHeading(headingText) === targetSlug
    ) {
      foundPos = pos
      return false
    }
    return true
  })

  if (foundPos === null) return false

  const selectionPos = Math.min(foundPos + 1, view.state.doc.content.size)
  view.dispatch(
    view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(selectionPos)))
  )
  view.focus()

  const domAtPos = view.domAtPos(foundPos + 1)
  const node = domAtPos.node
  const headingEl = node.nodeType === Node.ELEMENT_NODE
    ? (node as HTMLElement)
    : node.parentElement
  headingEl?.scrollIntoView({ block: 'start', behavior: 'smooth' })
  return true
}

const stripMarkdownExtension = (path: string) =>
  path.toLowerCase().endsWith('.md') ? path.slice(0, -3) : path

const isSameNotePath = (linkPath: string, notePath?: string) => {
  if (!notePath) return false
  const normalizedLink = stripMarkdownExtension(normalizeWikiPath(linkPath)).toLowerCase()
  const normalizedNote = stripMarkdownExtension(normalizeWikiPath(notePath)).toLowerCase()
  return normalizedLink === normalizedNote
}

const isExternalHref = (href: string) =>
  /^(https?:|mailto:|tel:)/i.test(href)

const collapseRelativeSegments = (relPath: string) => {
  const parts = relPath.split('/').filter((part) => part !== '' && part !== '.')
  const stack: string[] = []
  for (const part of parts) {
    if (part === '..') {
      if (stack.length === 0) return null
      stack.pop()
    } else {
      stack.push(part)
    }
  }
  return stack.join('/')
}

const resolveWorkspaceLinkPath = (href: string, notePath?: string) => {
  const withoutHash = href.split('#')[0]
  const withoutQuery = withoutHash.split('?')[0]
  const decoded = decodeLinkTarget(withoutQuery)
  if (!decoded) return null

  if (/^file:\/\//i.test(decoded)) {
    try {
      return decodeURIComponent(new URL(decoded).pathname)
    } catch {
      return decoded
    }
  }

  if (/^[a-zA-Z]:[\\/]/.test(decoded) || decoded.startsWith('/')) return decoded
  if (decoded.startsWith('knowledge/') || !notePath) return collapseRelativeSegments(decoded.replace(/^\.\//, ''))

  const noteDir = notePath.split('/').slice(0, -1).join('/')
  return collapseRelativeSegments(`${noteDir}/${decoded.replace(/^\.\//, '')}`)
}

export interface MarkdownEditorHandle {
  /** Returns {path, lineNumber} for the cursor's current position, or null if no notePath / no editor. */
  getCursorContext: () => { path: string; lineNumber: number } | null
}

export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor({
  content,
  onChange,
  onPrimaryHeadingCommit,
  preserveUntitledTitleHeading = false,
  placeholder = 'Start writing...',
  wikiLinks,
  onImageUpload,
  editorSessionKey = 0,
  onHistoryHandlersChange,
  editable = true,
  frontmatter,
  onFrontmatterChange,
  onExport,
  notePath,
  googleDoc,
}, ref) {
  const isInternalUpdate = useRef(false)
  const wrapperRef = useRef<HTMLDivElement>(null)
  // Read wikiLinks lazily inside the editor config via this ref. wikiLinks changes
  // identity whenever the workspace directory tree changes (file watcher → new file
  // list), and it used to be a useEditor() dependency — so any background write to
  // the workspace destroyed and recreated the entire editor, resetting scroll to the
  // top. Keeping it off the dep array (and reading the ref at event time) means the
  // editor instance survives directory changes.
  const wikiLinksRef = useRef(wikiLinks)
  const [activeWikiLink, setActiveWikiLink] = useState<WikiLinkMatch | null>(null)
  const [anchorPosition, setAnchorPosition] = useState<{ left: number; top: number } | null>(null)
  const [selectionHighlight, setSelectionHighlight] = useState<SelectionHighlightRange>(null)
  const selectionHighlightRef = useRef<SelectionHighlightRange>(null)
  const [wikiCommandValue, setWikiCommandValue] = useState<string>('')
  const onPrimaryHeadingCommitRef = useRef(onPrimaryHeadingCommit)
  const wikiKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
  const handleSelectWikiLinkRef = useRef<(path: string) => void>(() => {})
  const [activeRowboatMention, setActiveRowboatMention] = useState<RowboatMentionMatch | null>(null)
  const [rowboatBlockEdit, setRowboatBlockEdit] = useState<RowboatBlockEdit | null>(null)
  const [rowboatAnchorTop, setRowboatAnchorTop] = useState<{ top: number; left: number; width: number } | null>(null)
  const rowboatBlockEditRef = useRef<RowboatBlockEdit | null>(null)

  // @ mention autocomplete state (analogous to wiki-link state)
  const [activeAtMention, setActiveAtMention] = useState<{ range: { from: number; to: number }; query: string } | null>(null)
  const [atAnchorPosition, setAtAnchorPosition] = useState<{ left: number; top: number } | null>(null)
  const [atCommandValue, setAtCommandValue] = useState<string>('')
  const atKeyStateRef = useRef<{ open: boolean; options: string[]; value: string }>({ open: false, options: [], value: '' })
  const handleSelectAtMentionRef = useRef<(value: string) => void>(() => {})

  // Keep ref in sync with state for the plugin to access
  selectionHighlightRef.current = selectionHighlight
  wikiLinksRef.current = wikiLinks

  // Memoize the selection highlight extension
  const selectionHighlightExtension = useMemo(
    () => createSelectionHighlightExtension(() => selectionHighlightRef.current),
    []
  )

  useEffect(() => {
    onPrimaryHeadingCommitRef.current = onPrimaryHeadingCommit
  }, [onPrimaryHeadingCommit])

  const maybeCommitPrimaryHeading = useCallback((view: EditorView) => {
    const onCommit = onPrimaryHeadingCommitRef.current
    if (!onCommit) return
    const { selection, doc } = view.state
    if (!selection.empty) return

    const { $from } = selection
    if ($from.depth < 1 || $from.index(0) !== 0) return
    if (!['heading', 'paragraph'].includes($from.parent.type.name)) return

    const firstNode = doc.firstChild
    if (!firstNode || !['heading', 'paragraph'].includes(firstNode.type.name)) return

    onCommit()
  }, [])

  const preventTitleHeadingDemotion = useCallback((view: EditorView, event: KeyboardEvent) => {
    if (!preserveUntitledTitleHeading) return false
    if (event.key !== 'Backspace' || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false

    const { selection } = view.state
    if (!selection.empty) return false

    const { $from } = selection
    if ($from.depth < 1 || $from.index(0) !== 0) return false
    if ($from.parent.type.name !== 'heading') return false

    const headingLevel = ((
      $from.parent.attrs as { level?: number } | null | undefined
    )?.level) ?? 0
    if (headingLevel !== 1) return false
    if ($from.parentOffset !== 0) return false
    if ($from.parent.textContent.length > 0) return false

    event.preventDefault()
    return true
  }, [preserveUntitledTitleHeading])

  const promoteFirstParagraphToTitleHeading = useCallback((view: EditorView) => {
    if (!preserveUntitledTitleHeading) return

    const { state, dispatch } = view
    const { selection } = state
    if (!selection.empty) return

    const { $from } = selection
    if ($from.depth < 1 || $from.index(0) !== 0) return
    if ($from.parent.type.name !== 'paragraph') return
    if ($from.parentOffset !== 0) return
    if ($from.parent.textContent.length > 0) return

    const headingType = state.schema.nodes.heading
    if (!headingType) return

    const tr = state.tr.setNodeMarkup($from.before(1), headingType, { level: 1 })
    dispatch(tr)
  }, [preserveUntitledTitleHeading])

  const editor = useEditor({
    editable,
    extensions: [
      StarterKit.configure({
        heading: {
          levels: [1, 2, 3],
        },
        link: false,
      }),
      Link.configure({
        openOnClick: false,
        HTMLAttributes: {
          rel: 'noopener noreferrer',
          target: '_blank',
        },
      }),
      Image.configure({
        inline: false,
        allowBase64: true,
        HTMLAttributes: {
          class: 'editor-image',
        },
      }),
      ImageUploadPlaceholderExtension,
      TaskBlockExtension,
      PromptBlockExtension.configure({ notePath }),
      ImageBlockExtension,
      EmbedBlockExtension,
      IframeBlockExtension,
      ChartBlockExtension,
      TableBlockExtension,
      CalendarBlockExtension,
      EmailsBlockExtension,
      EmailBlockExtension,
      TranscriptBlockExtension,
      MermaidBlockExtension,
      WikiLink.configure({
        onCreate: (path: string) => {
          void wikiLinksRef.current?.onCreate?.(path)
        },
      }),
      TaskList,
      TaskItem.configure({
        nested: true,
      }),
      TableKit.configure({
        table: { resizable: false },
      }),
      Placeholder.configure({
        placeholder,
      }),
      Markdown.configure({
        html: true,
        breaks: true,
        tightLists: false,
        transformCopiedText: true,
        transformPastedText: true,
      }),
      selectionHighlightExtension,
      TabIndentExtension,
    ],
    content: '',
    onUpdate: ({ editor }) => {
      if (isInternalUpdate.current) return
      let markdown = getMarkdownWithBlankLines(editor)
      // Post-process to clean up any markers and ensure blank lines are preserved
      markdown = postprocessMarkdown(markdown)
      onChange(markdown)
    },
    onBlur: ({ editor }) => {
      maybeCommitPrimaryHeading(editor.view)
    },
    editorProps: {
      attributes: {
        class: 'prose prose-sm max-w-none focus:outline-none',
      },
      handleKeyDown: (view, event) => {
        const state = wikiKeyStateRef.current
        if (state.open) {
          if (event.key === 'Escape') {
            event.preventDefault()
            event.stopPropagation()
            setActiveWikiLink(null)
            setAnchorPosition(null)
            setWikiCommandValue('')
            return true
          }

          if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            if (state.options.length === 0) return true
            event.preventDefault()
            event.stopPropagation()
            const currentIndex = Math.max(0, state.options.indexOf(state.value))
            const delta = event.key === 'ArrowDown' ? 1 : -1
            const nextIndex = (currentIndex + delta + state.options.length) % state.options.length
            setWikiCommandValue(state.options[nextIndex])
            return true
          }

          if (event.key === 'Enter' || event.key === 'Tab') {
            if (state.options.length === 0) return true
            event.preventDefault()
            event.stopPropagation()
            const selected = state.options.includes(state.value) ? state.value : state.options[0]
            handleSelectWikiLinkRef.current(selected)
            return true
          }
        }

        // @ mention autocomplete keyboard handling
        const atState = atKeyStateRef.current
        if (atState.open) {
          if (event.key === 'Escape') {
            event.preventDefault()
            event.stopPropagation()
            setActiveAtMention(null)
            setAtAnchorPosition(null)
            setAtCommandValue('')
            return true
          }

          if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            if (atState.options.length === 0) return true
            event.preventDefault()
            event.stopPropagation()
            const currentIndex = Math.max(0, atState.options.indexOf(atState.value))
            const delta = event.key === 'ArrowDown' ? 1 : -1
            const nextIndex = (currentIndex + delta + atState.options.length) % atState.options.length
            setAtCommandValue(atState.options[nextIndex])
            return true
          }

          if (event.key === 'Enter' || event.key === 'Tab') {
            if (atState.options.length === 0) return true
            event.preventDefault()
            event.stopPropagation()
            const selected = atState.options.includes(atState.value) ? atState.value : atState.options[0]
            handleSelectAtMentionRef.current(selected)
            return true
          }
        }

        if (preventTitleHeadingDemotion(view, event)) {
          return true
        }

        const isPrintableKey = event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey
        if (isPrintableKey) {
          promoteFirstParagraphToTitleHeading(view)
        }

        if (
          event.key === 'Enter'
          && !event.shiftKey
          && !event.ctrlKey
          && !event.metaKey
          && !event.altKey
        ) {
          maybeCommitPrimaryHeading(view)
        }

        return false
      },
      handleClickOn: (_view, _pos, node, _nodePos, event) => {
        if (node.type.name === 'wikiLink') {
          event.preventDefault()
          const wikiPath = String(node.attrs.path ?? '')
          const { path: linkedNotePath, heading } = splitWikiFragment(wikiPath)
          if (heading && (!linkedNotePath || isSameNotePath(linkedNotePath, notePath))) {
            return scrollToHeading(_view, heading)
          }
          wikiLinksRef.current?.onOpen?.(node.attrs.path)
          return true
        }
        return false
      },
      handleDOMEvents: {
        click: (view, event) => {
          const target = event.target as Element | null
          const link = target?.closest('a[href]') as HTMLAnchorElement | null
          if (!link) return false
          if (link.dataset.type === 'wiki-link') return false

          const href = link.getAttribute('href') ?? ''
          if (!href) return false

          if (href.startsWith('#')) {
            event.preventDefault()
            return scrollToHeading(view, href)
          }

          if (isExternalHref(href)) {
            event.preventDefault()
            window.open(href, '_blank')
            return true
          }

          const workspacePath = resolveWorkspaceLinkPath(href, notePath)
          if (!workspacePath) return false

          event.preventDefault()
          void window.ipc.invoke('shell:openPath', { path: workspacePath }).then((result) => {
            if (result.error) console.error('Failed to open linked file:', result.error)
          }).catch((err) => {
            console.error('Failed to open linked file:', err)
          })
          return true
        },
      },
    },
    // NOTE: wikiLinks is intentionally NOT a dependency — it's read via wikiLinksRef
    // at event time. Including it rebuilds the whole editor on every directory change
    // (file watcher), which resets scroll to the top. See wikiLinksRef declaration.
  }, [
    editorSessionKey,
    maybeCommitPrimaryHeading,
    notePath,
    preventTitleHeadingDemotion,
    promoteFirstParagraphToTitleHeading,
  ])

  const orderedFiles = useMemo(() => {
    if (!wikiLinks) return []
    const seen = new Set<string>()
    const ordered: string[] = []

    const addPath = (path: string) => {
      const normalized = normalizeWikiPath(path)
      if (!normalized || seen.has(normalized)) return
      seen.add(normalized)
      ordered.push(normalized)
    }

    wikiLinks.recent.forEach(addPath)
    wikiLinks.files.forEach(addPath)

    return ordered
  }, [wikiLinks])

  const updateWikiLinkState = useCallback(() => {
    if (!editor || !wikiLinks) return
    const { selection } = editor.state
    if (!selection.empty) {
      setActiveWikiLink(null)
      setAnchorPosition(null)
      return
    }

    const { $from } = selection
    if ($from.parent.type.spec.code) {
      setActiveWikiLink(null)
      setAnchorPosition(null)
      return
    }
    if ($from.marks().some((mark) => mark.type.spec.code)) {
      setActiveWikiLink(null)
      setAnchorPosition(null)
      return
    }

    const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
    const textBefore = text.slice(0, $from.parentOffset)
    const triggerIndex = textBefore.lastIndexOf('[[')
    if (triggerIndex === -1 || textBefore.indexOf(']]', triggerIndex) !== -1) {
      setActiveWikiLink(null)
      setAnchorPosition(null)
      return
    }

    const matchText = textBefore.slice(triggerIndex)
    const query = matchText.slice(2)
    const range = { from: selection.from - matchText.length, to: selection.from }
    setActiveWikiLink({ range, query })

    const wrapper = wrapperRef.current
    if (!wrapper) {
      setAnchorPosition(null)
      return
    }

    const coords = editor.view.coordsAtPos(selection.from)
    const wrapperRect = wrapper.getBoundingClientRect()
    setAnchorPosition({
      left: coords.left - wrapperRect.left,
      top: coords.bottom - wrapperRect.top,
    })
  }, [editor, wikiLinks])

  useImperativeHandle(ref, () => ({
    getCursorContext: () => {
      if (!notePath || !editor) return null
      try {
        return { path: notePath, lineNumber: getCursorContextLine(editor) }
      } catch {
        return null
      }
    },
  }), [notePath, editor])

  const updateRowboatMentionState = useCallback(() => {
    if (!editor) return
    const { selection } = editor.state
    if (!selection.empty) {
      setActiveRowboatMention(null)
      setRowboatAnchorTop(null)
      return
    }

    const { $from } = selection
    if ($from.parent.type.spec.code) {
      setActiveRowboatMention(null)
      setRowboatAnchorTop(null)
      return
    }

    const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
    const textBefore = text.slice(0, $from.parentOffset)

    // Match @rowboat at a word boundary (preceded by nothing or whitespace)
    const match = textBefore.match(/(^|\s)@rowboat$/)
    if (!match) {
      setActiveRowboatMention(null)
      setRowboatAnchorTop(null)
      return
    }

    const triggerStart = textBefore.length - '@rowboat'.length
    const from = selection.from - (textBefore.length - triggerStart)
    const to = selection.from
    setActiveRowboatMention({ range: { from, to } })

    const wrapper = wrapperRef.current
    if (!wrapper) {
      setRowboatAnchorTop(null)
      return
    }

    const coords = editor.view.coordsAtPos(selection.from)
    const wrapperRect = wrapper.getBoundingClientRect()
    const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
    const pmRect = proseMirrorEl?.getBoundingClientRect()
    setRowboatAnchorTop({
      top: coords.top - wrapperRect.top + wrapper.scrollTop,
      left: pmRect ? pmRect.left - wrapperRect.left : 0,
      width: pmRect ? pmRect.width : wrapperRect.width,
    })
  }, [editor])

  // Detect @ trigger for autocomplete popover (similar to [[ detection)
  const updateAtMentionState = useCallback(() => {
    if (!editor) return
    const { selection } = editor.state
    if (!selection.empty) {
      setActiveAtMention(null)
      setAtAnchorPosition(null)
      return
    }

    const { $from } = selection
    // Skip code blocks
    if ($from.parent.type.spec.code) {
      setActiveAtMention(null)
      setAtAnchorPosition(null)
      return
    }
    // Skip inline code marks
    if ($from.marks().some((mark) => mark.type.spec.code)) {
      setActiveAtMention(null)
      setAtAnchorPosition(null)
      return
    }

    const text = $from.parent.textBetween(0, $from.parent.content.size, '\n', '\n')
    const textBefore = text.slice(0, $from.parentOffset)

    // Find @ at a word boundary (start of line or preceded by whitespace)
    const atMatch = textBefore.match(/(^|[\s])@([a-zA-Z0-9]*)$/)
    if (!atMatch) {
      setActiveAtMention(null)
      setAtAnchorPosition(null)
      return
    }

    const query = atMatch[2] // text after @

    // If the full "@rowboat" is already typed, let updateRowboatMentionState handle it
    if (query === 'rowboat') {
      setActiveAtMention(null)
      setAtAnchorPosition(null)
      return
    }

    const atSymbolOffset = textBefore.lastIndexOf('@')
    const matchText = textBefore.slice(atSymbolOffset)
    const range = { from: selection.from - matchText.length, to: selection.from }
    setActiveAtMention({ range, query })

    const wrapper = wrapperRef.current
    if (!wrapper) {
      setAtAnchorPosition(null)
      return
    }

    const coords = editor.view.coordsAtPos(selection.from)
    const wrapperRect = wrapper.getBoundingClientRect()
    setAtAnchorPosition({
      left: coords.left - wrapperRect.left,
      top: coords.bottom - wrapperRect.top,
    })
  }, [editor])

  useEffect(() => {
    if (!editor || !wikiLinks) return
    editor.on('update', updateWikiLinkState)
    editor.on('selectionUpdate', updateWikiLinkState)
    return () => {
      editor.off('update', updateWikiLinkState)
      editor.off('selectionUpdate', updateWikiLinkState)
    }
  }, [editor, wikiLinks, updateWikiLinkState])

  useEffect(() => {
    if (!editor) return
    editor.on('update', updateRowboatMentionState)
    editor.on('selectionUpdate', updateRowboatMentionState)
    return () => {
      editor.off('update', updateRowboatMentionState)
      editor.off('selectionUpdate', updateRowboatMentionState)
    }
  }, [editor, updateRowboatMentionState])

  useEffect(() => {
    if (!editor) return
    editor.on('update', updateAtMentionState)
    editor.on('selectionUpdate', updateAtMentionState)
    return () => {
      editor.off('update', updateAtMentionState)
      editor.off('selectionUpdate', updateAtMentionState)
    }
  }, [editor, updateAtMentionState])

  // When a tell-rowboat block is clicked, compute anchor and open popover
  useEffect(() => {
    if (!rowboatBlockEdit || !editor) return
    const wrapper = wrapperRef.current
    if (!wrapper) return
    const coords = editor.view.coordsAtPos(rowboatBlockEdit.nodePos)
    const wrapperRect = wrapper.getBoundingClientRect()
    const proseMirrorEl = wrapper.querySelector('.ProseMirror') as HTMLElement | null
    const pmRect = proseMirrorEl?.getBoundingClientRect()
    setRowboatAnchorTop({
      top: coords.top - wrapperRect.top + wrapper.scrollTop,
      left: pmRect ? pmRect.left - wrapperRect.left : 0,
      width: pmRect ? pmRect.width : wrapperRect.width,
    })
  }, [editor, rowboatBlockEdit])

  // Update editor content when prop changes (e.g., file selection changes)
  useEffect(() => {
    if (editor && content !== undefined) {
      const currentContent = getMarkdownWithBlankLines(editor)
      // Normalize for comparison (trim trailing whitespace from lines)
      const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
      if (normalizeForCompare(currentContent) !== normalizeForCompare(content)) {
        // Preserve scroll + selection across an external content sync. setContent()
        // resets the selection to the top of the doc and ProseMirror scrolls it into
        // view; without restoring, a background writer touching the open file (graph
        // builder, live-note runner, version-history commit) yanks the viewport back
        // to the top repeatedly — making the note impossible to scroll. This editor
        // instance is bound to a single note path, so the prior scrollTop is always
        // valid for the reloaded content.
        const wrapper = wrapperRef.current
        const prevScrollTop = wrapper?.scrollTop ?? 0
        const hadFocus = editor.isFocused
        const { from: prevFrom, to: prevTo } = editor.state.selection

        isInternalUpdate.current = true
        const preprocessed = preprocessMarkdown(content)
        // Treat tab-open content as baseline: do not add hydration to undo history.
        editor.chain().setMeta('addToHistory', false).setContent(preprocessed).run()

        // Only restore the caret for a focused editor, so we never steal focus or
        // scroll for a passive viewer. Clamp to the (possibly shorter) new doc.
        if (hadFocus) {
          const docSize = editor.state.doc.content.size
          const from = Math.min(prevFrom, docSize)
          const to = Math.min(prevTo, docSize)
          try {
            editor.chain().setMeta('addToHistory', false).setTextSelection({ from, to }).run()
          } catch { /* selection no longer valid in the new doc — ignore */ }
        }
        isInternalUpdate.current = false

        // Restore scroll last so it wins over any scrollIntoView triggered above.
        if (wrapper) wrapper.scrollTop = prevScrollTop
      }
    }
  }, [editor, content])

  useEffect(() => {
    if (!onHistoryHandlersChange) return
    if (!editor) {
      onHistoryHandlersChange(null)
      return
    }

    onHistoryHandlersChange({
      undo: () => editor.chain().focus().undo().run(),
      redo: () => editor.chain().focus().redo().run(),
    })

    return () => {
      onHistoryHandlersChange(null)
    }
  }, [editor, onHistoryHandlersChange])

  // Update editable state when prop changes
  useEffect(() => {
    if (editor) {
      editor.setEditable(editable)
    }
  }, [editor, editable])

  // Force re-render decorations when selection highlight changes
  useEffect(() => {
    if (editor) {
      // Trigger a transaction to force decoration re-render
      editor.view.dispatch(editor.state.tr)
    }
  }, [editor, selectionHighlight])

  const normalizedQuery = normalizeWikiPath(activeWikiLink?.query ?? '').toLowerCase()
  const filteredFiles = useMemo(() => {
    if (!activeWikiLink) return []
    if (!normalizedQuery) return orderedFiles
    return orderedFiles.filter((path) => path.toLowerCase().includes(normalizedQuery))
  }, [activeWikiLink, normalizedQuery, orderedFiles])

  const visibleFiles = filteredFiles.slice(0, 12)
  const rawCreateCandidate = activeWikiLink ? normalizeWikiPath(activeWikiLink.query) : ''
  const createCandidate = rawCreateCandidate && !rawCreateCandidate.endsWith('/')
    ? ensureMarkdownExtension(rawCreateCandidate)
    : ''
  const canCreate = Boolean(
    createCandidate
      && !orderedFiles.some((path) => path.toLowerCase() === createCandidate.toLowerCase())
  )

  const handleSelectWikiLink = useCallback((path: string) => {
    if (!editor || !activeWikiLink) return
    const normalized = normalizeWikiPath(path)
    if (!normalized) return
    const finalPath = ensureMarkdownExtension(normalized)
    void wikiLinks?.onCreate?.(finalPath)

    editor
      .chain()
      .focus()
      .insertContentAt(
        { from: activeWikiLink.range.from, to: activeWikiLink.range.to },
        { type: 'wikiLink', attrs: { path: finalPath } }
      )
      .run()

    setActiveWikiLink(null)
    setAnchorPosition(null)
  }, [editor, activeWikiLink, wikiLinks])

  useEffect(() => {
    handleSelectWikiLinkRef.current = handleSelectWikiLink
  }, [handleSelectWikiLink])

  const handleRowboatAdd = useCallback(async (instruction: string) => {
    if (!editor) return

    if (rowboatBlockEdit) {
      // Editing existing taskBlock — update its data attribute
      const { nodePos } = rowboatBlockEdit
      const node = editor.state.doc.nodeAt(nodePos)
      if (node && node.type.name === 'taskBlock') {
        // Preserve existing schedule data
        let updated: Record<string, unknown> = { instruction }
        try {
          const existing = JSON.parse(node.attrs.data || '{}')
          updated = { ...existing, instruction }
        } catch {
          // Invalid JSON — just write new
        }
        const tr = editor.state.tr.setNodeMarkup(nodePos, undefined, { data: JSON.stringify(updated) })
        editor.view.dispatch(tr)
      }
      setRowboatBlockEdit(null)
      rowboatBlockEditRef.current = null
      setRowboatAnchorTop(null)
      return
    }

    if (activeRowboatMention) {
      // Insert a temporary processing block
      const blockData: Record<string, unknown> = { instruction, processing: true }

      const insertFrom = activeRowboatMention.range.from
      const insertTo = activeRowboatMention.range.to

      editor
        .chain()
        .focus()
        .insertContentAt(
          { from: insertFrom, to: insertTo },
          [
            { type: 'taskBlock', attrs: { data: JSON.stringify(blockData) } },
            { type: 'paragraph' },
          ],
        )
        .run()

      setActiveRowboatMention(null)
      setRowboatAnchorTop(null)

      // Get editor content for the agent
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const editorContent = (editor.storage as any).markdown?.getMarkdown?.() ?? ''

      // Helper to find the processing block
      const findProcessingBlock = (): number | null => {
        let pos: number | null = null
        editor.state.doc.descendants((node, p) => {
          if (pos !== null) return false
          if (node.type.name === 'taskBlock') {
            try {
              const data = JSON.parse(node.attrs.data || '{}')
              if (data.instruction === instruction && data.processing === true) {
                pos = p
                return false
              }
            } catch { /* skip */ }
          }
        })
        return pos
      }

      try {
        // Call the copilot assistant for both one-time and recurring tasks
        const result = await window.ipc.invoke('inline-task:process', {
          instruction,
          noteContent: editorContent,
          notePath: notePath ?? '',
        })

        const currentPos = findProcessingBlock()
        if (currentPos === null) return

        const node = editor.state.doc.nodeAt(currentPos)
        if (!node) return

        if (result.schedule) {
          // Recurring/scheduled task: update block with schedule, write target tags to disk
          const targetId = Math.random().toString(36).slice(2, 10)
          const updatedData: Record<string, unknown> = {
            instruction: result.instruction,
            schedule: result.schedule,
            'schedule-label': result.scheduleLabel,
            targetId,
          }
          const tr = editor.state.tr.setNodeMarkup(currentPos, undefined, {
            data: JSON.stringify(updatedData),
          })
          editor.view.dispatch(tr)

          // Mark note as live
          if (onFrontmatterChange) {
            const fields = extractAllFrontmatterValues(frontmatter ?? null)
            fields['live_note'] = 'true'
            onFrontmatterChange(buildFrontmatter(fields))
          }

          // Write target tags directly to the file on disk after a short delay
          // to let the editor save the updated content first
          if (notePath) {
            setTimeout(async () => {
              try {
                const file = await window.ipc.invoke('workspace:readFile', { path: notePath })
                const content = file.data
                const openTag = `<!--task-target:${targetId}-->`
                const closeTag = `<!--/task-target:${targetId}-->`

                // Only add if not already present
                if (content.includes(openTag)) return

                // Find the task block in the raw markdown and insert target tags after it
                const blockJson = JSON.stringify(updatedData)
                const blockStart = content.indexOf('```task\n' + blockJson)
                if (blockStart !== -1) {
                  const blockEnd = content.indexOf('\n```', blockStart + 8)
                  if (blockEnd !== -1) {
                    const insertAt = blockEnd + 4 // after the closing ```
                    const before = content.slice(0, insertAt)
                    const after = content.slice(insertAt)
                    const updated = before + '\n\n' + openTag + '\n' + closeTag + after
                    await window.ipc.invoke('workspace:writeFile', {
                      path: notePath,
                      data: updated,
                      opts: { encoding: 'utf8' },
                    })
                  }
                }
              } catch (err) {
                console.error('[RowboatAdd] Failed to write target tags:', err)
              }
            }, 500)
          }
        } else {
          // One-time task: remove the processing block, insert response in its place
          const insertPos = currentPos
          const deleteEnd = currentPos + node.nodeSize
          editor.chain().focus().deleteRange({ from: insertPos, to: deleteEnd }).run()

          if (result.response) {
            editor.chain().insertContentAt(insertPos, result.response).run()
          }
        }
      } catch (error) {
        console.error('[RowboatAdd] Processing failed:', error)

        // Remove the processing block on error
        const currentPos = findProcessingBlock()
        if (currentPos !== null) {
          const node = editor.state.doc.nodeAt(currentPos)
          if (node) {
            editor.chain().focus().deleteRange({ from: currentPos, to: currentPos + node.nodeSize }).run()
          }
        }
      }
    }
  }, [editor, activeRowboatMention, rowboatBlockEdit, frontmatter, onFrontmatterChange, notePath])

  const handleRowboatRemove = useCallback(() => {
    if (!editor || !rowboatBlockEdit) return
    const { nodePos } = rowboatBlockEdit
    const node = editor.state.doc.nodeAt(nodePos)
    if (node) {
      editor
        .chain()
        .focus()
        .deleteRange({ from: nodePos, to: nodePos + node.nodeSize })
        .run()
    }
    setRowboatBlockEdit(null)
    rowboatBlockEditRef.current = null
    setRowboatAnchorTop(null)
  }, [editor, rowboatBlockEdit])

  const handleScroll = useCallback(() => {
    updateWikiLinkState()
    updateAtMentionState()
  }, [updateWikiLinkState, updateAtMentionState])

  const showWikiPopover = Boolean(wikiLinks && activeWikiLink && anchorPosition)
  const wikiOptions = useMemo(() => {
    if (!showWikiPopover) return []
    const options: string[] = []
    if (canCreate) options.push(createCandidate)
    options.push(...visibleFiles)
    return options
  }, [showWikiPopover, canCreate, createCandidate, visibleFiles])

  useEffect(() => {
    wikiKeyStateRef.current = { open: showWikiPopover, options: wikiOptions, value: wikiCommandValue }
  }, [showWikiPopover, wikiOptions, wikiCommandValue])

  // Keep cmdk selection in sync with available options
  useEffect(() => {
    if (!showWikiPopover) {
      setWikiCommandValue('')
      return
    }
    if (wikiOptions.length === 0) {
      setWikiCommandValue('')
      return
    }
    setWikiCommandValue((prev) => (wikiOptions.includes(prev) ? prev : wikiOptions[0]))
  }, [showWikiPopover, wikiOptions])

  // @ mention autocomplete options
  const atMentionOptions = useMemo(() => [
    { value: 'rowboat', label: '@rowboat', description: 'Research, schedule, or run tasks with AI' },
  ], [])

  const filteredAtOptions = useMemo(() => {
    if (!activeAtMention) return []
    const q = activeAtMention.query.toLowerCase()
    if (!q) return atMentionOptions
    return atMentionOptions.filter((opt) => opt.value.toLowerCase().startsWith(q))
  }, [activeAtMention, atMentionOptions])

  const atOptionValues = useMemo(() => filteredAtOptions.map((o) => o.value), [filteredAtOptions])
  const showAtPopover = Boolean(activeAtMention && atAnchorPosition && filteredAtOptions.length > 0)

  useEffect(() => {
    atKeyStateRef.current = { open: showAtPopover, options: atOptionValues, value: atCommandValue }
  }, [showAtPopover, atOptionValues, atCommandValue])

  // Keep @ cmdk selection in sync
  useEffect(() => {
    if (!showAtPopover) {
      setAtCommandValue('')
      return
    }
    if (atOptionValues.length === 0) {
      setAtCommandValue('')
      return
    }
    setAtCommandValue((prev) => (atOptionValues.includes(prev) ? prev : atOptionValues[0]))
  }, [showAtPopover, atOptionValues])

  // @ mention selection handler
  const handleSelectAtMention = useCallback((value: string) => {
    if (!editor || !activeAtMention) return

    if (value === 'rowboat') {
      // Replace "@<partial>" with "@rowboat" — this triggers updateRowboatMentionState
      editor
        .chain()
        .focus()
        .insertContentAt(
          { from: activeAtMention.range.from, to: activeAtMention.range.to },
          '@rowboat'
        )
        .run()
    }

    setActiveAtMention(null)
    setAtAnchorPosition(null)
    setAtCommandValue('')
  }, [editor, activeAtMention])

  useEffect(() => {
    handleSelectAtMentionRef.current = handleSelectAtMention
  }, [handleSelectAtMention])

  // Handle keyboard shortcuts
  const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
    if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
      event.preventDefault()
      // The parent component handles saving via onChange
    }
  }, [])

  // Create image upload handler that shows placeholder
  const handleImageUploadWithPlaceholder = useMemo(() => {
    if (!editor || !onImageUpload) return undefined
    return createImageUploadHandler(editor, onImageUpload)
  }, [editor, onImageUpload])

  // Live-note pill state for the toolbar — derived from the on-disk `live:`
  // block plus the agent-status bus. The `tick` dependency keeps the relative
  // time label fresh as minutes roll over.
  const { live: currentLive, isRunning: liveIsRunning, tick: liveTick } = useLiveNoteForPath(notePath)
  const livePillStateForCurrentNote: LivePillState = useMemo(() => {
    void liveTick // re-run on tick to refresh relative-time label
    if (!currentLive) return { variant: 'passive', label: 'Make live' }
    if (liveIsRunning) return { variant: 'running', label: 'Updating…' }
    if (currentLive.lastRunError) {
      const when = currentLive.lastAttemptAt ? formatRelativeTime(currentLive.lastAttemptAt) : ''
      return { variant: 'error', label: when ? `Live · failed ${when}` : 'Live · failed' }
    }
    if (currentLive.active === false) return { variant: 'passive', label: 'Live · paused' }
    if (currentLive.lastRunAt) {
      const when = formatRelativeTime(currentLive.lastRunAt)
      return { variant: 'idle', label: when ? `Live · ${when}` : 'Live' }
    }
    return { variant: 'idle', label: 'Live · never run' }
  }, [currentLive, liveIsRunning, liveTick])

  return (
    <div className="tiptap-editor" onKeyDown={handleKeyDown}>
      <EditorToolbar
        editor={editor}
        onSelectionHighlight={setSelectionHighlight}
        onImageUpload={handleImageUploadWithPlaceholder}
        onExport={onExport}
        googleDoc={googleDoc}
        onOpenLiveNote={notePath ? () => {
          window.dispatchEvent(new CustomEvent('rowboat:open-live-note-panel', {
            detail: { filePath: notePath },
          }))
        } : undefined}
        liveState={notePath ? livePillStateForCurrentNote : undefined}
      />
      {(frontmatter !== undefined) && onFrontmatterChange && (
        <FrontmatterProperties
          raw={frontmatter}
          onRawChange={onFrontmatterChange}
          editable={editable}
        />
      )}
      <MeetingEventBanner frontmatter={frontmatter} />
      <div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
        <EditorContent editor={editor} />
        {wikiLinks ? (
          <Popover
            open={showWikiPopover}
            onOpenChange={(open) => {
              if (!open) {
                setActiveWikiLink(null)
                setAnchorPosition(null)
                setWikiCommandValue('')
              }
            }}
          >
            <PopoverAnchor asChild>
              <span
                className="wiki-link-anchor"
                style={
                  anchorPosition
                    ? { left: anchorPosition.left, top: anchorPosition.top }
                    : undefined
                }
              />
            </PopoverAnchor>
            <PopoverContent
              className="w-72 p-1"
              align="start"
              side="bottom"
              onOpenAutoFocus={(event) => event.preventDefault()}
            >
              <Command shouldFilter={false} value={wikiCommandValue} onValueChange={setWikiCommandValue}>
                <CommandList>
                  {canCreate ? (
                    <CommandItem
                      value={createCandidate}
                      onSelect={() => handleSelectWikiLink(createCandidate)}
                    >
                      Create "{wikiLabel(createCandidate) || createCandidate}"
                    </CommandItem>
                  ) : null}
                  {visibleFiles.map((path) => (
                    <CommandItem
                      key={path}
                      value={path}
                      onSelect={() => handleSelectWikiLink(path)}
                    >
                      {wikiLabel(path)}
                    </CommandItem>
                  ))}
                  {visibleFiles.length === 0 && !canCreate ? (
                    <CommandEmpty>No matches found.</CommandEmpty>
                  ) : null}
                </CommandList>
              </Command>
            </PopoverContent>
          </Popover>
        ) : null}
        {/* @ mention autocomplete popover */}
        <Popover
          open={showAtPopover}
          onOpenChange={(open) => {
            if (!open) {
              setActiveAtMention(null)
              setAtAnchorPosition(null)
              setAtCommandValue('')
            }
          }}
        >
          <PopoverAnchor asChild>
            <span
              className="wiki-link-anchor"
              style={
                atAnchorPosition
                  ? { left: atAnchorPosition.left, top: atAnchorPosition.top }
                  : undefined
              }
            />
          </PopoverAnchor>
          <PopoverContent
            className="w-72 p-1"
            align="start"
            side="bottom"
            onOpenAutoFocus={(event) => event.preventDefault()}
          >
            <Command shouldFilter={false} value={atCommandValue} onValueChange={setAtCommandValue}>
              <CommandList>
                {filteredAtOptions.map((opt) => (
                  <CommandItem
                    key={opt.value}
                    value={opt.value}
                    onSelect={() => handleSelectAtMention(opt.value)}
                  >
                    <div className="flex flex-col">
                      <span className="font-medium">{opt.label}</span>
                      <span className="text-xs text-muted-foreground">{opt.description}</span>
                    </div>
                  </CommandItem>
                ))}
              </CommandList>
            </Command>
          </PopoverContent>
        </Popover>
        <RowboatMentionPopover
          open={Boolean((activeRowboatMention || rowboatBlockEdit) && rowboatAnchorTop)}
          anchor={rowboatAnchorTop}
          initialText={rowboatBlockEdit?.existingText ?? ''}
          onAdd={handleRowboatAdd}
          onRemove={rowboatBlockEdit ? handleRowboatRemove : undefined}
          onClose={() => {
            setActiveRowboatMention(null)
            setRowboatBlockEdit(null)
            rowboatBlockEditRef.current = null
            setRowboatAnchorTop(null)
          }}
        />
      </div>
    </div>
  )
})
