<script setup lang="ts">
import { TextSelection } from "prosemirror-state"
import type { EditorView } from "prosemirror-view"
import {
  computed,
  nextTick,
  onMounted,
  type Ref,
  reactive,
  ref,
  watch,
} from "vue"
import EditorBlock from "@/components/EditorBlock.vue"
import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
import {
  type FocusPosition,
  type FocusTarget,
  useFocusRegistry,
} from "@/composables/useFocusRegistry"
import {
  defaultKeyBinding,
  type KeyBinding,
  useKeyboard,
} from "@/composables/useKeyboard"
import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
import type {
  PatternClosePayload,
  PatternOpenPayload,
  PatternUpdatePayload,
} from "@/composables/usePatternPlugin"
import {
  type EditorBlockData,
  generateBlockId,
  serializeBlocks,
  splitMarkdownIntoBlocks,
} from "@/lib/block-parser"
import { inlineToString } from "@/lib/content-model"
import {
  moveBlock,
  deleteIfEmpty as opDeleteIfEmpty,
  indentBlock as opIndentBlock,
  insertBlock as opInsertBlock,
  mergePrevious as opMergePrevious,
  outdentBlock as opOutdentBlock,
  splitBlocks as opSplitBlocks,
} from "@/lib/editor-operations"
import type { FormattingHandlers } from "@/lib/formatting"
import { createLogger } from "@/lib/logger"
import {
  createOperationHistory,
  type EditorOperation,
  type OperationHistory,
} from "@/lib/operation-history"
import { resolveTheme, type ThemeInput, themeToCSSVars } from "@/lib/theme"

const content = defineModel<string>("content", { required: false })

const emit = defineEmits<{
  focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
  blur: []
  "selection-change": [payload: { from: number; to: number; empty: boolean }]
  error: [payload: { code: string; message?: string; blockId?: string }]
  "page-ref-clicked": [payload: { pageName: string; alias?: string }]
  "block-ref-clicked": [payload: { blockId: string }]
  "tag-clicked": [payload: { tag: string }]
  "asset-dropped": [payload: { file: File; pos: number }]
}>()

export type EditorFocusTarget =
  | FocusTarget
  | "first-block"
  | "last-block"
  | "first-zone"
  | "last-zone"
  | true

const props = withDefaults(
  defineProps<{
    /** Where to place focus on mount:
     * - `"first-block"`: focus the first EditorBlock
     * - `"last-block"`: focus the last EditorBlock
     * - `"first-zone"`: focus the insertion zone before the first block
     * - `"last-zone"`: focus the insertion zone after the last block
     * - `{ kind: "block"; id }`: focus a specific block by ID
     * - `{ kind: "zone"; index }`: focus a specific zone by index
     * - not set: no initial focus */
    focused?: EditorFocusTarget
    markerMode?: MarkerVisibilityMode
    debug?: string
    theme?: ThemeInput
    keyBinding?: KeyBinding
    extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
    resolvedRefs?: Set<string>
    resolvedRefsVersion?: number
    resolveAsset?: (file: File) => Promise<string>
    /** Enables window.__testUtils__ for Playwright E2E tests */
    testMode?: boolean
    /** Real data for [[ Page ]], ((block)), and #tag suggestions.
     * When provided, replaces the built-in DEMO_ arrays. */
    mentionData?: import("@/composables/useSuggestionGroups").MentionData | null
  }>(),
  {},
)

const themeCSSVars = computed(() => {
  const resolved = resolveTheme(props.theme)
  return themeToCSSVars(resolved)
})

/**
 * Tracks the string last set by an internal content update. The watcher
 * compares content.value against this; if they match, the watcher was
 * triggered by us and the update is skipped. If they differ, the change
 * came from the parent (external) and the editor reloads.
 * Unlike a generation counter + nextTick approach, this has no race
 * window where an external update between set and nextTick can be
 * mistaken for internal.
 */
let lastSerialized: string | undefined
let isInitialized = false

const blocks = ref<EditorBlockData[]>([])

const liveAnnouncement = ref("")

let announceTimer: ReturnType<typeof setTimeout> | null = null
function announce(msg: string) {
  liveAnnouncement.value = msg
  if (announceTimer) clearTimeout(announceTimer)
  announceTimer = setTimeout(() => {
    liveAnnouncement.value = ""
  }, 2000)
}

const registry = useFocusRegistry()

const log = createLogger("Editor", props.debug)

const blockRefs = new Map<string, { setContent: (md: string) => void }>()
const zoneRefs = new Map<number, HTMLElement>()

/**
 * Focus the insertion zone at the given index. Used for keyboard
 * navigation when the cursor exits the first or last block.
 */
function focusZone(index: number) {
  const zone = zoneRefs.get(index)
  if (!zone) return
  zone.focus()
}

/**
 * Push content into a block's ProseMirror view, but only if the block
 * still exists in the model. Without this guard, an async content
 * change that resolves after the block is removed could schedule a
 * doc replacement in a component about to be unmounted.
 */
function syncBlockContent(id: string, content: string) {
  if (!blocks.value.some((b) => b.id === id)) return
  blockRefs.get(id)?.setContent(content)
}

// ── Drag-to-reorder ────────────────────────────────────────────────

const dragState = ref<{
  fromIndex: number
  blockId: string
  content: string
  depth: number
} | null>(null)
const dropZoneIndex = ref<number | null>(null)

function onDragStart(
  index: number,
  blockId: string,
  content: string,
  depth: number,
  event: DragEvent,
) {
  dragState.value = { fromIndex: index, blockId, content, depth }
  event.dataTransfer?.setData("text/plain", blockId)
  if (event.dataTransfer) {
    event.dataTransfer.effectAllowed = "move"
  }
}

function onDragOverZone(zoneIndex: number, event: DragEvent) {
  if (!dragState.value) {
    dropZoneIndex.value = null
    return
  }
  dropZoneIndex.value = zoneIndex
  event.dataTransfer!.dropEffect = "move"
}

function onDragLeaveZone(zoneIndex: number) {
  if (dropZoneIndex.value === zoneIndex) {
    dropZoneIndex.value = null
  }
}

function onDragEnd() {
  dragState.value = null
  dropZoneIndex.value = null
}

function onDropOnZone(zoneIndex: number) {
  const state = dragState.value
  if (!state) {
    dragState.value = null
    dropZoneIndex.value = null
    return
  }

  // Zone i = insert before block i.  In post-removal coordinates:
  // if fromIndex < zoneIndex, the zone shifts left by 1.
  const targetIndex = state.fromIndex < zoneIndex ? zoneIndex - 1 : zoneIndex

  if (state.fromIndex === targetIndex) {
    dragState.value = null
    dropZoneIndex.value = null
    return
  }

  blocks.value = moveBlock(blocks.value, state.fromIndex, targetIndex)
  void history.execute({
    type: "move-block",
    fromIndex: state.fromIndex,
    toIndex: targetIndex,
    blockId: state.blockId,
    content: state.content,
    depth: state.depth,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
  zoneRefs.clear()
  onBlockContentChange()

  dragState.value = null
  dropZoneIndex.value = null
}

// ── Undo / Redo history ──────────────────────────────────────────

const history: OperationHistory = createOperationHistory(100)
const canUndo = ref(false)
const canRedo = ref(false)

// Applies an operation's model mutation and syncs PM views. Does NOT
// update canUndo/canRedo — that's the caller's responsibility (undo/redo
// update the flags afterward; direct operation callsites do so inline).
function applyOperation(op: EditorOperation): void {
  zoneRefs.clear()
  let focusInfo: { id: string; pos: "start" | "end" | number } | null = null

  switch (op.type) {
    case "split-block": {
      const block = blocks.value[op.index]
      if (!block) return
      block.content = op.beforeContent
      syncBlockContent(block.id, op.beforeContent)
      blocks.value.splice(op.index + 1, 0, {
        id: op.splitBlockId,
        depth: op.depth,
        content: op.afterContent,
      })
      focusInfo = { id: op.splitBlockId, pos: "start" }
      break
    }
    case "merge-block": {
      const prev = blocks.value[op.index - 1]
      if (prev) {
        prev.content = op.prevContent + op.appendedContent
        syncBlockContent(prev.id, prev.content)
      }
      blocks.value.splice(op.index, 1)
      // Cursor lands at the join point: PM's 1-indexed position after
      // the last character of the pre-merge content.
      if (prev) focusInfo = { id: prev.id, pos: op.prevContent.length + 1 }
      break
    }
    case "insert-block": {
      blocks.value.splice(op.index, 0, {
        id: op.blockId,
        depth: op.depth,
        content: op.content,
      })
      focusInfo = { id: op.blockId, pos: "start" }
      break
    }
    case "delete-block": {
      blocks.value.splice(op.index, 1)
      const target = blocks.value[op.index] ?? blocks.value[op.index - 1]
      if (target) focusInfo = { id: target.id, pos: "start" }
      break
    }
    case "indent-block": {
      const indentBlock = blocks.value[op.index]
      if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
      if (op.childrenPreviousDepths) {
        for (const c of op.childrenPreviousDepths) {
          const child = blocks.value[c.index]
          if (child) child.depth = Math.min(c.previousDepth + 1, 10)
        }
      }
      // No focusInfo here — indent only changes depth metadata, not PM
      // content, so moving the cursor would be jarring.
      break
    }
    case "outdent-block": {
      const outdentBlock = blocks.value[op.index]
      if (outdentBlock) outdentBlock.depth = Math.max(op.previousDepth - 1, 0)
      if (op.childrenPreviousDepths) {
        for (const c of op.childrenPreviousDepths) {
          const child = blocks.value[c.index]
          if (child) child.depth = Math.max(c.previousDepth - 1, 0)
        }
      }
      // No focusInfo — same rationale as indent-block.
      break
    }
    case "set-block-content": {
      const setBlock = blocks.value.find(
        (b: EditorBlockData) => b.id === op.blockId,
      )
      if (setBlock) {
        setBlock.content = op.newContent
        syncBlockContent(setBlock.id, op.newContent)
      }
      if (setBlock) focusInfo = { id: setBlock.id, pos: "end" }
      break
    }
    case "move-block": {
      blocks.value = moveBlock(blocks.value, op.fromIndex, op.toIndex)
      break
    }
  }
  onBlockContentChange()

  if (focusInfo) {
    nextTick(() => registry.focus(focusInfo!.id, focusInfo!.pos))
  }
}

function undo() {
  const inverse = history.undo()
  if (!inverse) return
  applyOperation(inverse)
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
}

function redo() {
  const op = history.redo()
  if (!op) return
  applyOperation(op)
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
}

/** Coalescing window for SetBlockContentOp — mirrors PM's newGroupDelay. */
const COALESCE_MS = 500

function onBlockContentChangeOp(op: {
  blockId: string
  previousContent: string
  newContent: string
  timestamp: number
}) {
  const last = history.peek()
  if (
    last?.type === "set-block-content" &&
    last.blockId === op.blockId &&
    op.timestamp - last.timestamp < COALESCE_MS
  ) {
    history.coalesce({
      type: "set-block-content",
      blockId: op.blockId,
      previousContent: last.previousContent,
      newContent: op.newContent,
      timestamp: op.timestamp,
    })
  } else {
    void history.execute({
      type: "set-block-content",
      blockId: op.blockId,
      previousContent: op.previousContent,
      newContent: op.newContent,
      timestamp: op.timestamp,
    })
  }
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
}

function parseContent(md: string | undefined) {
  blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
}

// Sync: parent content → blocks (external updates only).
// External content replacement invalidates all history entries
// (they reference stale indices/block IDs), so clear the undo stacks.
watch(
  () => content.value,
  (val) => {
    if (!isInitialized) {
      isInitialized = true
      parseContent(val)
      return
    }
    if (val === lastSerialized) return
    history.clear()
    canUndo.value = false
    canRedo.value = false
    parseContent(val)
  },
  { immediate: true },
)

// Serialize and emit when any block's content changes.
function onBlockContentChange(blockId?: string, newContent?: string) {
  if (blockId && newContent) {
    const idx = blocks.value.findIndex((b) => b.id === blockId)
    if (idx >= 0) {
      const b = blocks.value[idx]
      if (b) {
        blocks.value[idx] = { ...b, content: newContent }
      }
    }
  }
  lastSerialized = serializeBlocks(blocks.value)
  content.value = lastSerialized
}

// ── Active block state (toolbar binding) ──────────────────────────────

const activeHandlers = ref<FormattingHandlers | null>(null)
const selectionVersion = ref(0)

function onBlockFocusHandler(payload: {
  view: EditorView
  handlers: FormattingHandlers
}) {
  activeView.value = payload.view
  activeHandlers.value = payload.handlers
  emit("focus", payload)
}

function onBlockBlurHandler(event: FocusEvent) {
  // Keep handlers when focus moves to the toolbar (e.g., heading dropdown click).
  // Only clear when focus moves outside the editor.
  const target = event.relatedTarget as HTMLElement | null
  if (target?.closest('[role="toolbar"], [data-editor-overlay]')) return
  activeHandlers.value = null
  activeView.value = null
  emit("blur")
}

function onBlockSelectionChangeHandler(payload: {
  from: number
  to: number
  empty: boolean
}) {
  selectionVersion.value++
  emit("selection-change", payload)
}

// ── First paragraph height tracking (bullet alignment) ────────────

const blockHeights = reactive<Record<string, number>>({})

function onFirstParaHeight(blockId: string, height: number) {
  blockHeights[blockId] = height
}

// ── Pattern session (used only for keyboard capture lifecycle) ─────

const activeSession = ref<PatternOpenPayload | null>(null)
const activeView: Ref<EditorView | null> = ref(null)

useKeyboard({
  keybinding: props.keyBinding ?? defaultKeyBinding,
  activeSession,
  activeView,
  onUndo: undo,
  onRedo: redo,
})

function onPatternOpen(payload: PatternOpenPayload) {
  log.info("pattern-open", { kind: payload.kind, query: payload.query })
  activeSession.value = payload
}

function onPatternUpdate(payload: PatternUpdatePayload) {
  if (!activeSession.value) return
  activeSession.value = {
    ...activeSession.value,
    query: payload.query,
    position: payload.position,
    range: payload.range,
  }
}

function onPatternClose(_payload: PatternClosePayload) {
  log.info("pattern-close")
  activeSession.value = null
}

function onZoneNavigateUp(zoneIndex: number) {
  if (zoneIndex <= 0) return
  const prev = blocks.value[zoneIndex - 1]
  if (prev) registry.focus(prev.id, "end")
}

function onZoneNavigateDown(zoneIndex: number) {
  if (zoneIndex >= blocks.value.length) return
  const next = blocks.value[zoneIndex]
  if (next) registry.focus(next.id, "start")
}

// ── Block event handlers ──────────────────────────────────────────────

function onSplit(index: number, before: string, after: string) {
  const result = opSplitBlocks(
    blocks.value,
    index,
    before,
    after,
    generateBlockId,
  )
  if (!result.newBlock) return
  announce("Block split")
  blocks.value = result.blocks
  zoneRefs.clear()
  onBlockContentChange()

  void history.execute({
    type: "split-block",
    index,
    splitBlockId: result.newBlock.id,
    depth: result.newBlock.depth,
    beforeContent: before,
    afterContent: after,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo

  nextTick(() => registry.focus(result.newBlock!.id, "start"))
}

function onMergePrevious(index: number) {
  const result = opMergePrevious(blocks.value, index)
  if (result.blocks.length >= blocks.value.length) return
  announce("Merged with previous block")

  const current = blocks.value[index]
  const prev = blocks.value[index - 1]
  if (!current || !prev) return
  const removedBlockId = current.id
  const removedBlockDepth = current.depth

  // Directly update the prev block's PM + model so the debounced watch
  // (which fires ~50ms later) sees currentContent === newContent and is a
  // no-op — preventing a cursor-resetting doc replacement.
  syncBlockContent(prev.id, result.mergedContent)

  // Defensively ensure the result block's content matches mergedContent,
  // even if mergePrevious's implementation evolves. Without this, the new
  // block objects in result.blocks might have stale content, causing the
  // debounced watch in Block.vue to re-apply the old value and lose state.
  const prevIndex = result.blocks.findIndex((b) => b.id === prev.id)
  if (prevIndex !== -1) result.blocks[prevIndex]!.content = result.mergedContent

  blocks.value = result.blocks
  zoneRefs.clear()
  onBlockContentChange()

  void history.execute({
    type: "merge-block",
    index,
    removedBlockId,
    removedBlockDepth,
    prevContent: prev.content,
    appendedContent: current.content,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo

  nextTick(() => registry.focus(prev.id, "end"))
}

function onDeleteIfEmpty(index: number) {
  const result = opDeleteIfEmpty(blocks.value, index)
  if (!result.nextId || result.blocks.length >= blocks.value.length) return
  const removedBlock = blocks.value[index]
  if (!removedBlock) return
  announce("Block deleted")

  blocks.value = result.blocks
  zoneRefs.clear()
  onBlockContentChange()

  void history.execute({
    type: "delete-block",
    index,
    blockId: removedBlock.id,
    content: removedBlock.content,
    depth: removedBlock.depth,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo

  const prevBlock = index > 0 ? blocks.value[index - 1] : null
  const focusId = prevBlock?.id ?? result.nextId

  nextTick(() => registry.focus(focusId!, prevBlock ? "end" : "start"))
}

function onArrowUpFromStart(index: number, leftOffset?: number) {
  // Boundary: focus the insertion zone before the first block.
  if (index <= 0) {
    focusZone(0)
    return
  }
  const prev = blocks.value[index - 1]
  if (!prev) return
  const target: FocusPosition =
    leftOffset !== undefined ? { left: leftOffset, edge: "bottom" } : "end"
  registry.focus(prev.id, target)
}

function onArrowDownFromEnd(index: number, leftOffset?: number) {
  // Boundary: focus the insertion zone after the last block.
  if (index >= blocks.value.length - 1) {
    focusZone(blocks.value.length)
    return
  }
  const next = blocks.value[index + 1]
  if (!next) return
  const target: FocusPosition =
    leftOffset !== undefined ? { left: leftOffset, edge: "top" } : "start"
  registry.focus(next.id, target)
}

function onBlockBoundaryExit(index: number, direction: "up" | "down") {
  log.info("onBlockBoundaryExit", { index, direction })
  if (direction === "up") {
    const prev = blocks.value[index - 1]
    if (prev) {
      registry.focus(prev.id, "end")
      return
    }
  } else {
    const next = blocks.value[index + 1]
    if (next) {
      registry.focus(next.id, "start")
      return
    }
  }
  // Fallback to insertion zone if no adjacent block
  const zoneIndex = direction === "up" ? index : index + 1
  focusZone(zoneIndex)
}

function onIndent(index: number) {
  const block = blocks.value[index]
  if (!block) return
  const result = opIndentBlock(blocks.value, index)
  if (result.childIndices.length === 0 && block.depth >= 10) return

  const previousDepth = block.depth
  const childEntries = result.childIndices.map((i) => ({
    index: i,
    previousDepth: blocks.value[i]?.depth ?? 0,
  }))

  blocks.value = result.blocks
  zoneRefs.clear()
  onBlockContentChange()

  void history.execute({
    type: "indent-block",
    index,
    previousDepth,
    childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
}

function onOutdent(index: number) {
  const block = blocks.value[index]
  if (!block) return
  const result = opOutdentBlock(blocks.value, index)
  if (result.childIndices.length === 0 && block.depth <= 0) return

  const previousDepth = block.depth
  const childEntries = result.childIndices.map((i) => ({
    index: i,
    previousDepth: blocks.value[i]?.depth ?? 0,
  }))

  blocks.value = result.blocks
  zoneRefs.clear()
  onBlockContentChange()

  void history.execute({
    type: "outdent-block",
    index,
    previousDepth,
    childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
    timestamp: Date.now(),
  })
  canUndo.value = history.canUndo
  canRedo.value = history.canRedo
}

const focusedTarget = ref<FocusTarget | null>(null)

/** Resolve the public `focused` prop to an internal `FocusTarget`. */
function resolveFocusTarget(
  val: EditorFocusTarget | undefined,
): FocusTarget | null {
  if (!val) return null
  // Backward compat: true → "first-block"
  if (val === true) val = "first-block"
  if (typeof val === "object") return val as FocusTarget
  const last = blocks.value.length - 1
  switch (val) {
    case "first-block":
      return blocks.value[0] ? { kind: "block", id: blocks.value[0].id } : null
    case "last-block":
      return last >= 0 ? { kind: "block", id: blocks.value[last]!.id } : null
    case "first-zone":
      return { kind: "zone", index: 0 }
    case "last-zone":
      return { kind: "zone", index: blocks.value.length }
  }
}

watch(registry.focusedId, (id) => {
  if (id !== null) {
    focusedTarget.value = { kind: "block", id }
  }
})

/** Apply the initial focus target on mount or when the prop changes. */
watch(
  () => props.focused,
  (val) => {
    const target = resolveFocusTarget(val)
    if (!target) return
    focusedTarget.value = target
    nextTick(() => {
      if (target.kind === "zone") {
        focusZone(target.index)
      } else {
        registry.focus(target.id, "start")
      }
    })
  },
  { immediate: true },
)

function onZoneFocus(index: number) {
  focusedTarget.value = { kind: "zone", index }
}

function onZoneBlur() {
  if (focusedTarget.value?.kind === "zone") {
    focusedTarget.value = null
  }
}

function onZoneActivate(index: number, content: string) {
  focusedTarget.value = null
  const lines = content.split("\n")

  if (lines.length > 1) {
    // Multi-line paste: create a block for each non-empty line.
    // Only the first insert is recorded in history — undoing a paste should
    // remove all inserted blocks at once, not step through them one by one.
    // TODO: Replace with a batch operation type when the history model
    // supports atomic multi-op undo.
    let insertedId: string | undefined
    let insertIndex = index
    let firstInsertRecorded = false
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i]
      if (line === undefined) continue
      if (line === "" && i < lines.length - 1) continue
      const result = opInsertBlock(
        blocks.value,
        insertIndex,
        line,
        0,
        generateBlockId,
      )
      blocks.value = result.blocks
      insertedId = result.newBlock.id
      if (!firstInsertRecorded) {
        void history.execute({
          type: "insert-block",
          index: insertIndex,
          blockId: result.newBlock.id,
          content: line,
          depth: 0,
          timestamp: Date.now(),
        })
        firstInsertRecorded = true
      }
      insertIndex++
    }
    zoneRefs.clear()
    onBlockContentChange()
    canUndo.value = history.canUndo
    canRedo.value = history.canRedo
    const lastInsertedId = insertedId
    if (lastInsertedId) {
      announce("Inserted block")
      nextTick(() => registry.focus(lastInsertedId, "end"))
    }
  } else {
    const result = opInsertBlock(
      blocks.value,
      index,
      content,
      0,
      generateBlockId,
    )
    blocks.value = result.blocks
    zoneRefs.clear()
    onBlockContentChange()

    void history.execute({
      type: "insert-block",
      index,
      blockId: result.newBlock.id,
      content,
      depth: 0,
      timestamp: Date.now(),
    })
    canUndo.value = history.canUndo
    canRedo.value = history.canRedo

    if (content) {
      nextTick(() => registry.focus(result.newBlock.id, "end"))
    } else {
      nextTick(() => registry.focus(result.newBlock.id, "start"))
    }
  }
}

// ── E2E test mode (dev only) — injects window.__testUtils__ ───────
// The test utilities access block internals directly via blockRefs and
// blocks.value. In production (testMode=false), this entire block is
// tree-shaken since the prop is never true.
onMounted(() => {
  if (!props.testMode) return
  ;(window as any).__testUtils__ = {
    selectText(blockId: string, from: number, to: number) {
      const api = blockRefs.get(blockId) as any
      if (!api?.view) return
      const v = api.view as EditorView
      v.focus()
      // Map 0-based character offsets (as returned by docToContent) → PM document positions.
      // Uses inlineToString to account for hard_break nodes (textContent omits them).
      const doc = v.state.doc
      let charPos = 0
      let pmFrom: number | null = null
      let pmTo: number | null = null
      doc.descendants((node, pos) => {
        if (!node.isTextblock) return true
        const textLen = inlineToString(node).length
        const contentStart = pos + 1
        const charEnd = charPos + textLen
        if (pmFrom === null && from < charEnd) {
          pmFrom = contentStart + Math.max(0, from - charPos)
        }
        if (pmTo === null && to <= charEnd) {
          pmTo = contentStart + Math.max(0, to - charPos)
        }
        charPos = charEnd + 2 // docToContent joins with "\n\n"
        return pmFrom === null || pmTo === null
      })
      if (pmFrom === null) pmFrom = doc.content.size
      if (pmTo === null) pmTo = doc.content.size
      v.dispatch(
        v.state.tr.setSelection(TextSelection.create(doc, pmFrom, pmTo)),
      )
    },
    getBlockIds() {
      return blocks.value.map((b) => b.id)
    },
    insertText(blockId: string, text: string) {
      const api = blockRefs.get(blockId) as any
      if (!api?.view) return
      const v = api.view as EditorView
      v.focus()
      v.dispatch(v.state.tr.insertText(text))
    },
    /** Replace the block's entire content in a single PM transaction */
    replaceContent(blockId: string, text: string) {
      const api = blockRefs.get(blockId) as any
      if (!api?.setContent) {
        console.warn(
          `[Editor] replaceContent: block ${blockId} has no setContent method`,
        )
        return
      }
      api.setContent(text)
    },
    getContent() {
      return serializeBlocks(blocks.value)
    },
    focusAtEnd(blockId: string) {
      const api = blockRefs.get(blockId) as any
      if (!api?.view) return
      const v = api.view as EditorView
      const end = v.state.doc.content.size
      v.focus()
      v.dispatch(
        v.state.tr
          .setSelection(TextSelection.create(v.state.doc, end, end))
          .scrollIntoView(),
      )
    },
    focusAtStart(blockId: string) {
      const api = blockRefs.get(blockId) as any
      if (!api?.view) return
      const v = api.view as EditorView
      v.focus()
      let start = 0
      v.state.doc.descendants((node, pos) => {
        if (node.isTextblock) {
          start = pos + 1
          return false
        }
      })
      v.dispatch(
        v.state.tr
          .setSelection(TextSelection.create(v.state.doc, start, start))
          .scrollIntoView(),
      )
    },
    toggleBlur() {
      return new Promise<void>((resolve) => {
        const btn = document.createElement("button")
        btn.style.position = "fixed"
        btn.style.top = "0"
        btn.style.left = "0"
        document.body.appendChild(btn)
        btn.focus()
        requestAnimationFrame(() => {
          document.body.removeChild(btn)
          resolve()
        })
      })
    },
    getParseTree() {
      const firstApi = blockRefs.values().next().value as any
      if (!firstApi?.view) return ""
      return (firstApi.view as EditorView).state.doc.toString()
    },
    reorderBlocks(fromIndex: number, toIndex: number) {
      if (fromIndex === toIndex) return
      const moved = blocks.value[fromIndex]
      if (!moved) return
      blocks.value = moveBlock(blocks.value, fromIndex, toIndex)
      void history.execute({
        type: "move-block",
        fromIndex,
        toIndex,
        blockId: moved.id,
        content: moved.content,
        depth: moved.depth,
        timestamp: Date.now(),
      })
      canUndo.value = history.canUndo
      canRedo.value = history.canRedo
      onBlockContentChange()
    },
    setTimeSource(fakeMs: number) {
      ;(window as any).__testTimeSource = fakeMs
    },
  }
})

defineExpose({
  undo,
  redo,
  canUndo,
  canRedo,
  getHistoryState: () => ({
    canUndo: history.canUndo,
    canRedo: history.canRedo,
  }),
  applyHistory: (op: EditorOperation) => {
    history.execute(op)
    canUndo.value = history.canUndo
    canRedo.value = history.canRedo
  },
})
</script>

<template>
  <div class="editor-shell flex flex-col min-h-0" :style="themeCSSVars">
    <div
      aria-live="polite"
      aria-atomic="true"
      class="sr-only"
    >{{ liveAnnouncement }}</div>
    <div v-if="$slots.toolbar" class="sticky top-0 z-30 pb-2">
      <slot
        name="toolbar"
        :handlers="activeHandlers"
        :selection-version="selectionVersion"
        :can-undo="canUndo"
        :can-redo="canRedo"
        :undo="undo"
        :redo="redo"
      />
    </div>
    <div class="editor-body flex-1 overflow-y-auto min-h-0" @dragover.prevent>
      <template v-for="(block, i) in blocks" :key="block.id">
        <EditorInsertionZone
        :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"
        :zone-index="i"
        :class="dropZoneIndex === i ? 'border-(--ui-primary)/25 border-t-2' : ''"
        @activate="(content: string) => onZoneActivate(i, content)"
        @focus="onZoneFocus(i)"
        @blur="onZoneBlur"
        @navigate-up="onZoneNavigateUp(i)"
        @navigate-down="onZoneNavigateDown(i)"
        @dragover.prevent="onDragOverZone(i, $event)"
        @dragleave="onDragLeaveZone(i)"
        @drop="onDropOnZone(i)"
      />
      <div
        class="flex items-start group/block"
        :data-block-index="i"
        :data-block-id="block.id"
      >
      <div
        class="flex items-center justify-end min-h-7 select-none"
        :style="{ height: blockHeights[block.id] ? blockHeights[block.id] + 'px' : undefined, width: (block.depth + 1) * 20 + 'px' }"
      >
        <div
          draggable="true"
          class="flex items-center justify-center cursor-grab active:cursor-grabbing rounded transition-all"
          data-drag-handle
          :class="dragState?.blockId === block.id ? 'size-7 text-(--ui-primary)'
            : 'size-6 text-(--ui-text-muted) opacity-30 group-hover/block:opacity-100 group-hover/block:text-(--ui-primary)'"
          @dragstart="onDragStart(i, block.id, block.content, block.depth, $event)"
          @dragend="onDragEnd"
        >
          <UIcon name="i-lucide-asterisk" />
        </div>
      </div>

        <EditorBlock
          :ref="(el: any) => { if (el) blockRefs.set(block.id, el); else blockRefs.delete(block.id) }"
          :id="block.id"
          :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
          v-model:content="block.content"
          :focused="focusedTarget?.kind === 'block' && focusedTarget.id === block.id"
          :marker-mode="markerMode"
          :depth="block.depth"
          :debug="debug"
          :registry="registry"
          :extra-patterns="extraPatterns"
          :resolved-refs="resolvedRefs"
          :resolved-refs-version="resolvedRefsVersion"
          :resolve-asset="resolveAsset"
          :mention-data="mentionData"
          class="flex-1 min-w-0"
          @update:content="(val: string | undefined) => onBlockContentChange(block.id, val)"
          @split="(before, after) => onSplit(i, before, after)"
          @merge-previous="onMergePrevious(i)"
          @delete-if-empty="onDeleteIfEmpty(i)"
          @indent="onIndent(i)"
          @outdent="onOutdent(i)"
          @arrow-up-from-start="(leftOffset) => onArrowUpFromStart(i, leftOffset)"
          @arrow-down-from-end="(leftOffset) => onArrowDownFromEnd(i, leftOffset)"
          @first-para-height="(h: number) => onFirstParaHeight(block.id, h)"
          @focus="onBlockFocusHandler"
          @blur="onBlockBlurHandler"
          @selection-change="onBlockSelectionChangeHandler"
          @pattern-open="onPatternOpen"
          @pattern-update="onPatternUpdate"
          @pattern-close="onPatternClose"
          @content-change-op="onBlockContentChangeOp"
          @error="emit('error', $event)"
          @page-ref-clicked="emit('page-ref-clicked', $event)"
          @block-ref-clicked="emit('block-ref-clicked', $event)"
          @tag-clicked="emit('tag-clicked', $event)"
          @asset-dropped="emit('asset-dropped', $event)"
        />
      </div>
    </template>
    <EditorInsertionZone
      :ref="(el: any) => { if (el) zoneRefs.set(blocks.length, el); else zoneRefs.delete(blocks.length) }"
      :zone-index="blocks.length"
      is-last
      :class="dropZoneIndex === blocks.length ? 'bg-(--ui-primary)/10 rounded' : ''"
      @activate="(content: string) => onZoneActivate(blocks.length, content)"
      @focus="onZoneFocus(blocks.length)"
      @blur="onZoneBlur"
      @navigate-up="onZoneNavigateUp(blocks.length)"
      @navigate-down="onZoneNavigateDown(blocks.length)"
      @dragover.prevent="onDragOverZone(blocks.length, $event)"
      @dragleave="onDragLeaveZone(blocks.length)"
      @drop="onDropOnZone(blocks.length)"
    />
    </div>
  </div>
</template>
