<script setup lang="ts">
import { watchDebounced } from "@vueuse/core"
import { GapCursor, gapCursor } from "prosemirror-gapcursor"
import { keymap } from "prosemirror-keymap"
import {
  EditorState,
  Selection,
  TextSelection,
  type Transaction,
} from "prosemirror-state"
import { EditorView } from "prosemirror-view"
import {
  computed,
  nextTick,
  onMounted,
  onUnmounted,
  ref,
  type Ref,
  useTemplateRef,
  watch,
} from "vue"
import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
import { useBlockSuggestionMenu } from "@/composables/useBlockSuggestionMenu"
import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
import {
  addFences,
  type CodeBlockNavigationDelegate,
  CodeBlockView,
} from "@/composables/useCodeBlockView"
import type {
  FocusPosition,
  FocusRegistry,
} from "@/composables/useFocusRegistry"
import {
  createMarkdownDecorationsPlugin,
  type MarkerVisibilityMode,
} from "@/composables/useMarkdownDecorations"
import { MathBlockView } from "@/composables/useMathBlockView"
import { createPasteHandler } from "@/composables/usePasteHandler"
import type {
  PatternClosePayload,
  PatternOpenPayload,
  PatternSpec,
  PatternUpdatePayload,
} from "@/composables/usePatternPlugin"
import { createPatternPlugin } from "@/composables/usePatternPlugin"
import {
  autoClosePlugin,
  createPairRule,
  dollarSignRule,
  doubleAsteriskRule,
  doubleCaretRule,
  doubleTildeRule,
  singleUnderscoreRule,
  tripleBacktickRule,
} from "@/lib/auto-close-plugin"
import { generateBlockId } from "@/lib/block-parser"
import { contentToDoc, docToContent } from "@/lib/content-model"
import {
  type FormattingHandlers,
  getActiveHeading,
  getActiveTask,
  insertBlockMath,
  insertBlockRef,
  insertInlineMath,
  insertLink,
  insertPageRef,
  insertTag,
  isFormatActive,
  setHeading,
  toggleBold,
  toggleCode,
  toggleHighlight,
  toggleItalic,
  toggleStrikethrough,
  toggleTask,
} from "@/lib/formatting"
import { createFormattingHandlers } from "@/lib/formatting-handlers"
import { createCodeBlockDelegate } from "@/composables/createCodeBlockDelegate"
import { createLogger } from "@/lib/logger"
import { getRegisteredNodeViews } from "@/lib/node-view-registry"
import { focusAt, isValidGapCursor } from "@/composables/useBlockFocus"

const content = defineModel<string>("content")

const blockAriaLabel = computed(() => {
  const text = content.value ?? ""
  const firstLine = text.split("\n")[0]?.trim() ?? ""
  if (firstLine.startsWith("```")) return "Code block"
  if (firstLine.startsWith("$$")) return "Math block"
  if (firstLine.startsWith("> [") && firstLine.endsWith("]")) return "Callout"
  if (firstLine.startsWith(">")) return "Blockquote"
  if (firstLine.startsWith("|")) return "Table"
  if (/^#{1,6}\s/.test(firstLine))
    return `Heading ${firstLine.match(/^#+/)?.[0]?.length ?? 0}`
  if (/^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\b/.test(firstLine))
    return "Task"
  if (/^\d+[.)]\s/.test(firstLine)) return "Ordered list"
  if (/^[-*+]\s/.test(firstLine)) return "Unordered list"
  return "Paragraph"
})

const props = defineProps<{
  id?: string
  focused?: boolean
  cursorPosition?: FocusPosition
  markerMode?: MarkerVisibilityMode
  debug?: string
  registry?: FocusRegistry
  readonly?: boolean
  // Prop instead of emit because CodeBlockView (a plain TS class, not a Vue
  // component) triggers it via CodeBlockNavigationDelegate. The delegate is
  // passed into the node view at construction time and routes through this
  // prop closure. Vue emits are unavailable in that context.
  onBoundaryExit?: (direction: "up" | "down") => void
  depth?: number
  /**
   * Extra pattern specs appended to the built-in triggers ([[, ((, /, #).
   * Useful for adding custom suggestion menu triggers like @mention.
   */
  extraPatterns?: PatternSpec[]
  /**
   * Known page/block IDs. References absent from this set ([[Page]] or
   * ((block-id))) are rendered as unresolved (dashed underline + tooltip).
   */
  resolvedRefs?: Set<string>
  /**
   * Bump to trigger re-decoration with updated resolvedRefs. The parent
   * can use e.g. `computed(() => resolvedRefs.size)` so native Set
   * mutations (.add, .delete) automatically produce a new version.
   */
  resolvedRefsVersion?: number
  /**
   * Resolves a dropped or pasted file to a URL. When provided, the editor
   * inserts `![](url)` after resolution and skips the `asset-dropped`
   * emit. When absent, `asset-dropped` is emitted so the parent handles
   * the upload.
   */
  resolveAsset?: (file: File) => Promise<string>
  /**
   * Real data for [[ Page ]], ((block)), and #tag suggestions.
   * When provided, replaces the built-in DEMO_ arrays.
   */
  mentionData?: import("@/composables/useSuggestionGroups").MentionData | null
}>()

const emit = defineEmits<{
  change: [content: string]
  split: [before: string, after: string]
  "merge-previous": []
  indent: []
  outdent: []
  "delete-if-empty": []
  "arrow-up-from-start": [leftOffset?: number]
  "arrow-down-from-end": [leftOffset?: number]
  "pattern-open": [payload: PatternOpenPayload]
  "pattern-update": [payload: PatternUpdatePayload]
  "pattern-close": [PatternClosePayload]
  focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
  blur: [event: FocusEvent]
  "selection-change": [payload: { from: number; to: number; empty: boolean }]
  "content-change-op": [
    payload: {
      blockId: string
      previousContent: string
      newContent: string
      timestamp: number
    },
  ]
  error: [payload: { code: string; message?: string; blockId?: string }]
  "first-para-height": [height: number]
  "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 }]
}>()

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

let lastFirstParaHeight = 0
function measureFirstParagraph() {
  const editorEl = view?.dom
  if (!editorEl) return
  const firstP = editorEl.querySelector("p")
  if (!firstP) return
  const height = firstP.offsetHeight
  if (height !== lastFirstParaHeight) {
    lastFirstParaHeight = height
    emit("first-para-height", height)
  }
}

const handleKeyDown = createBlockKeyboardHandler({
  split: (before, after) => emit("split", before, after),
  "merge-previous": () => emit("merge-previous"),
  "delete-if-empty": () => emit("delete-if-empty"),
  indent: () => emit("indent"),
  outdent: () => emit("outdent"),
  "arrow-up-from-start": (leftOffset) =>
    emit("arrow-up-from-start", leftOffset),
  "arrow-down-from-end": (leftOffset) =>
    emit("arrow-down-from-end", leftOffset),
})

function handlerWithRefocus(fn: (view: EditorView) => void) {
  return () => {
    if (!view) return
    fn(view)
    requestAnimationFrame(() => view?.focus())
  }
}

const formattingHandlers = createFormattingHandlers(() => view)
const editorRef = useTemplateRef("editorRef")
let view: EditorView | null = null
const getView = () => view
const mountError = ref(false)

const { plugin: markdownDecorationsPlugin } = createMarkdownDecorationsPlugin(
  props.markerMode ?? "live-preview",
)

// ── Suggestion menu ──────────────────────────────────────────────

const localSession = ref<PatternOpenPayload | null>(null)
const menuRef = useTemplateRef<{ forwardKey: (key: string) => void }>("menuRef")
const menuWrapperRef = useTemplateRef<HTMLElement>("menuWrapperRef")
const mentionDataRef = computed(() => props.mentionData ?? null)

const { slashGroups, mentionGroups, menuPosition, closeSession } =
  useBlockSuggestionMenu({
    localSession,
    menuRef,
    menuWrapperRef,
    formattingHandlers: ref(formattingHandlers) as Ref<FormattingHandlers | null>,
    mentionData: mentionDataRef,
    getView,
  })

const patternPlugin = createPatternPlugin(
  {
    onPatternOpen: (payload) => {
      localSession.value = payload
      emit("pattern-open", payload)
    },
    onPatternUpdate: (payload) => {
      if (!localSession.value) return
      localSession.value = {
        ...localSession.value,
        query: payload.query,
        position: payload.position,
        range: payload.range,
      }
      emit("pattern-update", payload)
    },
    onPatternClose: (payload) => {
      localSession.value = null
      emit("pattern-close", payload)
    },
  },
  props.extraPatterns,
)

function handleFile(file: File, pos: number) {
  if (props.resolveAsset) {
    props.resolveAsset(file).then((url) => {
      const md = file.type.startsWith("image/")
        ? `![](${url})`
        : `[${file.name}](${url})`
      if (!view) return
      view.dispatch(view.state.tr.insertText(md, pos, pos))
      view.focus()
    })
  } else {
    emit("asset-dropped", { file, pos })
  }
}

onMounted(() => {
  const initialDoc = contentToDoc(content.value || "")

  let lastSelectionFrom = -1
  let lastSelectionTo = -1

  const editorState = EditorState.create({
    doc: initialDoc,
    plugins: [
      gapCursor(),
      markdownDecorationsPlugin,
      ...(props.readonly
        ? []
        : [
            patternPlugin,
            autoClosePlugin([
              tripleBacktickRule,
              dollarSignRule,
              doubleAsteriskRule,
              doubleTildeRule,
              doubleCaretRule,
              singleUnderscoreRule,
              createPairRule("{", "}"),
              createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
              createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
            ]),
            keymap({
              "Mod-b": (_state, _dispatch, view) => {
                if (!view) return false
                toggleBold(view)
                return true
              },
              "Mod-i": (_state, _dispatch, view) => {
                if (!view) return false
                toggleItalic(view)
                return true
              },
              "Mod-`": (_state, _dispatch, view) => {
                if (!view) return false
                toggleCode(view)
                return true
              },
              "Mod-Shift-s": (_state, _dispatch, view) => {
                if (!view) return false
                toggleStrikethrough(view)
                return true
              },
              "Mod-Shift-h": (_state, _dispatch, view) => {
                if (!view) return false
                toggleHighlight(view)
                return true
              },
              "Mod-k": (_state, _dispatch, view) => {
                if (!view) return false
                insertLink(view)
                return true
              },
              "Mod-m": (_state, _dispatch, view) => {
                if (!view) return false
                insertInlineMath(view)
                return true
              },
            }),
          ]),
    ],
  })

  const mountEl = editorRef.value
  if (!mountEl) return

  const delegate = createCodeBlockDelegate({
    getView: () => view,
    onBoundaryExit: props.onBoundaryExit,
    log
  })

  // Mount the ProseMirror EditorView. If this fails (e.g. invalid schema,
  // missing DOM, CM6 crash during node view init), catch the error and
  // fall back to a plain <textarea>.
  try {
    view = new EditorView(mountEl, {
      state: editorState,
      editable: () => !props.readonly,
      attributes: {
        role: "textbox",
        "aria-multiline": "true",
        "data-block-id": props.id ?? "",
      },
      nodeViews: {
        code_block: (node, view, getPos) =>
          new CodeBlockView(
            node,
            view,
            () => getPos() ?? 0,
            delegate,
            props.debug,
          ),
        math_block: (node, view, getPos) =>
          new MathBlockView(node, view, () => getPos() ?? 0),
        ...getRegisteredNodeViews(),
      },
      handleKeyDown,
      handleTextInput(_view, _from, _to, text) {
        if (text.indexOf("\u00a0") !== -1) {
          _view.dispatch(
            _view.state.tr.insertText(text.replace(/\u00a0/g, " "), _from, _to),
          )
          return true
        }
        return false
      },
      handlePaste(view, event) {
        // Handle clipboard files (e.g. pasted screenshots) first.
        const file = event.clipboardData?.files?.[0]
        if (file) {
          handleFile(
            file,
            view.state.selection.from ?? view.state.selection.anchor,
          )
          return true
        }
        // Fall through to text-paste handler for multi-line splits.
        return createPasteHandler((before, after) =>
          emit("split", before, after),
        )(view, event)
      },
      handleDrop(view, event) {
        const file = event.dataTransfer?.files?.[0]
        if (!file) return false
        const pos = view.posAtCoords({
          left: event.clientX,
          top: event.clientY,
        })
        if (!pos) return false
        handleFile(file, pos.pos)
        return true
      },
      dispatchTransaction(transaction: Transaction) {
        const currentView = view
        if (!currentView) return
        const prevContent = docToContent(currentView.state.doc)
        const newState = currentView.state.apply(transaction)
        currentView.updateState(newState)

        if (transaction.docChanged && !transaction.getMeta("externalUpdate")) {
          const newContent = docToContent(newState.doc)
          log.debug("content change", {
            length: newContent.length,
            docChanged: true,
          })
          content.value = newContent
          emit("change", newContent)
          emit("content-change-op", {
            blockId: props.id ?? generateBlockId(),
            previousContent: prevContent,
            newContent,
            timestamp: Date.now(),
          })
        }

        if (transaction.docChanged) {
          requestAnimationFrame(measureFirstParagraph)
        }

        const { from, to, empty } = newState.selection
        if (from !== lastSelectionFrom || to !== lastSelectionTo) {
          lastSelectionFrom = from
          lastSelectionTo = to
          emit("selection-change", { from, to, empty })
        }
      },
      handleDOMEvents: {
        focus() {
          if (view) {
            view.dispatch(view.state.tr.setMeta("decorations:focus", true))
            emit("focus", { view, handlers: formattingHandlers })
            if (props.id && props.registry) {
              props.registry.notifyFocused(props.id)
            }
          }
          return false
        },
        blur(_view: EditorView, event: FocusEvent) {
          if (view) {
            view.dispatch(view.state.tr.setMeta("decorations:focus", false))
          }
          emit("blur", event)
          return false
        },
        click(_evView, event) {
          const domTarget = event.target as HTMLElement | null
          const pageRefEl = domTarget?.closest?.(".md-page-ref")
          if (pageRefEl instanceof HTMLElement) {
            const pageName = pageRefEl.getAttribute("data-page-name") ?? ""
            const alias = pageRefEl.getAttribute("data-alias") ?? undefined
            event.preventDefault()
            emit("page-ref-clicked", { pageName, alias })
            return true
          }
          const blockRefEl = domTarget?.closest?.(".md-block-ref")
          if (blockRefEl instanceof HTMLElement) {
            const blockId = blockRefEl.getAttribute("data-block-id") ?? ""
            event.preventDefault()
            emit("block-ref-clicked", { blockId })
            return true
          }
          const tagEl = domTarget?.closest?.(".md-tag")
          if (tagEl instanceof HTMLElement) {
            const tag = tagEl.getAttribute("data-tag") ?? ""
            event.preventDefault()
            emit("tag-clicked", { tag })
            return true
          }
          return false
        },
      },
    })

    view.dom.setAttribute("aria-label", blockAriaLabel.value)

    if (props.focused && view) {
      view.dispatch(view.state.tr.setMeta("decorations:focus", true))
      focusAt(view, props.cursorPosition)
    }

    if (props.resolvedRefsVersion !== undefined && view) {
      view.dispatch(
        view.state.tr.setMeta("decorations:resolvedRefs", {
          refs: props.resolvedRefs ?? new Set<string>(),
          version: props.resolvedRefsVersion,
        }),
      )
    }

    if (!props.readonly && props.id && props.registry) {
      props.registry.register(props.id, {
        focus: (pos) => focusAt(view, pos),
        element: mountEl ?? undefined,
      })
    }

    requestAnimationFrame(measureFirstParagraph)
  } catch (e) {
    log.error("mount failed", { error: e, blockId: props.id })
    mountError.value = true
    emit("error", {
      code: "editor-mount-failed",
      message: e instanceof Error ? e.message : String(e),
      blockId: props.id,
    })
  }
})

onUnmounted(() => {
  if (props.id && props.registry) {
    props.registry.unregister(props.id)
  }
  if (view) {
    view.destroy()
    view = null
  }
})

watch(
  () => [props.focused, props.cursorPosition] as const,
  ([focused, cursorPosition]) => {
    if (!view) return
    view.dispatch(view.state.tr.setMeta("decorations:focus", !!focused))
    if (focused) focusAt(view, cursorPosition)
  },
)

watch(
  () => props.markerMode,
  (newMode) => {
    if (!view || !newMode) return
    view.dispatch(view.state.tr.setMeta("markerMode", newMode))
  },
)

watch(
  () => props.resolvedRefsVersion,
  (version) => {
    if (!view || version === undefined) return
    view.dispatch(
      view.state.tr.setMeta("decorations:resolvedRefs", {
        refs: props.resolvedRefs ?? new Set<string>(),
        version,
      }),
    )
  },
)

watchDebounced(
  () => content.value,
  (newContent) => {
    if (!view || newContent === undefined) return
    const currentContent = docToContent(view.state.doc)
    if (currentContent === newContent) return
    log.debug("external update", {
      oldLen: currentContent.length,
      newLen: newContent.length,
    })

    const newDoc = contentToDoc(newContent)
    const tr = view.state.tr.replaceWith(
      0,
      view.state.doc.content.size,
      newDoc.content,
    )
    tr.setMeta("externalUpdate", true)
    view.dispatch(tr)
  },
  { debounce: 50 },
)

watch(blockAriaLabel, (label) => {
  if (!view) return
  view.dom.setAttribute("aria-label", label)
})

defineExpose({
  /**
   * Live ProseMirror EditorView reference.
   *
   * ⚠️ Dispatch via this view directly bypasses the operation history
   * (undo/redo). Prefer setContent() or emit("change") for programmatic
   * updates. Only use raw dispatch when you know history is irrelevant.
   */
  get view() {
    return view
  },
  focus: (pos?: "start" | "end" | number) => focusAt(view, pos),
  getContent: () => (view ? docToContent(view.state.doc) : ""),
  setContent: (md: string) => {
    if (!view) return
    const newDoc = contentToDoc(md)
    const tr = view.state.tr.replaceWith(
      0,
      view.state.doc.content.size,
      newDoc.content,
    )
    tr.setMeta("externalUpdate", true)
    view.dispatch(tr)
    content.value = md
  },
})
</script>

<template>
  <div ref="menuWrapperRef" style="position: relative">
    <div v-if="mountError" class="w-full min-h-7 text-base">
      <textarea
        v-model="content"
        class="w-full min-h-7 p-1 text-sm font-mono bg-transparent border border-(--ui-error)/50 rounded-md resize-none outline-none"
        :aria-label="`Block editor (mount failed)`"
      />
    </div>
    <div v-else ref="editorRef" class="w-full min-h-7 text-base"></div>

    <EditorSuggestionMenu
      ref="menuRef"
      :active="localSession !== null"
      :groups="localSession?.kind === 'command' ? slashGroups : mentionGroups"
      :position="menuPosition"
      :query="localSession?.query ?? ''"
      @close="closeSession"
    />
  </div>
</template>
