<template>
  <div
    class="vm-richtext"
    :class="{
      'is-readonly': readOnlyMode,
      'is-disabled': disabled,
    }"
    :style="shellStyle"
  >
    <div
      v-if="readOnlyMode"
      class="vm-richtext__html"
      v-html="safeHtml"
    />

    <div
      v-else
      class="vm-richtext__shell"
    >
      <div
        v-if="editor"
        class="vm-richtext__toolbar"
      >
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bold') }" :title="crudLabel('rtBold')" @click="editor.chain().focus().toggleBold().run()"><i class="ri-bold" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('italic') }" :title="crudLabel('rtItalic')" @click="editor.chain().focus().toggleItalic().run()"><i class="ri-italic" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('underline') }" :title="crudLabel('rtUnderline')" @click="editor.chain().focus().toggleUnderline().run()"><i class="ri-underline" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('strike') }" :title="crudLabel('rtStrike')" @click="editor.chain().focus().toggleStrike().run()"><i class="ri-strikethrough" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('highlight') }" :title="crudLabel('rtHighlight')" @click="editor.chain().focus().toggleHighlight().run()"><i class="ri-mark-pen-line" /></button>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :title="crudLabel('rtTextColor')" @click="pickTextColor"><i class="ri-font-color" /></button>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 2 }) }" :title="crudLabel('rtH2')" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('heading', { level: 3 }) }" :title="crudLabel('rtH3')" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()">H3</button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('paragraph') }" :title="crudLabel('rtParagraph')" @click="editor.chain().focus().setParagraph().run()">P</button>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }" :title="crudLabel('rtAlignLeft')" @click="editor.chain().focus().setTextAlign('left').run()"><i class="ri-align-left" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }" :title="crudLabel('rtAlignCenter')" @click="editor.chain().focus().setTextAlign('center').run()"><i class="ri-align-center" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }" :title="crudLabel('rtAlignRight')" @click="editor.chain().focus().setTextAlign('right').run()"><i class="ri-align-right" /></button>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('bulletList') }" :title="crudLabel('rtBulletList')" @click="editor.chain().focus().toggleBulletList().run()"><i class="ri-list-unordered" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('orderedList') }" :title="crudLabel('rtOrderedList')" @click="editor.chain().focus().toggleOrderedList().run()"><i class="ri-list-ordered" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('taskList') }" :title="crudLabel('rtTaskList')" @click="editor.chain().focus().toggleTaskList().run()"><i class="ri-checkbox-line" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('blockquote') }" :title="crudLabel('rtBlockquote')" @click="editor.chain().focus().toggleBlockquote().run()"><i class="ri-double-quotes-l" /></button>
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('codeBlock') }" :title="crudLabel('rtCodeBlock')" @click="editor.chain().focus().toggleCodeBlock().run()"><i class="ri-code-box-line" /></button>
        <button type="button" class="vm-richtext__btn" :title="crudLabel('rtHr')" @click="editor.chain().focus().setHorizontalRule().run()"><i class="ri-separator" /></button>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :class="{ 'is-active': editor.isActive('link') }" :title="crudLabel('rtLink')" @click="toggleLink"><i class="ri-link" /></button>
        <button v-if="uploadEnabled" type="button" class="vm-richtext__btn" :title="crudLabel('rtImage')" @click="pickImage"><i class="ri-image-line" /></button>
        <button v-if="uploadEnabled" type="button" class="vm-richtext__btn" :title="crudLabel('rtVideo')" @click="pickVideo"><i class="ri-video-line" /></button>
        <button
          ref="emojiBtnRef"
          type="button"
          class="vm-richtext__btn"
          :class="{ 'is-active': emojiOpen }"
          :title="crudLabel('rtEmoji')"
          @click="toggleEmoji"
        >
          <i class="ri-emotion-line" />
        </button>
        <Teleport to="body">
          <div
            v-if="emojiOpen"
            ref="emojiPanelRef"
            class="vm-richtext__emoji-panel vm-dialog-float"
            :style="emojiPanelStyle"
            @pointerdown.stop
            @mousedown.stop
          >
            <button
              v-for="(e, i) in VM_RICHTEXT_EMOJIS"
              :key="i"
              type="button"
              class="vm-richtext__emoji-item"
              @click.stop="insertEmoji(e)"
            >
              {{ e }}
            </button>
          </div>
        </Teleport>
        <span class="vm-richtext__sep" />
        <button type="button" class="vm-richtext__btn" :title="crudLabel('rtTable')" @click="insertTable"><i class="ri-table-line" /></button>
        <button type="button" class="vm-richtext__btn" :title="crudLabel('rtUndo')" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"><i class="ri-arrow-go-back-line" /></button>
        <button type="button" class="vm-richtext__btn" :title="crudLabel('rtRedo')" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"><i class="ri-arrow-go-forward-line" /></button>
      </div>

      <EditorContent class="vm-richtext__content" :editor="editor" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { crudLabel } from '../style'
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import DOMPurify from 'dompurify'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
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 { useUpload } from '../../hooks/useUpload'
import { VM_RICHTEXT_EMOJIS, VmRichtextVideo } from './vm-richtext-extensions'

defineOptions({ name: 'vm-richtext' })

const RICH_TAGS = [
  'p', 'br', 'div', 'span', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'strike', 'a',
  'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
  'img', 'video', 'source', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub',
  'mark',
]

const props = withDefaults(
  defineProps<{
    modelValue?: string | null
    readonly?: boolean
    disabled?: boolean
    placeholder?: string
    height?: number | string
    uploadImage?: (file: File) => Promise<string>
    uploadPrefixPath?: string
    autoUpload?: boolean
  }>(),
  {
    readonly: false,
    placeholder: undefined,
    height: 320,
    uploadPrefixPath: 'app/public/richtext',
    autoUpload: true,
  },
)

const emit = defineEmits<{ 'update:modelValue': [v: string] }>()

const { toUpload } = useUpload()
const syncing = shallowRef(false)
const uploading = shallowRef(false)
const emojiOpen = shallowRef(false)
const emojiBtnRef = shallowRef<HTMLElement>()
const emojiPanelRef = shallowRef<HTMLElement>()
const emojiPanelStyle = shallowRef<Record<string, string>>({})

const readOnlyMode = computed(() => props.readonly)
const uploadEnabled = computed(() => Boolean(props.uploadImage) || props.autoUpload !== false)

const shellStyle = computed(() => {
  const h = typeof props.height === 'number' ? `${props.height}px` : String(props.height || '320px')
  return { '--vm-richtext-height': h } as Record<string, string>
})

function sanitize(html: string) {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: RICH_TAGS,
    ALLOWED_ATTR: [
      'href', 'target', 'rel', 'class', 'src', 'alt', 'title', 'width', 'height',
      'colspan', 'rowspan', 'align', 'controls', 'style',
    ],
  })
}

function normalizeHtml(html: string) {
  const safe = sanitize(html)
  const text = safe.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim()
  return text ? safe : ''
}

const safeHtml = computed(() => sanitize(String(props.modelValue || '')))

async function resolveUpload(file: File): Promise<string> {
  if (props.uploadImage) return props.uploadImage(file)
  if (props.autoUpload === false) throw new Error(crudLabel('noUploadImage'))
  const res = await toUpload(file, { prefixPath: props.uploadPrefixPath })
  return res.url
}

async function insertImage(file: File) {
  if (!editor.value || uploading.value) return
  uploading.value = true
  try {
    const url = await resolveUpload(file)
    editor.value.chain().focus().setImage({ src: url, alt: file.name || 'image' }).run()
  } finally {
    uploading.value = false
  }
}

async function insertVideo(file: File) {
  if (!editor.value || uploading.value) return
  uploading.value = true
  try {
    const url = await resolveUpload(file)
    editor.value.chain().focus().setVideo({ src: url }).run()
  } finally {
    uploading.value = false
  }
}

function pickFile(accept: string, handler: (file: File) => void) {
  const input = document.createElement('input')
  input.type = 'file'
  input.accept = accept
  input.onchange = () => {
    const file = input.files?.[0]
    if (file) handler(file)
  }
  input.click()
}

function pickImage() {
  pickFile('image/*', (file) => void insertImage(file))
}

function pickVideo() {
  pickFile('video/*', (file) => void insertVideo(file))
}

function placeEmojiPanel() {
  const btn = emojiBtnRef.value
  if (!btn) return
  const rect = btn.getBoundingClientRect()
  // 10×28 + 9×2 gap + 16 padding
  const panelW = 314
  const panelH = 280
  const gap = 6
  const pad = 8
  // 右缘对齐按钮（往左展开，避免溢出大弹窗右侧）
  let left = rect.right - panelW
  let top = rect.bottom + gap

  const host =
    (btn.closest('[data-slot="dialog-content"]') as HTMLElement | null) ||
    (btn.closest('.vm-dialog-shell-crud') as HTMLElement | null)
  const bound = host?.getBoundingClientRect() ?? {
    left: pad,
    right: window.innerWidth - pad,
    top: pad,
    bottom: window.innerHeight - pad,
  }

  left = Math.min(left, bound.right - panelW - pad)
  left = Math.max(left, bound.left + pad)
  if (top + panelH > bound.bottom - pad) {
    top = Math.max(bound.top + pad, rect.top - panelH - gap)
  }

  emojiPanelStyle.value = {
    position: 'fixed',
    left: `${Math.round(left)}px`,
    top: `${Math.round(top)}px`,
    width: `${panelW}px`,
    zIndex: '10050',
  }
}

async function toggleEmoji() {
  emojiOpen.value = !emojiOpen.value
  if (emojiOpen.value) {
    await nextTick()
    placeEmojiPanel()
  }
}

function insertEmoji(emoji: string) {
  editor.value?.chain().focus().insertContent(emoji).run()
  emojiOpen.value = false
}

function insertTable() {
  editor.value
    ?.chain()
    .focus()
    .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
    .run()
}

function pickTextColor() {
  const ed = editor.value
  if (!ed) return
  const current = (ed.getAttributes('textStyle').color as string | undefined) || '#111827'
  const color = window.prompt(crudLabel('textColorPrompt'), current)
  if (color == null) return
  const v = color.trim()
  if (!v) {
    ed.chain().focus().unsetColor().run()
    return
  }
  ed.chain().focus().setColor(v).run()
}

function toggleLink() {
  const ed = editor.value
  if (!ed) return
  const prev = ed.getAttributes('link').href as string | undefined
  if (ed.isActive('link')) {
    ed.chain().focus().unsetLink().run()
    return
  }
  const url = window.prompt(crudLabel('linkUrlPrompt'), prev || 'https://')
  if (url == null) return
  const href = url.trim()
  if (!href) {
    ed.chain().focus().unsetLink().run()
    return
  }
  ed.chain().focus().extendMarkRange('link').setLink({ href, target: '_blank', rel: 'noopener noreferrer' }).run()
}

function handleMediaPaste(event: ClipboardEvent) {
  if (!uploadEnabled.value) return false
  const items = Array.from(event.clipboardData?.items || [])
  const imageItem = items.find((item) => item.type.startsWith('image/'))
  if (imageItem) {
    const file = imageItem.getAsFile()
    if (!file) return false
    event.preventDefault()
    void insertImage(file)
    return true
  }
  const videoItem = items.find((item) => item.type.startsWith('video/'))
  if (videoItem) {
    const file = videoItem.getAsFile()
    if (!file) return false
    event.preventDefault()
    void insertVideo(file)
    return true
  }
  return false
}

function handleMediaDrop(event: DragEvent, moved: boolean) {
  if (moved || !uploadEnabled.value) return false
  const files = Array.from(event.dataTransfer?.files || [])
  const image = files.find((f) => f.type.startsWith('image/'))
  if (image) {
    event.preventDefault()
    void insertImage(image)
    return true
  }
  const video = files.find((f) => f.type.startsWith('video/'))
  if (video) {
    event.preventDefault()
    void insertVideo(video)
    return true
  }
  return false
}

const editor = useEditor({
  content: normalizeHtml(String(props.modelValue || '')),
  editable: !props.disabled,
  extensions: [
    StarterKit.configure({ heading: { levels: [2, 3] } }),
    Underline,
    TextStyle,
    Color,
    Highlight.configure({ multicolor: false }),
    TextAlign.configure({ types: ['heading', 'paragraph'] }),
    TaskList,
    TaskItem.configure({ nested: true }),
    Link.configure({
      openOnClick: false,
      HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
    }),
    Image.configure({ allowBase64: false }),
    VmRichtextVideo,
    Table.configure({ resizable: true }),
    TableRow,
    TableHeader,
    TableCell,
    Placeholder.configure({
      placeholder: props.placeholder ?? crudLabel('richtextPlaceholder'),
    }),
  ],
  editorProps: {
    attributes: { class: 'vm-richtext__prose' },
    handlePaste: (_view, event) => handleMediaPaste(event),
    handleDrop: (_view, event, _slice, moved) => handleMediaDrop(event, moved),
  },
  onUpdate: ({ editor: ed }) => {
    if (syncing.value) return
    emit('update:modelValue', normalizeHtml(ed.getHTML()))
  },
})

watch(() => props.modelValue, (next) => {
  const ed = editor.value
  if (!ed || readOnlyMode.value) return
  const html = normalizeHtml(String(next || ''))
  const current = normalizeHtml(ed.getHTML())
  if (current === html) return
  syncing.value = true
  ed.commands.setContent(html || '<p></p>', false)
  syncing.value = false
})

watch(() => props.disabled, (v) => {
  editor.value?.setEditable(!v)
})

function onDocPointer(e: Event) {
  if (!emojiOpen.value) return
  const t = e.target as Node
  if (emojiBtnRef.value?.contains(t)) return
  if (emojiPanelRef.value?.contains(t)) return
  emojiOpen.value = false
}

function onWinReposition() {
  if (emojiOpen.value) placeEmojiPanel()
}

onMounted(() => {
  document.addEventListener('pointerdown', onDocPointer, true)
  window.addEventListener('resize', onWinReposition)
  window.addEventListener('scroll', onWinReposition, true)
})
onBeforeUnmount(() => {
  document.removeEventListener('pointerdown', onDocPointer, true)
  window.removeEventListener('resize', onWinReposition)
  window.removeEventListener('scroll', onWinReposition, true)
  editor.value?.destroy()
})
</script>

<style lang="scss" scoped>
.vm-richtext {
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
}

.vm-richtext.is-disabled {
  opacity: 0.65;
  pointer-events: none;
}

.vm-richtext__shell {
  display: flex;
  flex-direction: column;
  width: 100%;
  min-width: 0;
  border: 1px solid var(--input);
  border-radius: var(--vm-control-radius);
  background: var(--card);
  overflow: hidden;
}

.vm-richtext__toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 2px;
  padding: 6px 8px;
  border-bottom: 1px solid var(--border);
  background: var(--muted);
}

.vm-richtext__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 28px;
  height: 28px;
  padding: 0 6px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: var(--foreground);
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;

  i { font-size: 15px; line-height: 1; }

  &:hover:not(:disabled) {
    background: color-mix(in srgb, var(--brand) 10%, transparent);
    color: var(--brand);
  }

  &.is-active {
    background: color-mix(in srgb, var(--brand) 16%, transparent);
    color: var(--brand);
  }

  &:disabled { opacity: 0.35; cursor: not-allowed; }
}

.vm-richtext__sep {
  width: 1px;
  height: 18px;
  margin: 0 4px;
  background: var(--border);
}

.vm-richtext__content {
  height: var(--vm-richtext-height, 320px) !important;
  min-height: var(--vm-richtext-height, 320px) !important;
  flex-shrink: 0;
  overflow-y: auto;
  background: var(--card);
}

.vm-richtext__html {
  padding: 0;
  color: var(--foreground);
  font-size: 14px;
  line-height: 1.65;
  word-break: break-word;

  :deep(p) { margin: 0.5em 0; }
  :deep(ul), :deep(ol) { margin: 0.5em 0; padding-left: 1.25em; }
  :deep(a) { color: var(--brand); text-decoration: underline; }
  :deep(img), :deep(video) { max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
  :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
  :deep(th), :deep(td) { border: 1px solid var(--border); padding: 6px 8px; }
  :deep(blockquote) { margin: 0.5em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
  :deep(h1), :deep(h2), :deep(h3), :deep(h4) { margin: 0.65em 0 0.35em; font-weight: 650; line-height: 1.35; }
}

.vm-richtext.is-readonly .vm-richtext__html {
  padding: 0;
  border: none;
  background: transparent;
}
</style>

<style lang="scss">
/* Teleport 到 body：Dialog modal 会给 body pointer-events:none，须 vm-dialog-float 恢复 */
.vm-richtext__emoji-panel {
  box-sizing: border-box;
  display: grid;
  grid-template-columns: repeat(10, 28px);
  gap: 2px;
  width: 314px;
  max-height: 280px;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 8px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--card);
  box-shadow: var(--shadow-soft, 0 8px 24px rgba(0, 0, 0, 0.12));
  scrollbar-width: thin;
  pointer-events: auto;
}

.vm-richtext__emoji-item {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border: none;
  border-radius: 6px;
  background: transparent;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;

  &:hover {
    background: var(--muted);
  }
}

.vm-richtext__content .tiptap.vm-richtext__prose {
  min-height: 100%;
  padding: 12px 14px;
  outline: none;
  color: var(--foreground);
  font-size: 14px;
  line-height: 1.65;

  p { margin: 0.45em 0; }

  p.is-editor-empty:first-child::before {
    content: attr(data-placeholder);
    float: left;
    height: 0;
    pointer-events: none;
    color: var(--muted-foreground);
  }

  h2, h3 { margin: 0.75em 0 0.35em; font-weight: 650; line-height: 1.35; }
  ul, ol { margin: 0.45em 0; padding-left: 1.25em; }
  ul[data-type='taskList'] { list-style: none; padding-left: 0.25em; }
  ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
  ul[data-type='taskList'] li label { margin-top: 2px; }
  blockquote { margin: 0.45em 0; padding-left: 12px; border-left: 3px solid var(--border); color: var(--muted-foreground); }
  a { color: var(--brand); text-decoration: underline; cursor: pointer; }
  img, video { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
  mark { background: color-mix(in srgb, var(--brand) 22%, transparent); border-radius: 2px; padding: 0 2px; }
  code { padding: 0.1em 0.35em; border-radius: 4px; background: var(--muted); font-size: 0.92em; }
  pre { margin: 0.5em 0; padding: 10px 12px; border-radius: 8px; background: var(--muted); overflow-x: auto;
    code { padding: 0; background: transparent; }
  }
  table { width: 100%; border-collapse: collapse; margin: 8px 0; }
  th, td { border: 1px solid var(--border); padding: 6px 8px; vertical-align: top; }
  th { background: var(--muted); font-weight: 600; }
  hr { margin: 12px 0; border: none; border-top: 1px solid var(--border); }
}
</style>
