<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
import '@wangeditor/editor/dist/css/style.css';
import { ElImageViewer } from 'element-plus';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';

defineOptions({ name: 'BusinessRichTextEditor' });

interface Props {
  placeholder?: string;
  disabled?: boolean;
  height?: number | string;
  /** 上传目录，传给后端 directory 字段 */
  uploadDirectory?: string;
  /** 单张图片大小上限（MB），默认 5 */
  maxImageSizeMB?: number;
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入内容',
  disabled: false,
  height: 320,
  uploadDirectory: undefined,
  maxImageSizeMB: 5
});

const model = defineModel<string | null | undefined>({ default: '' });

const editorRef = shallowRef<IDomEditor>();
const containerRef = ref<HTMLElement>();

/**
 * 图片预览：
 * - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
 * - 点按钮 → ElImageViewer 多图模式，url-list = 当前 HTML 里所有 img src（按出现顺序去重）
 * - 编辑态与 disabled 只读态共用
 */
const zoomBtnVisible = ref(false);
const zoomBtnStyle = ref<Record<string, string>>({});
const hoveredImageSrc = ref('');

const viewerVisible = ref(false);
const viewerUrlList = ref<string[]>([]);
const viewerIndex = ref(0);

let hideZoomBtnTimer: number | undefined;

function cancelHideZoomBtn() {
  if (hideZoomBtnTimer !== undefined) {
    window.clearTimeout(hideZoomBtnTimer);
    hideZoomBtnTimer = undefined;
  }
}

function scheduleHideZoomBtn() {
  cancelHideZoomBtn();
  hideZoomBtnTimer = window.setTimeout(() => {
    zoomBtnVisible.value = false;
  }, 150);
}

function positionZoomBtn(img: HTMLImageElement) {
  const container = containerRef.value;
  if (!container) {
    return;
  }
  const containerRect = container.getBoundingClientRect();
  const imgRect = img.getBoundingClientRect();
  const btnSize = 28;
  const gap = 8;
  zoomBtnStyle.value = {
    top: `${imgRect.top - containerRect.top + gap}px`,
    left: `${imgRect.right - containerRect.left - btnSize - gap}px`
  };
  hoveredImageSrc.value = img.getAttribute('src') ?? '';
  zoomBtnVisible.value = true;
}

function isZoomBtn(el: EventTarget | null): boolean {
  return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
}

function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
  const container = containerRef.value;
  if (!container) {
    return null;
  }
  const target = e.target as HTMLElement | null;
  // 1) target 本身或祖先链上是 img
  const direct =
    target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
  if (direct && container.contains(direct)) {
    return direct;
  }
  // 2) 兜底：wangeditor 可能在图片上层叠了 resize/selection 遮罩，target 不是 img；用坐标穿透找
  if (typeof document.elementsFromPoint === 'function') {
    const stack = document.elementsFromPoint(e.clientX, e.clientY);
    for (const el of stack) {
      if (el.tagName === 'IMG' && container.contains(el)) {
        return el as HTMLImageElement;
      }
    }
  }
  return null;
}

function onContainerMouseOver(e: MouseEvent) {
  if (isZoomBtn(e.target)) {
    cancelHideZoomBtn();
    return;
  }
  const img = findImageAtPoint(e);
  if (img) {
    cancelHideZoomBtn();
    positionZoomBtn(img);
  } else {
    scheduleHideZoomBtn();
  }
}

function onContainerMouseLeave() {
  scheduleHideZoomBtn();
}

function onTextScroll() {
  // wangeditor 内部滚动后按钮坐标会和图片错位，直接隐藏由下次 hover 重算
  zoomBtnVisible.value = false;
}

function openImageViewer() {
  if (!hoveredImageSrc.value) {
    return;
  }
  const urls = listImageSrcs(model.value);
  const idx = urls.indexOf(hoveredImageSrc.value);
  viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
  viewerIndex.value = idx >= 0 ? idx : 0;
  viewerVisible.value = true;
}

function closeImageViewer() {
  viewerVisible.value = false;
}

/**
 * 会话级清理账本（富文本图片治标）：
 * - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
 * - committed: commit() 调用后置 true，阻止后续 rollback / 卸载兜底重复删
 *
 * 真删时机：
 * - commit(): 扫当前 model HTML，删 uploadedMap 里"url 已不在 HTML"的项（被用户删掉的图）
 * - rollback(): 删 uploadedMap 里所有项（整个会话不要了）
 * - onBeforeUnmount: 兜底走 rollback 等价逻辑
 */
interface RichTextSession {
  uploadedMap: Map<string, string>;
  committed: boolean;
}

const session = reactive<RichTextSession>({
  uploadedMap: new Map(),
  committed: false
});

const toolbarConfig: Partial<IToolbarConfig> = {
  excludeKeys: [
    // 视频组
    'group-video',
    'insertVideo',
    'uploadVideo',
    // 更多样式分组
    'group-more-style',
    // 图片：只允许本地上传，不允许插入网络图片 URL
    'insertImage',
    // 超链接：业务暂不需要
    'insertLink',
    'editLink',
    'unLink',
    'viewLink'
  ]
};

const editorConfig: Partial<IEditorConfig> = {
  placeholder: props.placeholder,
  readOnly: props.disabled,
  MENU_CONF: {
    uploadImage: {
      maxFileSize: props.maxImageSizeMB * 1024 * 1024,
      allowedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp'],
      async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
        const result = await uploadFile(file, props.uploadDirectory);

        if (result.error || !result.data) {
          const msg = result.error?.response?.data?.msg || '图片上传失败';
          window.$message?.error(msg);
          return;
        }

        // 用永久代理路径塞 <img src>，不要用 result.data.url（24h 签名会过期）
        const { id, configId, path } = result.data;
        const proxyUrl = buildFileProxyUrl(configId, path);
        // 记录 url -> fileId，后续 commit/rollback 才知道删哪个
        session.uploadedMap.set(proxyUrl, id);
        insertFn(proxyUrl, file.name, proxyUrl);
      }
    }
  }
};

watch(
  () => props.disabled,
  value => {
    const editor = editorRef.value;
    if (!editor) {
      return;
    }

    if (value) {
      editor.disable();
    } else {
      editor.enable();
    }
  }
);

function handleCreated(editor: IDomEditor) {
  editorRef.value = editor;
  const textContainer = containerRef.value?.querySelector('.w-e-text-container');
  textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
}

/**
 * 从 HTML 字符串里抓所有 <img src="...">，返回 url 集合。
 * 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
 */
function extractImageUrls(html: string | null | undefined): Set<string> {
  const urls = new Set<string>();
  if (!html) {
    return urls;
  }
  const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
  let match: RegExpExecArray | null = re.exec(html);
  while (match !== null) {
    urls.add(match[1]);
    match = re.exec(html);
  }
  return urls;
}

/** 按出现顺序去重列出当前 HTML 内所有 img src，给 ElImageViewer 用。 */
function listImageSrcs(html: string | null | undefined): string[] {
  const list: string[] = [];
  if (!html) {
    return list;
  }
  const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
  let match: RegExpExecArray | null = re.exec(html);
  while (match !== null) {
    if (!list.includes(match[1])) {
      list.push(match[1]);
    }
    match = re.exec(html);
  }
  return list;
}

/** 删除一批 fileId。fire-and-forget；单项失败仅 console.warn。 */
async function deleteMany(ids: string[]) {
  if (ids.length === 0) {
    return;
  }
  await Promise.allSettled(
    ids.map(async id => {
      const { error } = await deleteFile(id);
      if (error) {
        // eslint-disable-next-line no-console
        console.warn('[BusinessRichTextEditor] 删除失败（已忽略）', id, error);
      }
    })
  );
}

defineExpose({
  /**
   * 父组件在【打开弹层并填充 model 之后】调用。
   * 清空 uploadedMap 并重置 committed；HTML 里已有的图（编辑模式回显的）不进 uploadedMap，
   * 因此 commit/rollback 不会动它们——只动本次会话上传的图。
   */
  initSession() {
    session.uploadedMap.clear();
    session.committed = false;
  },

  /**
   * 父组件在【业务保存成功后】调用。
   * 扫当前 model HTML：uploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
   */
  async commit() {
    const currentUrls = extractImageUrls(model.value);
    const toDelete: string[] = [];
    session.uploadedMap.forEach((fileId, url) => {
      if (!currentUrls.has(url)) {
        toDelete.push(fileId);
      }
    });
    session.uploadedMap.clear();
    session.committed = true;
    await deleteMany(toDelete);
  },

  /**
   * 父组件取消/关闭时调用；onBeforeUnmount 也会兜底调一次。
   * 删 uploadedMap 里所有项（整个会话回滚）。
   */
  async rollback() {
    if (session.committed) {
      return;
    }
    const toDelete = Array.from(session.uploadedMap.values());
    session.uploadedMap.clear();
    session.committed = true;
    await deleteMany(toDelete);
  }
});

onBeforeUnmount(() => {
  cancelHideZoomBtn();
  const textContainer = containerRef.value?.querySelector('.w-e-text-container');
  textContainer?.removeEventListener('scroll', onTextScroll);

  // 兜底：用户没显式 rollback 就直接关弹层 / 切路由 / unmount
  if (!session.committed) {
    const toDelete = Array.from(session.uploadedMap.values());
    session.uploadedMap.clear();
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    deleteMany(toDelete);
  }
  editorRef.value?.destroy();
  editorRef.value = undefined;
});

/** 当 height 传 '100%' 或 'auto' 时启用「撑满父容器」模式 —— 父级必须有具体高度。 */
const isAutoFill = computed(() => props.height === '100%' || props.height === 'auto');

const containerClass = computed(() => ({
  'business-rich-text-editor': true,
  'business-rich-text-editor--auto-fill': isAutoFill.value
}));

const editorStyle = computed(() => {
  if (isAutoFill.value) {
    return { flex: 1, minHeight: 0, overflowY: 'hidden' as const };
  }

  return {
    height: typeof props.height === 'number' ? `${props.height}px` : props.height,
    overflowY: 'hidden' as const
  };
});
</script>

<template>
  <div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
    <Toolbar
      class="business-rich-text-editor__toolbar"
      :editor="editorRef"
      :default-config="toolbarConfig"
      mode="default"
    />
    <Editor
      v-model="model"
      class="business-rich-text-editor__editor"
      :style="editorStyle"
      :default-config="editorConfig"
      mode="default"
      @on-created="handleCreated"
    />
    <button
      v-show="zoomBtnVisible"
      type="button"
      class="business-rich-text-editor__zoom-btn"
      :style="zoomBtnStyle"
      title="预览图片"
      aria-label="预览图片"
      @click.stop="openImageViewer"
    >
      <svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
        <path
          d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
        />
      </svg>
    </button>
    <ElImageViewer
      v-if="viewerVisible"
      :url-list="viewerUrlList"
      :initial-index="viewerIndex"
      :z-index="3100"
      teleported
      hide-on-click-modal
      @close="closeImageViewer"
    />
  </div>
</template>

<style scoped lang="scss">
.business-rich-text-editor {
  position: relative;
  width: 100%;
  border: 1px solid var(--el-border-color);
  border-radius: var(--el-border-radius-base);
  overflow: hidden;
  background: var(--el-bg-color);

  &__toolbar {
    border-bottom: 1px solid var(--el-border-color);
    background: var(--el-fill-color-light);
  }

  &__editor {
    background: var(--el-bg-color);
  }

  &--auto-fill {
    display: flex;
    flex-direction: column;
    height: 100%;
    min-height: 0;
  }

  &__zoom-btn {
    position: absolute;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    padding: 0;
    border: none;
    border-radius: 4px;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    cursor: pointer;
    transition: background 0.15s;
    z-index: 10;

    &:hover {
      background: rgba(0, 0, 0, 0.75);
    }
  }
}

/* wangeditor 弹层（链接、图片菜单等）默认 z-index 偏低，提高一档避免被 ElDialog 遮挡 */
:deep(.w-e-modal),
:deep(.w-e-drop-panel),
:deep(.w-e-bar-divider),
:deep(.w-e-hover-bar) {
  z-index: 3000 !important;
}
</style>
