<script setup lang="ts">
import {
  computed,
  nextTick,
  onBeforeUnmount,
  onMounted,
  ref,
  watch,
} from "vue";
import { Fancybox } from "@fancyapps/ui";
import "@fancyapps/ui/dist/fancybox/fancybox.css";
import { unified, type Plugin, type Transformer } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
import MarkdownCodeEditor from "./MarkdownCodeEditor.vue";
import { highlightCodeToHtml } from "@/lib/codeHighlight";
import { resolveMarkdownCodeLanguage } from "@/lib/markdownLanguages";

type DocumentMode = "preview" | "edit" | "mixed";
type ClipboardMediaKind = "image" | "video";

const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "svg"]);
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "mov", "m4v", "ogv"]);
const VIDEO_MIME_TYPES: Record<string, string> = {
  mp4: "video/mp4",
  webm: "video/webm",
  mov: "video/quicktime",
  m4v: "video/mp4",
  ogv: "video/ogg",
};

interface MarkdownAstNode {
  type?: string;
  position?: {
    start?: { offset?: number };
    end?: { offset?: number };
  };
}

interface MarkdownTreeNode {
  type: string;
  url?: string;
  alt?: string | null;
  children?: MarkdownTreeNode[];
  data?: Record<string, unknown>;
}

interface MarkdownTree {
  type: string;
  children?: MarkdownTreeNode[];
  data?: Record<string, unknown>;
}

interface MarkdownBlock {
  id: string;
  type: string;
  source: string;
  html: string;
  startOffset: number;
  endOffset: number;
}

interface MarkdownCodeEditorApi {
  focus: () => void;
  focusAtEnd: () => void;
  replaceRange: (text: string, from?: number, to?: number) => void;
  scrollToLine: (lineNumber: number) => void;
  setCursorPosition: (pos: number) => void;
}

const props = withDefaults(
  defineProps<{
    modelValue?: string;
    placeholder?: string;
    documentMode?: DocumentMode;
    previewOnly?: boolean;
    filePath?: string | null;
    renderSingleLineBreaks?: boolean;
    paragraphIndent?: boolean;
  }>(),
  {
    modelValue: "",
    placeholder: "开始写作...",
    documentMode: undefined,
    previewOnly: false,
    filePath: null,
    renderSingleLineBreaks: false,
    paragraphIndent: false,
  },
);

const emit = defineEmits<{
  "update:modelValue": [value: string];
  "save-shortcut": [value?: string];
}>();

function getFileExtension(path: string): string {
  const cleanPath = path.split(/[?#]/, 1)[0] || path;
  const fileName = cleanPath.split(/[/\\]/).pop() || cleanPath;
  const dotIndex = fileName.lastIndexOf(".");
  if (dotIndex < 0) return "";

  return fileName.slice(dotIndex + 1).toLowerCase();
}

function getCurrentFileDirectory() {
  if (!props.filePath) return "";

  const separatorIndex = Math.max(
    props.filePath.lastIndexOf("/"),
    props.filePath.lastIndexOf("\\"),
  );
  if (separatorIndex < 0) return "";

  return props.filePath.slice(0, separatorIndex);
}

function isWindowsAbsolutePath(path: string) {
  return /^[A-Za-z]:[\\/]/.test(path);
}

function isUncPath(path: string) {
  return path.startsWith("\\\\");
}

function isExternalUrl(url: string) {
  if (isWindowsAbsolutePath(url) || isUncPath(url)) return false;
  return /^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//") || url.startsWith("#");
}

function normalizeLocalMediaPath(url: string) {
  const trimmed = url.trim();
  if (!trimmed || isExternalUrl(trimmed)) return trimmed;

  if (isWindowsAbsolutePath(trimmed) || isUncPath(trimmed)) {
    return trimmed;
  }

  const directory = getCurrentFileDirectory();
  if (!directory) return trimmed;

  const separator = directory.includes("\\") ? "\\" : "/";
  const normalizedRelativePath = trimmed.replace(/[\\/]+/g, separator);
  return `${directory}${separator}${normalizedRelativePath}`;
}

function toAssetSrc(path: string) {
  if (isExternalUrl(path)) return path;
  if (isTauri()) return convertFileSrc(path);
  if (isWindowsAbsolutePath(path)) return `file:///${path.replace(/\\/g, "/")}`;
  if (isUncPath(path)) return `file:${path.replace(/\\/g, "/")}`;

  return path;
}

function getVideoProperties(url: string, alt?: string | null) {
  const ext = getFileExtension(url);
  const properties: Record<string, string | boolean> = {
    controls: true,
    preload: "metadata",
    src: toAssetSrc(normalizeLocalMediaPath(url)),
  };

  if (alt) properties.title = alt;
  if (VIDEO_MIME_TYPES[ext]) properties.type = VIDEO_MIME_TYPES[ext];

  return properties;
}

const remarkLocalMedia: Plugin<[], MarkdownTree> = function (): Transformer<MarkdownTree> {
  return (tree) => {
    visitMediaNodes(tree.children || []);
  };
};

function visitMediaNodes(children: MarkdownTreeNode[]) {
  for (let index = 0; index < children.length; index += 1) {
    const child = children[index];

    if (child.children) {
      visitMediaNodes(child.children);
    }

    if (child.type !== "image" || !child.url) continue;

    const ext = getFileExtension(child.url);
    if (VIDEO_EXTENSIONS.has(ext)) {
      child.data = {
        ...child.data,
        hName: "video",
        hProperties: getVideoProperties(child.url, child.alt),
        hChildren: [],
      };
      continue;
    }

    if (!IMAGE_EXTENSIONS.has(ext) || isExternalUrl(child.url)) continue;

    child.url = toAssetSrc(normalizeLocalMediaPath(child.url));
  }
}

// ── Unified processors ─────────────────────────────────────────────

const mediaSanitizeSchema = {
  ...defaultSchema,
  attributes: {
    ...defaultSchema.attributes,
    img: [...(defaultSchema.attributes?.img || []), "alt", "title"],
    source: [...(defaultSchema.attributes?.source || []), "src", "type"],
    video: [
      ...(defaultSchema.attributes?.video || []),
      "controls",
      "preload",
      "src",
      "title",
      "type",
    ],
  },
  protocols: {
    ...defaultSchema.protocols,
    src: [...(defaultSchema.protocols?.src || []), "asset"],
  },
  tagNames: [...(defaultSchema.tagNames || []), "video"],
};

function createMarkdownProcessor(renderSingleLineBreaks: boolean) {
  const processor = unified().use(remarkParse).use(remarkGfm);

  if (renderSingleLineBreaks) {
    processor.use(remarkBreaks);
  }

  return processor
    .use(remarkLocalMedia)
    .use(remarkRehype, { allowDangerousHtml: false })
    .use(rehypeSanitize, mediaSanitizeSchema)
    .use(rehypeStringify);
}

/** Full pipelines: standard Markdown and optional single-line breaks. */
const standardMd2html = createMarkdownProcessor(false);
const singleLineBreaksMd2html = createMarkdownProcessor(true);

const mdParser = unified().use(remarkParse).use(remarkGfm);

const activeDocumentMode = computed<DocumentMode>(() => {
  return props.documentMode ?? (props.previewOnly ? "preview" : "edit");
});

// ── Rendering ──────────────────────────────────────────────────────

const renderedHtml = ref("");
const renderError = ref(false);
const markdownBlocks = ref<MarkdownBlock[]>([]);

const editorCodeRef = ref<MarkdownCodeEditorApi>();
const scrollRef = ref<HTMLDivElement>();
const blockCodeRef = ref<MarkdownCodeEditorApi>();

const EMPTY_BLOCK_ID = "__empty__";
const editingBlockId = ref<string | null>(null);
const editingSource = ref("");
const editingBaseValue = ref("");
const editingRange = ref<{ startOffset: number; endOffset: number } | null>(
  null,
);
const pendingLocalModelUpdate = ref(false);
const isEditingEmptyDocument = computed(
  () => editingBlockId.value === EMPTY_BLOCK_ID,
);
const previewParagraphStyle = computed(() => ({
  "--paragraph-indent": props.paragraphIndent ? "2em" : "0",
}));
let modeScrollRestoreFrame: number | null = null;
let renderRequestId = 0;
const previewImageSelector = ".markdown-preview:not(.markdown-mixed) img";

interface ScrollSnapshot {
  ratio: number;
}

function getScrollableDistance(el: HTMLElement) {
  return Math.max(0, el.scrollHeight - el.clientHeight);
}

function captureScrollSnapshot(): ScrollSnapshot | null {
  const el = scrollRef.value;
  if (!el) return null;

  const maxScrollTop = getScrollableDistance(el);
  return {
    ratio: maxScrollTop > 0 ? el.scrollTop / maxScrollTop : 0,
  };
}

function restoreScrollSnapshot(snapshot: ScrollSnapshot | null) {
  const el = scrollRef.value;
  if (!el || !snapshot) return;

  const maxScrollTop = getScrollableDistance(el);
  el.scrollTop = maxScrollTop * snapshot.ratio;
}

function restoreScrollAfterModeChange(
  snapshot: ScrollSnapshot | null,
) {
  if (!snapshot) return;

  if (modeScrollRestoreFrame !== null) {
    cancelAnimationFrame(modeScrollRestoreFrame);
    modeScrollRestoreFrame = null;
  }

  void nextTick(() => {
    restoreScrollSnapshot(snapshot);

    modeScrollRestoreFrame = requestAnimationFrame(() => {
      modeScrollRestoreFrame = null;
      restoreScrollSnapshot(snapshot);
    });
  });
}

function syncRenderedState(md: string, mode = activeDocumentMode.value) {
  if (mode === "preview") {
    void renderFullHtml(md);
    return;
  }

  if (mode === "mixed") {
    void parseBlocks(md);
  }
}

async function highlightCodeBlocks(html: string) {
  if (!html.includes("<pre><code")) return html;

  const document = new DOMParser().parseFromString(html, "text/html");
  const codeBlocks = Array.from(document.querySelectorAll("pre > code"));

  await Promise.all(
    codeBlocks.map(async (code) => {
      const languageClass = Array.from(code.classList).find((className) =>
        className.startsWith("language-"),
      );
      const requestedLanguage = languageClass?.slice("language-".length) || "text";
      const language = resolveMarkdownCodeLanguage(requestedLanguage)?.shiki || "text";
      code.parentElement?.setAttribute("data-language", requestedLanguage);

      try {
        const highlighted = await highlightCodeToHtml(code.textContent || "", language);
        if (!highlighted) return;
        const fragment = new DOMParser().parseFromString(highlighted, "text/html");
        const highlightedPre = fragment.body.firstElementChild;
        if (highlightedPre) {
          highlightedPre.setAttribute("data-language", requestedLanguage);
          code.parentElement?.replaceWith(highlightedPre);
        }
      } catch {
        code.parentElement?.setAttribute("data-language", requestedLanguage);
      }
    }),
  );

  return document.body.innerHTML;
}

async function renderMarkdownHtml(md: string) {
  const processor = props.renderSingleLineBreaks ? singleLineBreaksMd2html : standardMd2html;
  const file = processor.processSync(md);
  return highlightCodeBlocks(String(file.value));
}

async function renderFullHtml(md: string) {
  const requestId = ++renderRequestId;
  try {
    const html = await renderMarkdownHtml(md);
    if (requestId !== renderRequestId || activeDocumentMode.value !== "preview") return;
    renderedHtml.value = html;
    renderError.value = false;
  } catch {
    if (requestId !== renderRequestId) return;
    renderedHtml.value = "";
    renderError.value = true;
  }
}

function escapeHtml(value: string) {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

async function renderBlockHtml(source: string) {
  try {
    return await renderMarkdownHtml(source);
  } catch {
    return `<pre><code>${escapeHtml(source)}</code></pre>`;
  }
}

async function parseBlocks(md: string) {
  const requestId = ++renderRequestId;
  if (!md.trim()) {
    markdownBlocks.value = [];
    renderError.value = false;
    return;
  }

  try {
    const tree = mdParser.parse(md) as { children?: MarkdownAstNode[] };
    const blockCandidates = (tree.children ?? [])
      .map((node, index): Omit<MarkdownBlock, "html"> | null => {
        const startOffset = node.position?.start?.offset;
        const endOffset = node.position?.end?.offset;

        if (
          typeof startOffset !== "number" ||
          typeof endOffset !== "number" ||
          endOffset < startOffset
        ) {
          return null;
        }

        const source = md.slice(startOffset, endOffset);
        return {
          id: `${index}-${startOffset}-${endOffset}`,
          type: node.type || "unknown",
          source,
          startOffset,
          endOffset,
        };
      })
      .filter((block): block is Omit<MarkdownBlock, "html"> => block !== null);

    const blocks = await Promise.all(
      blockCandidates.map(async (block) => ({
        ...block,
        html: await renderBlockHtml(block.source),
      })),
    );

    if (requestId !== renderRequestId || activeDocumentMode.value !== "mixed") return;
    markdownBlocks.value = blocks;
    renderError.value = false;
  } catch {
    const html = await renderBlockHtml(md);
    if (requestId !== renderRequestId || activeDocumentMode.value !== "mixed") return;
    markdownBlocks.value = [
      {
        id: "fallback-0",
        type: "unknown",
        source: md,
        html,
        startOffset: 0,
        endOffset: md.length,
      },
    ];
    renderError.value = false;
  }
}

watch(
  () => props.modelValue,
  (val) => {
    const shouldResetScroll = !pendingLocalModelUpdate.value;
    const isLocalUpdate = pendingLocalModelUpdate.value;
    pendingLocalModelUpdate.value = false;

    if (!isLocalUpdate) {
      syncRenderedState(val);
    }

    if (editingBlockId.value && val !== editingBaseValue.value) {
      cancelBlockEdit();
    }

    nextTick(() => {
      // 外部切换文件时重置滚动位置；本组件内编辑时保留当前位置。
      if (shouldResetScroll && scrollRef.value) scrollRef.value.scrollTop = 0;
    });
  },
  { immediate: true },
);

watch(
  () => props.renderSingleLineBreaks,
  () => {
    syncRenderedState(props.modelValue);
  },
);

// ── Editor handlers ────────────────────────────────────────────────

function focusBlockEditor() {
  void nextTick(() => {
    blockCodeRef.value?.focusAtEnd();
  });
}

function emitModelValue(value: string) {
  pendingLocalModelUpdate.value = true;
  emit("update:modelValue", value);
}

function applyFullEditorValue(value: string) {
  emitModelValue(value);
}

function isSaveShortcut(e: KeyboardEvent) {
  return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s";
}

function requestSave(e?: KeyboardEvent) {
  e?.preventDefault();
  e?.stopPropagation();
  const committedValue = flushPendingChanges();
  emit("save-shortcut", committedValue);
}

function onRootKeydown(e: KeyboardEvent) {
  if (isSaveShortcut(e)) {
    requestSave(e);
  }
}

// ── Mixed mode block editing ───────────────────────────────────────

function startBlockEdit(block: MarkdownBlock) {
  if (activeDocumentMode.value !== "mixed") return;
  if (editingBlockId.value && editingBlockId.value !== block.id) {
    finalizeBlockEdit();
  }

  editingBlockId.value = block.id;
  editingSource.value = block.source;
  editingBaseValue.value = props.modelValue;
  editingRange.value = {
    startOffset: block.startOffset,
    endOffset: block.endOffset,
  };
  focusBlockEditor();
}

function startEmptyBlockEdit() {
  if (activeDocumentMode.value !== "mixed") return;
  editingBlockId.value = EMPTY_BLOCK_ID;
  editingSource.value = props.modelValue;
  editingBaseValue.value = props.modelValue;
  editingRange.value = {
    startOffset: 0,
    endOffset: props.modelValue.length,
  };
  focusBlockEditor();
}

function cancelBlockEdit() {
  editingBlockId.value = null;
  editingSource.value = "";
  editingBaseValue.value = "";
  editingRange.value = null;
}

function finalizeBlockEdit(): string | undefined {
  const range = editingRange.value;
  if (!editingBlockId.value || !range) {
    cancelBlockEdit();
    return undefined;
  }

  const nextValue =
    props.modelValue.slice(0, range.startOffset) +
    editingSource.value +
    props.modelValue.slice(range.endOffset);

  cancelBlockEdit();

  if (nextValue !== props.modelValue) {
    emitModelValue(nextValue);
    syncRenderedState(nextValue);
  }

  return nextValue;
}

function flushPendingChanges(): string | undefined {
  if (!editingBlockId.value) return undefined;
  return finalizeBlockEdit();
}

async function mergeCurrentBlockWithPrevious() {
  if (activeDocumentMode.value !== "mixed") return;
  const blockId = editingBlockId.value;
  if (!blockId) return;

  const blockIndex = markdownBlocks.value.findIndex((b) => b.id === blockId);
  if (blockIndex <= 0) return;

  const previousBlock = markdownBlocks.value[blockIndex - 1];
  const currentBlock = markdownBlocks.value[blockIndex];

  // 将当前编辑中的源码与前一个块的源码合并
  const mergedSource = previousBlock.source + editingSource.value;

  // 构建新文档：用合并后的源码替换两个块的范围
  const nextValue =
    props.modelValue.slice(0, previousBlock.startOffset) +
    mergedSource +
    props.modelValue.slice(currentBlock.endOffset);

  // 记录合并点位置（上一个块的原末尾），用于后续光标定位
  const cursorOffset = previousBlock.source.length;

  // 取消当前编辑状态
  cancelBlockEdit();

  // 更新文档
  emitModelValue(nextValue);

  // 等待重新解析块结构
  await parseBlocks(nextValue);

  // 找到合并后的新块（startOffset 仍为 previousBlock.startOffset）
  const newBlock = markdownBlocks.value.find(
    (b) => b.startOffset === previousBlock.startOffset,
  );
  if (newBlock) {
    // 进入编辑模式，光标定位在合并点
    editingBlockId.value = newBlock.id;
    editingSource.value = newBlock.source;
    editingBaseValue.value = nextValue;
    editingRange.value = {
      startOffset: newBlock.startOffset,
      endOffset: newBlock.endOffset,
    };
    await nextTick();
    blockCodeRef.value?.setCursorPosition(cursorOffset);
  }
}

watch(activeDocumentMode, (mode, oldMode) => {
  const scrollSnapshot = captureScrollSnapshot();
  let committedValue: string | undefined;
  if (oldMode === "mixed" && mode !== "mixed" && editingBlockId.value) {
    committedValue = finalizeBlockEdit();
  }

  syncRenderedState(committedValue ?? props.modelValue, mode);

  restoreScrollAfterModeChange(scrollSnapshot);
});

onMounted(() => {
  Fancybox.bind(scrollRef.value ?? null, previewImageSelector, {
    groupAll: true,
    placeFocusBack: true,
    theme: "auto",
  });
});

onBeforeUnmount(() => {
  Fancybox.unbind(scrollRef.value ?? null, previewImageSelector);

  if (modeScrollRestoreFrame !== null) {
    cancelAnimationFrame(modeScrollRestoreFrame);
  }
});

// ── Paste image / file handling ─────────────────────────────────

function blobToBase64(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const result = reader.result as string;
      // Strip the "data:xxx;base64," prefix
      resolve(result.split(',')[1] || '');
    };
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(blob);
  });
}

function generateMediaFilename(ext: string): string {
  const now = new Date();
  const pad = (n: number) => String(n).padStart(2, '0');
  const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
  const suffix = Math.random().toString(36).slice(2, 6);
  return `${ts}_${suffix}.${ext}`;
}

function imageExtFromMime(mime: string): string {
  const map: Record<string, string> = {
    'image/png': 'png',
    'image/jpeg': 'jpg',
    'image/gif': 'gif',
    'image/webp': 'webp',
    'image/avif': 'avif',
    'image/bmp': 'bmp',
    'image/svg+xml': 'svg',
  };
  return map[mime] || 'png';
}

function videoExtFromMime(mime: string): string {
  const map: Record<string, string> = {
    'video/mp4': 'mp4',
    'video/webm': 'webm',
    'video/quicktime': 'mov',
    'video/ogg': 'ogv',
  };
  return map[mime] || 'mp4';
}

function clipboardMediaKindFromMime(mime: string): ClipboardMediaKind | null {
  if (mime.startsWith('image/')) return 'image';
  if (mime.startsWith('video/')) return 'video';
  return null;
}

function clipboardMediaKindFromExtension(ext: string): ClipboardMediaKind | null {
  if (IMAGE_EXTENSIONS.has(ext)) return 'image';
  if (VIDEO_EXTENSIONS.has(ext)) return 'video';
  return null;
}

function getClipboardMediaItems(items: DataTransferItemList) {
  const mediaItems: {
    file: File;
    kind: ClipboardMediaKind;
    ext: string;
  }[] = [];

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item.kind !== 'file') continue;

    const file = item.getAsFile();
    if (!file) continue;

    const mime = item.type || file.type;
    const ext = getFileExtension(file.name);
    const kind = clipboardMediaKindFromMime(mime) ?? clipboardMediaKindFromExtension(ext);
    if (!kind) continue;

    mediaItems.push({
      file,
      kind,
      ext: kind === 'image'
        ? (mime ? imageExtFromMime(mime) : ext || 'png')
        : (mime ? videoExtFromMime(mime) : ext || 'mp4'),
    });
  }

  return mediaItems;
}

function escapeMarkdownAlt(value: string) {
  return value.replace(/[\r\n]+/g, " ").replace(/[[\]]/g, "");
}

function formatMarkdownDestination(value: string) {
  const trimmed = value.trim();
  if (/[\s()<>]/.test(trimmed)) return `<${trimmed.replace(/>/g, "%3E")}>`;
  return trimmed;
}

function htmlImageToMarkdown(element: HTMLImageElement) {
  const src = element.getAttribute("src")?.trim() || "";
  if (!src || /^(data|blob|cid):/i.test(src)) return "";

  const alt = escapeMarkdownAlt(element.getAttribute("alt") || "");
  return `![${alt}](${formatMarkdownDestination(src)})`;
}

function htmlChildrenToMarkdown(node: Node): string {
  return Array.from(node.childNodes).map(htmlNodeToMarkdown).join("");
}

function blockMarkdown(value: string) {
  const trimmed = value.trim();
  return trimmed ? `\n\n${trimmed}\n\n` : "";
}

function htmlNodeToMarkdown(node: Node): string {
  if (node.nodeType === Node.TEXT_NODE) {
    return (node.textContent || "").replace(/\s+/g, " ");
  }

  if (node.nodeType !== Node.ELEMENT_NODE) return "";

  const element = node as HTMLElement;
  const tagName = element.tagName.toLowerCase();

  if (tagName === "img") {
    return htmlImageToMarkdown(element as HTMLImageElement);
  }

  if (tagName === "br") return "\n";

  const children = htmlChildrenToMarkdown(element);
  switch (tagName) {
    case "p":
    case "div":
    case "section":
    case "article":
    case "header":
    case "footer":
    case "figure":
      return blockMarkdown(children);
    case "li":
      return `- ${children.trim()}\n`;
    case "ul":
    case "ol":
      return blockMarkdown(children);
    case "strong":
    case "b":
      return children.trim() ? `**${children.trim()}**` : "";
    case "em":
    case "i":
      return children.trim() ? `*${children.trim()}*` : "";
    case "code":
      return children.trim() ? `\`${children.trim().replace(/`/g, "\\`")}\`` : "";
    case "a": {
      const href = element.getAttribute("href")?.trim();
      const label = children.trim() || href || "";
      return href ? `[${label}](${formatMarkdownDestination(href)})` : label;
    }
    default:
      return children;
  }
}

function normalizePastedMarkdown(value: string) {
  return value
    .replace(/[ \t]+\n/g, "\n")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

function clipboardHtmlToMarkdown(data: DataTransfer) {
  const html = data.getData("text/html");
  if (!html || !/<img\b/i.test(html)) return "";

  const doc = new DOMParser().parseFromString(html, "text/html");
  if (!doc.body.querySelector("img[src]")) return "";

  return normalizePastedMarkdown(htmlChildrenToMarkdown(doc.body));
}

/**
 * Core paste handler: detects images/files in clipboard, saves them to the
 * hidden resources folder, and inserts markdown image syntax at the cursor.
 */
async function processPaste(
  e: ClipboardEvent,
  value: string,
  selectionStart: number,
  selectionEnd: number,
  applyValue: (newValue: string, cursorPos: number) => void,
) {
  const clipboardData = e.clipboardData;
  if (!clipboardData) return;

  const htmlMarkdown = clipboardHtmlToMarkdown(clipboardData);
  const mediaItems = clipboardData.items ? getClipboardMediaItems(clipboardData.items) : [];
  const shouldInsertHtmlMarkdown = Boolean(htmlMarkdown);
  const shouldSaveMedia = mediaItems.length > 0 && !shouldInsertHtmlMarkdown;

  if (!shouldInsertHtmlMarkdown && !shouldSaveMedia) return; // No media — let default text paste happen

  const dir = getCurrentFileDirectory();
  if (shouldSaveMedia && (!props.filePath || !dir)) return;

  e.preventDefault();
  e.stopPropagation();

  if (shouldInsertHtmlMarkdown) {
    const newValue =
      value.slice(0, selectionStart) + htmlMarkdown + value.slice(selectionEnd);
    const cursorPos = selectionStart + htmlMarkdown.length;
    applyValue(newValue, cursorPos);
    return;
  }

  const filePath = props.filePath;
  if (!filePath || !dir) return;

  // Derive the hidden resources folder name from the current markdown file
  const mdFileName = filePath.split(/[/\\]/).pop() || 'document';
  const stem = mdFileName.replace(/\.md$/i, '');
  const resourcesDir = `${dir}/.${stem}`;
  const resourcesRelDir = `.${stem}`;

  // Process each media item and build a replacement string
  const insertParts: string[] = [];

  for (const { file, ext } of mediaItems) {
    const filename = generateMediaFilename(ext);
    const assetPath = `${resourcesDir}/${filename}`;
    const relativePath = `${resourcesRelDir}/${filename}`;

    try {
      const base64 = await blobToBase64(file);
      await invoke("save_media_file", { path: assetPath, data: base64 });
    } catch (err) {
      console.error("Failed to save pasted media:", err);
      continue;
    }

    const alt = file.name || 'image';
    // Separate multiple pasted images with a line break
    if (insertParts.length > 0) insertParts.push('\n');
    insertParts.push(`![${alt}](${relativePath})`);
  }

  if (insertParts.length === 0) return;

  const insertText = insertParts.join('');
  const newValue =
    value.slice(0, selectionStart) + insertText + value.slice(selectionEnd);
  const cursorPos = selectionStart + insertText.length;
  applyValue(newValue, cursorPos);
}

async function onEditorPaste(payload: {
  event: ClipboardEvent;
  value: string;
  from: number;
  to: number;
}) {
  await processPaste(
    payload.event,
    payload.value,
    payload.from,
    payload.to,
    (newValue, cursorPos) => {
      const insertedText = newValue.slice(payload.from, cursorPos);
      editorCodeRef.value?.replaceRange(insertedText, payload.from, payload.to);
    },
  );
}

async function onBlockPaste(payload: {
  event: ClipboardEvent;
  value: string;
  from: number;
  to: number;
}) {
  await processPaste(
    payload.event,
    payload.value,
    payload.from,
    payload.to,
    (newValue, cursorPos) => {
      const insertedText = newValue.slice(payload.from, cursorPos);
      blockCodeRef.value?.replaceRange(insertedText, payload.from, payload.to);
    },
  );
}

function scrollToLine(lineNumber: number) {
  if (!scrollRef.value) return;

  if (activeDocumentMode.value === "edit") {
    editorCodeRef.value?.scrollToLine(lineNumber);
  } else {
    const previewEl = scrollRef.value.querySelector(".markdown-preview") as HTMLElement;
    if (previewEl) {
      let targetHeading: HTMLElement | null = null;
      for (const h of previewEl.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
        const el = h as HTMLElement;
        const dataLine = el.dataset?.line;
        if (dataLine && parseInt(dataLine) <= lineNumber) {
          targetHeading = el;
        }
      }
      if (targetHeading) {
        targetHeading.scrollIntoView({ behavior: "smooth", block: "start" });
      } else {
        const lineHeight = 28;
        scrollRef.value.scrollTop = (lineNumber - 1) * lineHeight - 100;
      }
    }
  }
}

defineExpose({
  flushPendingChanges,
  scrollToLine,
});
</script>

<template>
  <div
    ref="scrollRef"
    class="flex-1 w-full h-full overflow-auto markdown-editor-scroll"
    @wheel.passive
    @keydown.capture="onRootKeydown"
  >
    <div
      class="mx-auto px-8 py-4 h-full"
      :style="{
        maxWidth: `calc(700px * var(--editor-zoom, 1))`,
        fontSize: `calc(1rem * var(--editor-zoom, 1))`,
      }"
    >
      <!-- Editor mode -->
      <MarkdownCodeEditor
        v-if="activeDocumentMode === 'edit'"
        ref="editorCodeRef"
        :model-value="props.modelValue"
        :placeholder="props.placeholder"
        min-height="60vh"
        class="markdown-source-editor w-full font-serif leading-relaxed"
        @update:model-value="applyFullEditorValue"
        @paste="onEditorPaste"
      />

      <!-- Preview mode -->
      <div v-else-if="activeDocumentMode === 'preview'" class="min-h-[60vh]">
        <div v-if="renderError" class="text-red-500 text-sm">
          ⚠ Markdown 解析错误
        </div>
        <div v-else class="markdown-preview" :style="previewParagraphStyle" v-html="renderedHtml" />
      </div>

      <!-- Mixed mode -->
      <div v-else class="min-h-[60vh] markdown-preview markdown-mixed" :style="previewParagraphStyle">
        <div v-if="renderError" class="text-red-500 text-sm">
          ⚠ Markdown 解析错误
        </div>
        <MarkdownCodeEditor
          v-else-if="isEditingEmptyDocument"
          ref="blockCodeRef"
          :model-value="editingSource"
          :placeholder="props.placeholder"
          autofocus
          min-height="12rem"
          class="markdown-block-editor"
          @update:model-value="editingSource = $event"
          @escape="cancelBlockEdit"
          @blur="finalizeBlockEdit"
          @paste="onBlockPaste"
        />
        <div
          v-else-if="!props.modelValue.trim()"
          class="markdown-empty-placeholder"
          role="button"
          tabindex="0"
          @click="startEmptyBlockEdit"
          @keydown.enter.prevent="startEmptyBlockEdit"
          @keydown.space.prevent="startEmptyBlockEdit"
        >
          {{ props.placeholder }}
        </div>
        <template v-else>
          <div
            v-for="block in markdownBlocks"
            :key="block.id"
            class="markdown-block"
            :class="{
              'markdown-block-editing': editingBlockId === block.id,
              'markdown-block-paragraph': block.type === 'paragraph',
            }"
          >
            <MarkdownCodeEditor
              v-if="editingBlockId === block.id"
              ref="blockCodeRef"
              :model-value="editingSource"
              autofocus
              class="markdown-block-editor"
              :class="{
                'markdown-block-editor-soft-wrap': block.type === 'paragraph',
              }"
              @update:model-value="editingSource = $event"
              @escape="cancelBlockEdit"
              @blur="finalizeBlockEdit"
              @paste="onBlockPaste"
              @backspace-at-start="mergeCurrentBlockWithPrevious"
            />
            <div
              v-else
              class="markdown-block-preview"
              role="button"
              tabindex="0"
              v-html="block.html"
              @click="startBlockEdit(block)"
              @keydown.enter.prevent="startBlockEdit(block)"
              @keydown.space.prevent="startBlockEdit(block)"
            />
          </div>
        </template>
      </div>
    </div>
  </div>
</template>

<style>
/* ── Scroll container ────────────────────────────────────────────── */
.markdown-editor-scroll {
  background: var(--app-bg);
}

/* ── Markdown Preview Styles (warm palette) ─────────────────────── */

.markdown-preview {
  font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  line-height: 1.625;
  color: var(--app-text);
  word-break: break-word;
}

.markdown-preview > *:first-child { margin-top: 0; }

.markdown-source-editor {
  display: block;
  color: var(--app-text);
}

.markdown-mixed > .markdown-block:first-child .markdown-block-preview > *:first-child {
  margin-top: 0;
}

.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {
  font-weight: 700;
  color: var(--app-text-strong);
  line-height: 1.3;
  margin-top: 1.5em;
  margin-bottom: 0.5em;
}
.markdown-preview h1 { font-size: 1.5em; }
.markdown-preview h2 { font-size: 1.25em; }
.markdown-preview h3 { font-size: 1.125em; }
.markdown-preview h4 { font-size: 1em; }

.markdown-preview p { margin: 0.8em 0; text-indent: var(--paragraph-indent, 0); }

.markdown-preview strong { font-weight: 700; color: var(--app-text-strong); }
.markdown-preview em { font-style: italic; }
.markdown-preview del { text-decoration: line-through; color: var(--app-muted); }

.markdown-preview code {
  background-color: var(--app-surface-muted);
  color: var(--app-accent);
  border-radius: 0.25rem;
  padding: 0.125rem 0.375rem;
  font-size: 0.875em;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  word-break: break-all;
}

.markdown-preview pre {
  background-color: var(--app-surface-muted);
  border-radius: 0.375rem;
  padding: 1rem;
  overflow-x: auto;
  margin: 0.75em 0;
}
.markdown-preview pre.shiki {
  position: relative;
  background-color: var(--code-background) !important;
  color: var(--code-foreground);
  border: 1px solid var(--code-border);
}
.markdown-preview pre.shiki[data-language]::before {
  content: attr(data-language);
  display: block;
  margin-bottom: 0.65rem;
  color: var(--app-muted);
  font-family: "Geist", "PingFang SC", "Microsoft YaHei", sans-serif;
  font-size: 0.7rem;
  font-weight: 600;
  line-height: 1;
  text-transform: lowercase;
}
.markdown-preview pre.shiki span {
  color: inherit;
}
.markdown-preview pre code {
  background: none;
  padding: 0;
  color: var(--code-foreground);
  font-size: 0.875em;
  border-radius: 0;
  word-break: normal;
}

.markdown-preview a { color: var(--app-accent); text-decoration: underline; }

.markdown-preview blockquote {
  border-left: 4px solid var(--app-accent);
  padding-left: 1rem;
  margin: 0.75em 0;
  color: var(--app-muted);
  font-style: italic;
}
.markdown-preview blockquote p { margin: 0.25em 0; }

.markdown-preview ul { list-style-type: disc; padding-left: 1.5em; margin: 0.5em 0; }
.markdown-preview ol { list-style-type: decimal; padding-left: 1.5em; margin: 0.5em 0; }
.markdown-preview li { margin: 0.25em 0; }

.markdown-preview li input[type="checkbox"] {
  margin-right: 0.375rem;
  accent-color: var(--app-accent);
}

.markdown-preview hr {
  border: none;
  border-top: 1px solid var(--app-border-strong);
  margin: 1.5em 0;
}

.markdown-preview table {
  border-collapse: collapse;
  width: 100%;
  margin: 0.75em 0;
}
.markdown-preview th,
.markdown-preview td {
  border: 1px solid var(--app-border-strong);
  padding: 0.5rem 0.75rem;
  text-align: left;
}
.markdown-preview th {
  background-color: var(--app-surface-muted);
  font-weight: 700;
  color: var(--app-text-strong);
}
.markdown-preview tr:nth-child(even) td { background-color: var(--app-panel-bg); }

.markdown-preview img {
  max-width: 100%;
  height: auto;
  border-radius: 0.25rem;
  margin: 0.5em 0;
}

.markdown-preview:not(.markdown-mixed) img {
  cursor: zoom-in;
}

/* ── Mixed mode block editing ────────────────────────────────────── */

.markdown-block {
  display: contents;
}

.markdown-block-preview {
  display: contents;
  outline: none;
}

.markdown-block:not(.markdown-block-editing) > .markdown-block-preview > * {
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}

.markdown-block-editing {
  display: block;
}

.markdown-mixed
  > .markdown-block-paragraph
  + .markdown-block-paragraph.markdown-block-editing {
  margin-top: 0;
}

.markdown-block-editing,
.markdown-block-editing:hover {
  background-color: transparent;
}

.markdown-block-preview:focus-visible > * {
  outline: 2px solid var(--app-accent-muted);
  outline-offset: 2px;
  border-radius: 0.25rem;
}

.markdown-empty-placeholder:focus-visible {
  outline: 2px solid var(--app-accent-muted);
  outline-offset: 2px;
  border-radius: 0.25rem;
}

.markdown-block-editor {
  display: block;
  width: 100%;
  min-height: 1.625em;
  resize: none;
  overflow: visible;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: var(--app-text);
  font: inherit;
  line-height: inherit;
  margin: 0;
  padding: 0;
  outline: none;
  box-shadow: none;
  caret-color: var(--app-accent);
}

.markdown-block-editor .cm-tooltip {
  font-size: calc(0.875rem * var(--editor-zoom, 1));
}

.markdown-block-editor-soft-wrap {
  white-space: normal;
}

.markdown-block-editor::placeholder,
.markdown-empty-placeholder {
  color: var(--app-subtle);
}

.markdown-empty-placeholder {
  min-height: 12rem;
  padding-top: 0.25rem;
  cursor: text;
  outline: none;
}
</style>
