<script setup lang="ts">
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,
  serializeBlocks,
  splitMarkdownIntoBlocks,
} from "@/lib/block-parser"
import {
  applyOperationToBlocks,
} from "@/lib/editor-operations"
import { useBlockOperations } from "@/composables/useBlockOperations"
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"
import { injectTestUtils } from "@/composables/useEditorTestUtils"

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"
  }
  log.info("dragstart", {
    index,
    blockId,
    effectAllowed: "move",
    types: event.dataTransfer?.types,
  })
}

function onDragEnterZone(zoneIndex: number, event: DragEvent) {
  log.info("dragenter", { zoneIndex, hasDragState: !!dragState.value })
  onDragOverZone(zoneIndex, event)
}

function onDragOverZone(zoneIndex: number, event: DragEvent) {
  if (!dragState.value) {
    if (dropZoneIndex.value === zoneIndex) dropZoneIndex.value = null
    return
  }
  const changed = dropZoneIndex.value !== zoneIndex
  const prev = dropZoneIndex.value
  dropZoneIndex.value = zoneIndex
  event.dataTransfer!.dropEffect = "move"
  if (changed) {
    log.debug("dragover", { zoneIndex, from: prev, to: zoneIndex })
  }
}

function onDragLeaveZone(zoneIndex: number) {
  log.info("dragleave", {
    zoneIndex,
    hadHighlight: dropZoneIndex.value === zoneIndex,
  })
  if (dropZoneIndex.value === zoneIndex) {
    dropZoneIndex.value = null
  }
}

function onDragEnd() {
  log.info("dragend", {
    hadState: !!dragState.value,
    hadHighlight: dropZoneIndex.value !== null,
  })
  dragState.value = null
  dropZoneIndex.value = null
}

function onDropOnZone(zoneIndex: number) {
  ops.onDropOnZone(zoneIndex, dragState.value)
  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()
  const result = applyOperationToBlocks(blocks.value, op)
  blocks.value = result.blocks

  if (op.type === "split-block") {
    const block = blocks.value[op.index]
    if (block) syncBlockContent(block.id, op.beforeContent)
  } else if (op.type === "merge-block") {
    const prev = blocks.value[op.index - 1]
    if (prev) syncBlockContent(prev.id, prev.content)
  } else if (op.type === "set-block-content") {
    syncBlockContent(op.blockId, op.newContent)
  }

  onBlockContentChange()
  if (result.focusInfo) {
    nextTick(() => registry.focus(result.focusInfo!.id, result.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
}

const ops = useBlockOperations({
  blocks,
  registry,
  zoneRefs,
  history,
  canUndo,
  canRedo,
  liveAnnouncement,
  log,
  onBlockContentChange,
  syncBlockContent,
})

/** 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 (implemented in useBlockOperations) ──────

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)
}



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
  ops.onZoneActivate(index, content)
}

// ── E2E test mode ───────
onMounted(() => {
  injectTestUtils({
    testMode: !!props.testMode,
    blocks,
    blockRefs: blockRefs as unknown as Map<
      string,
      { view?: EditorView; setContent?: (md: string) => void }
    >,
    onBlockContentChange,
    history,
    canUndo,
    canRedo,
  })
})

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) => ops.onZoneActivate(i, content)"
        @focus="onZoneFocus(i)"
        @blur="onZoneBlur"
        @navigate-up="onZoneNavigateUp(i)"
        @navigate-down="onZoneNavigateDown(i)"
        @dragenter.prevent="onDragEnterZone(i, $event)"
        @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) 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) => ops.onSplit(i, before, after)"
          @merge-previous="ops.onMergePrevious(i)"
          @delete-if-empty="ops.onDeleteIfEmpty(i)"
          @indent="ops.onIndent(i)"
          @outdent="ops.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) => ops.onZoneActivate(blocks.length, content)"
      @focus="onZoneFocus(blocks.length)"
      @blur="onZoneBlur"
      @navigate-up="onZoneNavigateUp(blocks.length)"
      @navigate-down="onZoneNavigateDown(blocks.length)"
      @dragenter.prevent="onDragEnterZone(blocks.length, $event)"
      @dragover.prevent="onDragOverZone(blocks.length, $event)"
      @dragleave="onDragLeaveZone(blocks.length)"
      @drop="onDropOnZone(blocks.length)"
    />
    </div>
  </div>
</template>


