'use client'

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import { MediaPickerModal } from './MediaPickerModal.js'
import { AICoauthorBubbleMenu } from './AICoauthorBubbleMenu.js'
import { PresenceChips } from './PresenceChips.js'
import { OfflineStatus } from './OfflineStatus.js'
import { CommentMark, COMMENT_MARK_STYLES } from './CommentMark.js'
import { CommentSidePanel } from './CommentSidePanel.js'
import { clearActiveEditorIf, setActiveEditor } from '../lib/active-editor.js'
import {
  createCollaborationProvider,
  type CollaborationContext,
  type CollaborationStatus,
  type CollaborationUser,
  type OfflinePersistenceStatus,
} from '../lib/collaboration-provider.js'
import { encodeAnchor } from '../lib/comment-anchor.js'
import type { CommentAnchor } from '../lib/comments-client.js'
import * as Y from 'yjs'
import StarterKit from '@tiptap/starter-kit'
import UnderlineExt from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
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 TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import HorizontalRule from '@tiptap/extension-horizontal-rule'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import { common, createLowlight } from 'lowlight'
import {
  Bold,
  Italic,
  Underline,
  Strikethrough,
  Code,
  Heading1,
  Heading2,
  Heading3,
  List,
  ListOrdered,
  Quote,
  FileCode,
  Minus,
  AlignLeft,
  AlignCenter,
  AlignRight,
  Link as LinkIcon,
  Image as ImageIcon,
  Table as TableIcon,
  Columns,
  Rows,
  Trash2,
  Undo,
  Redo,
} from 'lucide-react'

const lowlight = createLowlight(common)

/**
 * Configuration for real-time collaboration on this editor.
 *
 * When supplied the editor switches into CRDT mode: history is delegated to
 * Yjs, edits sync through the cms-core realtime gateway, and a presence
 * strip with live cursors appears beside the toolbar. The `content` prop is
 * ignored in collaboration mode (the gateway is the source of truth);
 * `onChange` still fires on every local update so existing autosave wiring
 * keeps working.
 */
export interface TipTapCollaborationConfig {
  documentId: string
  /**
   * Base URL of the realtime gateway. `wss://your.app/api/cms/realtime/sync`
   * in production, `ws://localhost:3000/api/cms/realtime/sync` for dev.
   */
  url: string
  user: CollaborationUser
  /** Extra query params (e.g. auth token for cross-origin deployments). */
  params?: Record<string, string>
}

export interface TipTapEditorProps {
  content: string
  onChange: (html: string) => void
  placeholder?: string
  editable?: boolean
  className?: string
  /**
   * Toggle the AI co-author bubble menu on text selection.
   * Defaults to `true` when the editor is editable. Set to `false` to opt
   * out (e.g. for preview-only renders, or environments where the
   * `/ai/coauthor` endpoint is intentionally disabled).
   */
  enableAI?: boolean
  /**
   * Enable real-time collaboration via the cms-core realtime gateway. When
   * set, the editor swaps in Yjs-backed history + the collaboration cursor
   * extension and renders a presence strip above the toolbar.
   */
  collaboration?: TipTapCollaborationConfig
  /**
   * Enable inline comments. When set, the `CommentMark` extension is
   * loaded and a `CommentSidePanel` renders next to the editor. Anchors
   * are encoded against the active Yjs text node when collaboration is
   * also on, so highlights survive concurrent edits.
   *
   * Comments are independent of the collaboration prop — you can attach
   * comments to a static document, but cross-user "live" highlighting
   * only kicks in when collaboration is also enabled.
   */
  comments?: TipTapCommentsConfig
}

/**
 * Configuration for the comments side panel. The panel issues REST calls
 * against the slice-4 endpoints; this prop is just the small set of
 * presentation + identity bits the panel can't infer from the editor.
 */
export interface TipTapCommentsConfig {
  documentId: string
  /** Logged-in user id — gates edit + delete affordances. */
  currentUserId: string
  /** Set to true to expose moderation actions on every comment. */
  isAdmin?: boolean
  /** Error sink so the host shell can show a toast. */
  onError?: (message: string) => void
}

const editorStyles = `
.ProseMirror {
  min-height: 300px;
  outline: none;
  padding: 1rem;
}
.ProseMirror h1 { font-size: 2em; font-weight: 700; margin: 0.67em 0; }
.ProseMirror h2 { font-size: 1.5em; font-weight: 700; margin: 0.75em 0; }
.ProseMirror h3 { font-size: 1.17em; font-weight: 700; margin: 0.83em 0; }
.ProseMirror h4 { font-size: 1em; font-weight: 700; margin: 1em 0; }
.ProseMirror h5 { font-size: 0.83em; font-weight: 700; margin: 1.17em 0; }
.ProseMirror h6 { font-size: 0.67em; font-weight: 700; margin: 1.33em 0; }
.ProseMirror ul { list-style: disc; padding-left: 1.5rem; margin: 0.5em 0; }
.ProseMirror ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5em 0; }
.ProseMirror li { margin: 0.25em 0; }
.ProseMirror p { margin: 0.5em 0; }
.ProseMirror table {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}
.ProseMirror th,
.ProseMirror td {
  border: 1px solid #d1d5db;
  padding: 0.5rem;
  text-align: left;
  min-width: 80px;
}
.ProseMirror th {
  background-color: #f3f4f6;
  font-weight: 600;
}
.ProseMirror img {
  max-width: 100%;
  height: auto;
  border-radius: 0.375rem;
}
.ProseMirror blockquote {
  border-left: 3px solid #3b82f6;
  padding-left: 1rem;
  margin: 1rem 0;
  color: #4b5563;
}
.ProseMirror pre {
  background: #1e293b;
  color: #e2e8f0;
  padding: 1rem;
  border-radius: 0.5rem;
  overflow-x: auto;
  margin: 0.75rem 0;
}
.ProseMirror code {
  background: #f1f5f9;
  padding: 0.125rem 0.375rem;
  border-radius: 0.25rem;
  font-size: 0.875em;
}
.ProseMirror pre code {
  background: none;
  padding: 0;
  border-radius: 0;
  font-size: inherit;
}
.ProseMirror hr {
  border: none;
  border-top: 2px solid #e5e7eb;
  margin: 1.5rem 0;
}
.ProseMirror p.is-editor-empty:first-child::before {
  content: attr(data-placeholder);
  float: left;
  color: #9ca3af;
  pointer-events: none;
  height: 0;
}
.ProseMirror .selectedCell {
  background-color: #dbeafe;
}
`

function ToolbarButton({
  onClick,
  isActive = false,
  disabled = false,
  title,
  children,
}: {
  onClick: () => void
  isActive?: boolean
  disabled?: boolean
  title: string
  children: React.ReactNode
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      title={title}
      className={`rounded p-1.5 transition-colors ${
        isActive
          ? 'bg-blue-100 text-blue-700'
          : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
      } ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}
    >
      {children}
    </button>
  )
}

function Divider() {
  return <div className="mx-1 h-6 w-px bg-gray-200" />
}

function Toolbar({ editor, onOpenMediaPicker }: { editor: Editor; onOpenMediaPicker: () => void }) {
  const addLink = () => {
    const previousUrl = editor.getAttributes('link').href ?? ''
    const url = window.prompt('Enter URL:', previousUrl)
    if (url === null) return
    if (url === '') {
      editor.chain().focus().extendMarkRange('link').unsetLink().run()
      return
    }
    editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
  }

  const insertTable = () => {
    editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
  }

  const isInTable = editor.isActive('table')

  return (
    <div className="sticky top-0 z-10 flex flex-wrap items-center gap-0.5 border-b border-gray-200 bg-gray-50 p-2">
      {/* Group 1: Text formatting */}
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleBold().run()}
        isActive={editor.isActive('bold')}
        title="Bold (Ctrl+B)"
      >
        <Bold className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleItalic().run()}
        isActive={editor.isActive('italic')}
        title="Italic (Ctrl+I)"
      >
        <Italic className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleUnderline().run()}
        isActive={editor.isActive('underline')}
        title="Underline (Ctrl+U)"
      >
        <Underline className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleStrike().run()}
        isActive={editor.isActive('strike')}
        title="Strikethrough"
      >
        <Strikethrough className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleCode().run()}
        isActive={editor.isActive('code')}
        title="Inline code"
      >
        <Code className="h-4 w-4" />
      </ToolbarButton>

      <Divider />

      {/* Group 2: Headings */}
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
        isActive={editor.isActive('heading', { level: 1 })}
        title="Heading 1"
      >
        <Heading1 className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
        isActive={editor.isActive('heading', { level: 2 })}
        title="Heading 2"
      >
        <Heading2 className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
        isActive={editor.isActive('heading', { level: 3 })}
        title="Heading 3"
      >
        <Heading3 className="h-4 w-4" />
      </ToolbarButton>

      <Divider />

      {/* Group 3: Lists & blocks */}
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleBulletList().run()}
        isActive={editor.isActive('bulletList')}
        title="Bullet list"
      >
        <List className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleOrderedList().run()}
        isActive={editor.isActive('orderedList')}
        title="Ordered list"
      >
        <ListOrdered className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleBlockquote().run()}
        isActive={editor.isActive('blockquote')}
        title="Blockquote"
      >
        <Quote className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().toggleCodeBlock().run()}
        isActive={editor.isActive('codeBlock')}
        title="Code block"
      >
        <FileCode className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().setHorizontalRule().run()}
        title="Horizontal rule"
      >
        <Minus className="h-4 w-4" />
      </ToolbarButton>

      <Divider />

      {/* Group 4: Alignment */}
      <ToolbarButton
        onClick={() => editor.chain().focus().setTextAlign('left').run()}
        isActive={editor.isActive({ textAlign: 'left' })}
        title="Align left"
      >
        <AlignLeft className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().setTextAlign('center').run()}
        isActive={editor.isActive({ textAlign: 'center' })}
        title="Align center"
      >
        <AlignCenter className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().setTextAlign('right').run()}
        isActive={editor.isActive({ textAlign: 'right' })}
        title="Align right"
      >
        <AlignRight className="h-4 w-4" />
      </ToolbarButton>

      <Divider />

      {/* Group 5: Insert */}
      <ToolbarButton onClick={addLink} isActive={editor.isActive('link')} title="Insert link">
        <LinkIcon className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton onClick={onOpenMediaPicker} title="Insert image">
        <ImageIcon className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton onClick={insertTable} title="Insert table (3×3)">
        <TableIcon className="h-4 w-4" />
      </ToolbarButton>

      {/* Group 6: Table controls (visible only when cursor is inside a table) */}
      {isInTable && (
        <>
          <Divider />
          <ToolbarButton
            onClick={() => editor.chain().focus().addColumnBefore().run()}
            title="Add column before"
          >
            <Columns className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().addColumnAfter().run()}
            title="Add column after"
          >
            <Columns className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().deleteColumn().run()}
            title="Delete column"
          >
            <Columns className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().addRowBefore().run()}
            title="Add row before"
          >
            <Rows className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().addRowAfter().run()}
            title="Add row after"
          >
            <Rows className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().deleteRow().run()}
            title="Delete row"
          >
            <Rows className="h-4 w-4" />
          </ToolbarButton>
          <ToolbarButton
            onClick={() => editor.chain().focus().deleteTable().run()}
            title="Delete table"
          >
            <Trash2 className="h-4 w-4" />
          </ToolbarButton>
        </>
      )}

      <Divider />

      {/* Group 7: History */}
      <ToolbarButton
        onClick={() => editor.chain().focus().undo().run()}
        disabled={!editor.can().undo()}
        title="Undo"
      >
        <Undo className="h-4 w-4" />
      </ToolbarButton>
      <ToolbarButton
        onClick={() => editor.chain().focus().redo().run()}
        disabled={!editor.can().redo()}
        title="Redo"
      >
        <Redo className="h-4 w-4" />
      </ToolbarButton>
    </div>
  )
}

export function TipTapEditor({
  content,
  onChange,
  placeholder,
  editable = true,
  className,
  enableAI = true,
  collaboration,
  comments,
}: TipTapEditorProps) {
  const [mediaPickerOpen, setMediaPickerOpen] = useState(false)
  // The collaboration context is created once per (documentId, url) tuple
  // and torn down on prop changes / unmount. We deliberately key on a
  // stringified tuple so swapping users keeps the same Y.Doc (their seat at
  // the document doesn't drop) while swapping documents creates a new one.
  const collabKey = collaboration ? `${collaboration.documentId}::${collaboration.url}` : null
  const collabRef = useRef<CollaborationContext | null>(null)
  const [collabStatus, setCollabStatus] = useState<CollaborationStatus>('connecting')
  const [offlineStatus, setOfflineStatus] = useState<OfflinePersistenceStatus>('pending')
  useEffect(() => {
    if (!collaboration) {
      collabRef.current = null
      return
    }
    const ctx = createCollaborationProvider({
      documentId: collaboration.documentId,
      url: collaboration.url,
      user: collaboration.user,
      params: collaboration.params,
      onStatus: setCollabStatus,
      onOfflineStatus: setOfflineStatus,
    })
    collabRef.current = ctx
    return () => {
      ctx.destroy()
      if (collabRef.current === ctx) collabRef.current = null
    }
    // We rely on collabKey to scope effect identity. user / params are
    // tolerated to mutate freely; the Yjs awareness state is updated
    // separately in the awareness-seeding effect below.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [collabKey])

  // Keep the local awareness state in sync with user info changes without
  // tearing down the collaboration session.
  useEffect(() => {
    if (!collaboration || !collabRef.current) return
    collabRef.current.awareness.setLocalState({ user: collaboration.user })
  }, [
    collaboration?.user.name,
    collaboration?.user.color,
    collaboration?.user.avatarUrl,
    collaboration?.user.userId,
    collaboration,
  ])

  const extensions = useMemo(() => {
    // The TipTap extension union is hostile to mutation (the inferred
    // element type is too narrow). We collect into `unknown[]` and let
    // `useEditor`'s prop typing carry the contract from the call site.
    const base: unknown[] = [
      StarterKit.configure({
        codeBlock: false,
        horizontalRule: false,
        // History is owned by Yjs when collaboration is enabled.
        history: collaboration ? false : undefined,
      }),
      UnderlineExt,
      Link.configure({
        openOnClick: false,
        HTMLAttributes: { class: 'text-blue-600 underline' },
      }),
      Image.configure({
        inline: true,
        allowBase64: true,
      }),
      Table.configure({ resizable: true }),
      TableRow,
      TableHeader,
      TableCell,
      TextAlign.configure({ types: ['heading', 'paragraph'] }),
      Placeholder.configure({
        placeholder: placeholder || 'Start writing...',
      }),
      CodeBlockLowlight.configure({ lowlight }),
      HorizontalRule,
    ]
    if (collaboration && collabRef.current) {
      base.push(
        Collaboration.configure({ document: collabRef.current.ydoc }),
        CollaborationCursor.configure({
          provider: collabRef.current.provider,
          user: collaboration.user,
        }),
      )
    }
    if (comments) {
      base.push(CommentMark)
    }
    return base
    // Re-derive extensions when the collaboration session or comments
    // toggle changes.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [collabKey, placeholder, comments != null])

  const editor = useEditor(
    {
      // TipTap's `Extensions` type is the union of every loaded extension's
      // typed shape, which we can't satisfy when we conditionally append
      // collaboration extensions. The runtime contract is correct — TipTap
      // duck-types — so we cast through the narrow `any` slot.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      extensions: extensions as any[],
      // When collaboration is enabled the Y.Doc is the source of truth; we
      // pass `content: ''` to avoid double-applying the initial HTML.
      content: collaboration ? '' : content,
      editable: editable !== false,
      onUpdate: ({ editor: ed }) => {
        onChange(ed.getHTML())
      },
      onFocus: ({ editor: ed }) => {
        setActiveEditor(ed)
      },
    },
    // Force re-init when collaboration extensions change.
    [collabKey],
  )

  useEffect(() => {
    if (!editor) return
    return () => {
      clearActiveEditorIf(editor)
    }
  }, [editor])

  if (!editor) return null

  const handleImageSelected = (url: string, alt?: string) => {
    editor
      .chain()
      .focus()
      .setImage({ src: url, alt: alt ?? '' })
      .run()
  }

  const awareness = collabRef.current?.awareness ?? null

  /**
   * Encode an anchor for the current selection. Returns `null` when the
   * selection is empty (the panel falls back to a doc-level comment),
   * or when collaboration isn't enabled (we have no Yjs text node to
   * anchor against — slice 5's anchors are CRDT-relative by design).
   */
  const composeAnchor = (): CommentAnchor | null => {
    if (!collabRef.current) return null
    const sel = editor.state.selection
    if (sel.empty) return null
    // The Y.Doc shape that TipTap collaboration uses is
    //   doc.getXmlFragment('default') → [paragraph, …] → [text, …]
    // We anchor against the first XmlText child of the paragraph that
    // contains the selection start. This is the simple-but-correct
    // single-node case; multi-paragraph selections fall back to a
    // doc-level comment (anchor: null) and the user can re-anchor
    // later.
    const fragment = collabRef.current.ydoc.getXmlFragment('default')
    const yType = findTextNodeForPosition(fragment, sel.from)
    if (!yType) return null
    // The relative offset within the text node mirrors the PM offset
    // minus the paragraph's opening token. For the canonical single-
    // text-child case this is `sel.from - paragraphStart - 1`.
    const offset = computeTextOffset(fragment, sel.from)
    if (offset === null) return null
    return encodeAnchor({
      doc: collabRef.current.ydoc,
      yType,
      from: offset,
      to: offset + (sel.to - sel.from),
    })
  }

  const editorPane = (
    <div
      className={`flex-1 overflow-hidden rounded-lg border border-gray-200 bg-white ${className ?? ''}`}
    >
      {collaboration && awareness && (
        <div className="flex items-center justify-between gap-2 border-b border-gray-100 bg-white px-2 py-1.5">
          <PresenceChips awareness={awareness} status={collabStatus} />
          <OfflineStatus connection={collabStatus} offline={offlineStatus} />
        </div>
      )}
      {editable && <Toolbar editor={editor} onOpenMediaPicker={() => setMediaPickerOpen(true)} />}
      {editable && enableAI && <AICoauthorBubbleMenu editor={editor} />}
      <EditorContent editor={editor} />
      <style dangerouslySetInnerHTML={{ __html: editorStyles + COMMENT_MARK_STYLES }} />
      <MediaPickerModal
        open={mediaPickerOpen}
        onClose={() => setMediaPickerOpen(false)}
        onSelect={handleImageSelected}
        accept="image/*"
      />
    </div>
  )

  if (!comments) return editorPane

  return (
    <div className="flex h-full gap-3">
      {editorPane}
      <div className="w-80 shrink-0">
        <CommentSidePanel
          documentId={comments.documentId}
          currentUserId={comments.currentUserId}
          isAdmin={comments.isAdmin}
          onComposeAnchor={composeAnchor}
          onError={comments.onError}
          onActiveCommentChange={(id) => {
            // When a thread is opened in the panel, paint the matching
            // CommentMark with the active modifier so it stands out in
            // the doc. We do this by toggling a class on the rendered
            // span — purely visual; no doc mutation.
            if (typeof document === 'undefined') return
            document
              .querySelectorAll('span.actuate-comment--active')
              .forEach((el) => el.classList.remove('actuate-comment--active'))
            if (id) {
              document
                .querySelectorAll(`span[data-comment-id="${cssEscape(id)}"]`)
                .forEach((el) => el.classList.add('actuate-comment--active'))
            }
          }}
        />
      </div>
    </div>
  )
}

/**
 * Walk the XmlFragment's children until we reach the paragraph
 * containing the given ProseMirror position. Return its first XmlText
 * child, or null when the structure is anything other than the canonical
 * `paragraph → [text]` shape we anchor against.
 *
 * This is the *simple-case* mapping — slice 5 anchors to a single text
 * node per comment. Selections that span multiple paragraphs fall back
 * to a document-level comment (anchor: null) and the user can re-anchor
 * later from the side panel. y-prosemirror has a richer mapping we may
 * pull in once slice 5 ships.
 */
function findTextNodeForPosition(fragment: Y.XmlFragment, pmPos: number): Y.XmlText | null {
  let walked = 0
  for (const child of fragment.toArray()) {
    if (!(child instanceof Y.XmlElement)) continue
    const textChild = child.firstChild
    const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
    // Each paragraph contributes `1 + textLen + 1` PM positions
    // (open token, contents, close token).
    const blockSpan = 2 + textLen
    if (pmPos <= walked + blockSpan) {
      return textChild instanceof Y.XmlText ? textChild : null
    }
    walked += blockSpan
  }
  return null
}

function computeTextOffset(fragment: Y.XmlFragment, pmPos: number): number | null {
  let walked = 0
  for (const child of fragment.toArray()) {
    if (!(child instanceof Y.XmlElement)) continue
    const textChild = child.firstChild
    const textLen = textChild instanceof Y.XmlText ? textChild.length : 0
    const blockSpan = 2 + textLen
    if (pmPos <= walked + blockSpan) {
      // PM positions skip the paragraph's open token (1 char).
      const inside = pmPos - walked - 1
      if (inside < 0 || inside > textLen) return null
      return inside
    }
    walked += blockSpan
  }
  return null
}

/** Cheap CSS attr-value escaper for `data-comment-id` lookups. */
function cssEscape(value: string): string {
  return value.replace(/(["\\])/g, '\\$1')
}
