<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
  acceptCompletion,
  autocompletion,
  closeBrackets,
  closeBracketsKeymap,
  completionKeymap,
  pickedCompletion,
  startCompletion,
  type CompletionContext,
  type CompletionResult,
} from "@codemirror/autocomplete";
import {
  defaultKeymap,
  history,
  historyKeymap,
  indentWithTab,
} from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import {
  bracketMatching,
  defaultHighlightStyle,
  foldGutter,
  HighlightStyle,
  indentOnInput,
  syntaxHighlighting,
} from "@codemirror/language";
import { highlightSelectionMatches, searchKeymap } from "@codemirror/search";
import {
  EditorSelection,
  EditorState,
  StateEffect,
  StateField,
  type Text,
} from "@codemirror/state";
import {
  crosshairCursor,
  Decoration,
  type DecorationSet,
  drawSelection,
  dropCursor,
  EditorView,
  highlightActiveLine,
  highlightActiveLineGutter,
  highlightSpecialChars,
  keymap,
  placeholder as placeholderExtension,
  rectangularSelection,
} from "@codemirror/view";
import { tags } from "@lezer/highlight";
import { highlightCodeToTokens } from "@/lib/codeHighlight";
import { getMarkdownLanguageCompletions } from "@/lib/markdownLanguages";
import { resolveMarkdownCodeLanguage } from "@/lib/markdownLanguages";

interface PastePayload {
  event: ClipboardEvent;
  value: string;
  from: number;
  to: number;
}

const props = withDefaults(
  defineProps<{
    modelValue: string;
    placeholder?: string;
    autofocus?: boolean;
    minHeight?: string;
  }>(),
  {
    placeholder: "",
    autofocus: false,
    minHeight: "1.625em",
  },
);

const emit = defineEmits<{
  "update:modelValue": [value: string];
  blur: [];
  escape: [];
  paste: [payload: PastePayload];
  "backspace-at-start": [];
}>();

const hostRef = ref<HTMLDivElement>();
let editorView: EditorView | null = null;
let highlightRequestId = 0;

const setCodeDecorations = StateEffect.define<DecorationSet>();
const codeDecorationField = StateField.define<DecorationSet>({
  create: () => Decoration.none,
  update(value, transaction) {
    let next = value.map(transaction.changes);
    for (const effect of transaction.effects) {
      if (effect.is(setCodeDecorations)) next = effect.value;
    }
    return next;
  },
  provide: (field) => EditorView.decorations.from(field),
});

interface FencedCodeBlock {
  from: number;
  to: number;
  code: string;
  language: string;
}

function findFencedCodeBlocks(doc: Text): FencedCodeBlock[] {
  const blocks: FencedCodeBlock[] = [];

  for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
    const line = doc.line(lineNumber);
    const opener = /^(\s*)(`{3,}|~{3,})\s*([\w.+#-]*)/.exec(line.text);
    if (!opener) continue;

    const fenceChar = opener[2][0];
    const fenceLength = opener[2].length;
    const closingPattern = new RegExp(`^\\s*${fenceChar === "`" ? "`" : "~"}{${fenceLength},}\\s*$`);
    const contentFrom = Math.min(doc.length, line.to + 1);

    for (let closingLineNumber = lineNumber + 1; closingLineNumber <= doc.lines; closingLineNumber += 1) {
      const closingLine = doc.line(closingLineNumber);
      if (!closingPattern.test(closingLine.text)) continue;

      const requestedLanguage = opener[3] || "text";
      blocks.push({
        from: contentFrom,
        to: closingLine.from,
        code: doc.sliceString(contentFrom, closingLine.from),
        language: resolveMarkdownCodeLanguage(requestedLanguage)?.shiki || "text",
      });
      lineNumber = closingLineNumber;
      break;
    }
  }

  return blocks;
}

async function updateCodeDecorations(view: EditorView) {
  const requestId = ++highlightRequestId;
  const documentText = view.state.doc.toString();
  const blocks = findFencedCodeBlocks(view.state.doc);
  const ranges: ReturnType<Decoration["range"]>[] = [];

  for (const block of blocks) {
    const tokenLines = await highlightCodeToTokens(block.code, block.language);
    if (!tokenLines || requestId !== highlightRequestId) return;

    for (const tokens of tokenLines) {
      for (const token of tokens) {
        if (!token.content || !token.color) continue;
        const from = block.from + token.offset;
        const to = Math.min(block.to, from + token.content.length);
        if (to <= from) continue;
        ranges.push(
          Decoration.mark({
            class: "cm-shiki-token",
            attributes: { style: `color: ${token.color}` },
          }).range(from, to),
        );
      }
    }
  }

  if (
    requestId !== highlightRequestId ||
    !editorView ||
    editorView !== view ||
    view.state.doc.toString() !== documentText
  ) {
    return;
  }

  view.dispatch({ effects: setCodeDecorations.of(Decoration.set(ranges, true)) });
}

function fenceLanguageCompletion(context: CompletionContext): CompletionResult | null {
  const line = context.state.doc.lineAt(context.pos);
  const beforeCursor = context.state.sliceDoc(line.from, context.pos);
  const match = /^(\s*)(`{3,}|~{3,})([\w.+#-]*)$/.exec(beforeCursor);
  if (!match) return null;

  return {
    from: line.from + match[1].length + match[2].length,
    options: getMarkdownLanguageCompletions(),
    validFor: /^[\w.+#-]*$/,
  };
}

function insertClosedCodeFence(view: EditorView) {
  const selection = view.state.selection.main;
  if (!selection.empty) return false;

  const line = view.state.doc.lineAt(selection.head);
  const beforeCursor = view.state.sliceDoc(line.from, selection.head);
  const afterCursor = view.state.sliceDoc(selection.head, line.to);
  const match = /^(\s*)(`{3,}|~{3,})[\w.+#-]*\s*$/.exec(beforeCursor);
  if (!match || afterCursor.trim()) return false;

  const nextLine = line.number < view.state.doc.lines
    ? view.state.doc.line(line.number + 1).text
    : "";
  const fence = match[2][0].repeat(match[2].length);
  if (nextLine.trimStart().startsWith(fence)) return false;

  const insert = `\n${match[1]}\n${match[1]}${fence}`;
  view.dispatch({
    changes: { from: selection.head, insert },
    selection: { anchor: selection.head + 1 + match[1].length },
    scrollIntoView: true,
    userEvent: "input",
  });
  return true;
}

function acceptCompletionOrCloseFence(view: EditorView) {
  return acceptCompletion(view) || insertClosedCodeFence(view);
}

const editorHighlightStyle = HighlightStyle.define([
  { tag: tags.heading, color: "var(--app-text-strong)", fontWeight: "700" },
  { tag: tags.emphasis, fontStyle: "italic" },
  { tag: tags.strong, fontWeight: "700" },
  { tag: tags.link, color: "var(--code-token-link)", textDecoration: "underline" },
  { tag: tags.url, color: "var(--code-token-link)" },
  {
    tag: tags.monospace,
    color: "var(--code-token-string)",
    fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
  },
  { tag: [tags.keyword, tags.operatorKeyword], color: "var(--code-token-keyword)" },
  { tag: [tags.string, tags.regexp], color: "var(--code-token-string-expression)" },
  { tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--code-token-constant)" },
  { tag: [tags.comment, tags.meta], color: "var(--code-token-comment)" },
  {
    tag: [tags.function(tags.variableName), tags.labelName],
    color: "var(--code-token-function)",
  },
  {
    tag: [tags.typeName, tags.className, tags.namespace, tags.tagName],
    color: "var(--code-token-function)",
  },
  { tag: tags.propertyName, color: "var(--code-token-constant)" },
  { tag: tags.attributeName, color: "var(--code-token-parameter)" },
  { tag: [tags.punctuation, tags.separator, tags.bracket], color: "var(--code-token-punctuation)" },
  { tag: tags.inserted, color: "var(--code-token-inserted)" },
  { tag: tags.deleted, color: "var(--code-token-deleted)" },
  { tag: tags.changed, color: "var(--code-token-changed)" },
  { tag: tags.invalid, color: "var(--code-token-deleted)" },
]);

const editorTheme = EditorView.theme({
  "&": {
    color: "var(--code-foreground)",
    backgroundColor: "transparent",
    fontFamily: "inherit",
    fontSize: "inherit",
    minHeight: "var(--markdown-code-editor-min-height)",
  },
  "&.cm-focused": { outline: "none" },
  ".cm-scroller": {
    fontFamily: "inherit",
    lineHeight: "1.625",
    overflow: "visible",
  },
  ".cm-content": {
    padding: "0",
    minHeight: "var(--markdown-code-editor-min-height)",
    caretColor: "var(--app-accent)",
  },
  ".cm-line": { padding: "0" },
  ".cm-shiki-token > span": { color: "inherit !important" },
  ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--app-accent)" },
  ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": {
    backgroundColor: "var(--app-selection) !important",
  },
  ".cm-placeholder": { color: "var(--app-subtle)" },
  ".cm-activeLine": { backgroundColor: "transparent" },
  ".cm-gutters": { display: "none" },
  ".cm-tooltip": {
    border: "1px solid var(--app-border-strong)",
    borderRadius: "6px",
    backgroundColor: "var(--app-surface)",
    color: "var(--app-text)",
    boxShadow: "0 10px 28px var(--app-shadow)",
    overflow: "hidden",
  },
  ".cm-tooltip-autocomplete > ul": {
    fontFamily: '"Geist", "PingFang SC", "Microsoft YaHei", sans-serif',
    maxHeight: "240px",
  },
  ".cm-tooltip-autocomplete > ul > li": {
    minWidth: "220px",
    padding: "6px 10px",
  },
  ".cm-tooltip-autocomplete > ul > li[aria-selected]": {
    backgroundColor: "var(--app-active-bg)",
    color: "var(--app-text-strong)",
  },
  ".cm-completionLabel": { fontWeight: "600" },
  ".cm-completionDetail": { color: "var(--app-muted)", fontStyle: "normal" },
});

function createEditor() {
  const host = hostRef.value;
  if (!host) return;

  editorView = new EditorView({
    parent: host,
    state: EditorState.create({
      doc: props.modelValue,
      extensions: [
        highlightSpecialChars(),
        history(),
        foldGutter(),
        drawSelection(),
        dropCursor(),
        EditorState.allowMultipleSelections.of(true),
        indentOnInput(),
        syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
        bracketMatching(),
        rectangularSelection(),
        crosshairCursor(),
        highlightActiveLine(),
        highlightActiveLineGutter(),
        highlightSelectionMatches(),
        markdown({ codeLanguages: languages }),
        syntaxHighlighting(editorHighlightStyle),
        codeDecorationField,
        closeBrackets(),
        autocompletion({ override: [fenceLanguageCompletion], activateOnTyping: true }),
        EditorView.lineWrapping,
        editorTheme,
        placeholderExtension(props.placeholder),
        keymap.of([
          { key: "Enter", run: acceptCompletionOrCloseFence },
          ...completionKeymap,
          {
            key: "Escape",
            run() {
              emit("escape");
              return true;
            },
          },
          indentWithTab,
          ...closeBracketsKeymap,
          {
            key: "Backspace",
            run(view) {
              const selection = view.state.selection.main;
              if (selection.empty && selection.head === 0) {
                emit("backspace-at-start");
                return true;
              }
              return false;
            },
          },
          ...defaultKeymap,
          ...searchKeymap,
          ...historyKeymap,
        ]),
        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            emit("update:modelValue", update.state.doc.toString());
            void updateCodeDecorations(update.view);
          }
          if (
            !update.docChanged ||
            update.transactions.some((transaction) => transaction.annotation(pickedCompletion))
          ) {
            return;
          }

          const selection = update.state.selection.main;
          if (!selection.empty) return;
          const line = update.state.doc.lineAt(selection.head);
          const beforeCursor = update.state.sliceDoc(line.from, selection.head);
          if (/^\s*(`{3,}|~{3,})[\w.+#-]*$/.test(beforeCursor)) {
            queueMicrotask(() => {
              if (editorView) startCompletion(editorView);
            });
          }
        }),
        EditorView.domEventHandlers({
          blur() {
            emit("blur");
            return false;
          },
          paste(event, view) {
            const selection = view.state.selection.main;
            emit("paste", {
              event,
              value: view.state.doc.toString(),
              from: selection.from,
              to: selection.to,
            });
            return event.defaultPrevented;
          },
        }),
      ],
    }),
  });

  if (props.autofocus) {
    requestAnimationFrame(() => focusAtEnd());
  }
  void updateCodeDecorations(editorView);
}

watch(
  () => props.modelValue,
  (value) => {
    const view = editorView;
    if (!view || value === view.state.doc.toString()) return;

    const cursor = Math.min(view.state.selection.main.head, value.length);
    view.dispatch({
      changes: { from: 0, to: view.state.doc.length, insert: value },
      selection: EditorSelection.cursor(cursor),
    });
  },
);

function focus() {
  editorView?.focus();
}

function focusAtEnd() {
  const view = editorView;
  if (!view) return;
  const end = view.state.doc.length;
  view.dispatch({ selection: EditorSelection.cursor(end), scrollIntoView: true });
  view.focus();
}

function replaceRange(text: string, from?: number, to?: number) {
  const view = editorView;
  if (!view) return;
  const selection = view.state.selection.main;
  const replaceFrom = from ?? selection.from;
  const replaceTo = to ?? selection.to;
  const cursor = replaceFrom + text.length;
  view.dispatch({
    changes: { from: replaceFrom, to: replaceTo, insert: text },
    selection: EditorSelection.cursor(cursor),
    scrollIntoView: true,
    userEvent: "input.paste",
  });
  view.focus();
}

function scrollToLine(lineNumber: number) {
  const view = editorView;
  if (!view) return;
  const safeLine = Math.max(1, Math.min(lineNumber, view.state.doc.lines));
  const line = view.state.doc.line(safeLine);
  view.dispatch({ selection: EditorSelection.cursor(line.from), scrollIntoView: true });
  view.focus();
}

function setCursorPosition(pos: number) {
  const view = editorView;
  if (!view) return;
  const safePos = Math.max(0, Math.min(pos, view.state.doc.length));
  view.dispatch({ selection: EditorSelection.cursor(safePos), scrollIntoView: true });
  view.focus();
}

defineExpose({ focus, focusAtEnd, replaceRange, scrollToLine, setCursorPosition });

onMounted(createEditor);

onBeforeUnmount(() => {
  highlightRequestId += 1;
  editorView?.destroy();
  editorView = null;
});
</script>

<template>
  <div
    ref="hostRef"
    class="markdown-code-editor"
    :style="{ '--markdown-code-editor-min-height': props.minHeight }"
  />
</template>

<style scoped>
.markdown-code-editor {
  width: 100%;
  min-width: 0;
}
</style>
