'use client';

import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Mention from '@tiptap/extension-mention';
import { Markdown } from '@tiptap/markdown';
import type { AnyExtension } from '@tiptap/core';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import { useHotkey } from '@djangocfg/ui-core/hooks';
import {
  Bold, Italic, Strikethrough, Heading1, Heading2, Heading3,
  List, ListOrdered, Quote, Minus, Code, type LucideIcon,
} from 'lucide-react';
import { createMentionSuggestion } from './createMentionSuggestion';
import { mentionPresets } from './mentionPresets';
import { SlashCommandNode } from './slash/SlashCommandNode';
import { syncLeadingSlashNode } from './slash/syncSlashNode';
import type { SlashCommandInfo } from './slash/types';
import { SubmitOnEnter } from './submitOnEnter';
import type { MentionAttrs, MentionConfig } from './types';
import './styles.css';

// ── Helpers ──

interface MarkdownManager {
  serialize: (json: Record<string, unknown>) => string;
}

/**
 * Serialize the editor document to a markdown string.
 *
 * `@tiptap/markdown` v3 augments the `Editor` with a `getMarkdown()`
 * method; we use it when present and fall back to the storage manager
 * (and ultimately `getText()`) so the editor still produces *something*
 * if the extension shape ever drifts.
 */
function getMarkdown(editor: Editor): string {
  const withMd = editor as Editor & { getMarkdown?: () => string };
  if (typeof withMd.getMarkdown === 'function') return withMd.getMarkdown();
  const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined;
  if (!storage?.manager) return editor.getText();
  return storage.manager.serialize(editor.getJSON());
}

function extractMentionIds(editor: Editor): string[] {
  const ids: string[] = [];
  editor.state.doc.descendants((node) => {
    if (node.type.name === 'mention' && node.attrs.id) {
      ids.push(node.attrs.id as string);
    }
  });
  return [...new Set(ids)];
}

// ── Types ──

export interface MarkdownEditorProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  minHeight?: number;
  className?: string;
  disabled?: boolean;
  showToolbar?: boolean;
  /**
   * Drop the editor's own border / background / focus ring. Use when
   * the editor is embedded inside a host surface that already draws
   * the frame (e.g. the chat composer's textarea slot) — avoids a
   * double border.
   */
  unstyled?: boolean;
  /**
   * `@`-mention autocomplete config.
   *
   * IMPORTANT: Tiptap's `useEditor` initialises the editor exactly once.
   * The `Mention` extension is only registered when `mentions` is truthy
   * on the FIRST render — handing in a real config later (e.g. after an
   * async items fetch) silently does nothing, and typing `@` will not
   * open the popover.
   *
   * If you want mentions even with async-loaded items, pass
   * `{ items: [] }` from the very first render and update the array
   * when data arrives. Either keep the `MentionConfig` object identity
   * stable across renders and mutate `items` in place (the suggestion
   * plugin captures the config by closure and reads `items` on each
   * query), or accept that swapping the whole object reference is a
   * no-op for the live editor.
   */
  mentions?: MentionConfig;
  /**
   * Slash-command verb list. When set, the editor registers a
   * `SlashCommandNode` extension and replaces a leading `/verb` (whose
   * verb is in this list) in the document with an atomic chip — the
   * TipTap analogue of the plain `<SlashHighlightTextarea>` mirror.
   *
   * Like `mentions`, this is captured by `useEditor` exactly once on
   * first render. To register slash commands at all, pass `[]` from
   * the very first render and mutate / swap as needed. The conversion
   * effect re-reads the list on every value sync (it lives in a ref),
   * so verbs added later are recognised next time the buffer is set.
   *
   * Lives in `@djangocfg/ui-tools/markdown-editor` (not chat) so the
   * editor stays a leaf dependency. Structurally compatible with the
   * chat package's `SlashCommand[]` — pass either directly.
   */
  slashCommands?: readonly SlashCommandInfo[];
  /** Called when mentioned IDs change */
  onMentionIdsChange?: (ids: string[]) => void;
  /**
   * Called when the user presses Enter (without Shift, no IME
   * composition, no mention popover open). When set, Enter submits
   * and Shift+Enter inserts a newline — ChatGPT / Telegram chat
   * behaviour. When omitted, Enter behaves as Tiptap default
   * (HardBreak).
   *
   * Implementation lives in `submitOnEnter.ts` — it's a Tiptap
   * keymap extension, NOT a React wrapper handler. Wrapper-level
   * onKeyDown fires AFTER ProseMirror's keymap commits HardBreak in
   * the same tick; routing through the extension lets us intercept
   * before HardBreak runs.
   *
   * Return value (optional): truthy / undefined = consume the key
   * (default). Return `false` from onSubmit to let Tiptap fall
   * through to HardBreak — useful for guards like "don't submit an
   * empty draft".
   */
  onSubmit?: () => boolean | void;
  /**
   * Focus the editor on mount. Pair with `key={file}` upstream when the
   *  host wants a fresh focus per file change (inspector / editor tab).
   */
  autoFocus?: boolean;
  /**
   * Called when the user presses Cmd/Ctrl+S inside the editor. Receives
   * the current markdown. The browser's "save page" default is suppressed
   *  only when this handler is supplied — otherwise Cmd+S falls through.
   */
  onSave?: (markdown: string) => void;
}

/**
 * Imperative handle exposed via `ref`. Matches `ComposerHandle` from
 * `@djangocfg/ui-tools/chat` so consumers can forward it straight into
 * `useRegisterComposer({ focus, moveCursorToEnd })` — that's what makes
 * voice dictation (`VoiceComposerSlot`) push live text into a TipTap
 * composer.
 */
export interface MarkdownEditorHandle {
  /** Move keyboard focus into the editor. */
  focus: () => void;
  /** Place the caret at the end of the document (and focus). */
  moveCursorToEnd: () => void;
  /** Escape hatch — the underlying TipTap `Editor` instance. */
  getEditor: () => Editor | null;
}

// ── Component ──

export const MarkdownEditor = forwardRef<MarkdownEditorHandle, MarkdownEditorProps>(function MarkdownEditor(
  {
    value,
    onChange,
    placeholder = 'Write markdown...',
    minHeight = 120,
    className = '',
    disabled = false,
    showToolbar = true,
    unstyled = false,
    mentions,
    slashCommands,
    onMentionIdsChange,
    onSubmit,
    autoFocus = false,
    onSave,
  },
  ref,
) {
  // Keep the latest onSubmit in a ref so the Tiptap extension's
  // keymap closure always calls the freshest handler — Tiptap's
  // useEditor initialises extensions ONCE on first render. Without
  // the ref the extension would call a stale onSubmit (e.g. one
  // that references an outdated `value`).
  const onSubmitRef = useRef(onSubmit);
  onSubmitRef.current = onSubmit;
  const isExternalUpdate = useRef(false);

  // Slash list lives in a ref so verbs added at runtime (e.g. context-
  // aware commands registered after a first reply) are still recognised
  // by the post-`setContent` conversion pass. The editor extension itself
  // is registered once on mount — that is intentional and matches how
  // `mentions` works (Tiptap's `useEditor` captures extensions once).
  const slashCommandsRef = useRef<readonly SlashCommandInfo[]>(
    slashCommands ?? [],
  );
  slashCommandsRef.current = slashCommands ?? [];
  const slashEnabledOnMountRef = useRef<boolean>(slashCommands !== undefined);
  const slashWarnedRef = useRef(false);
  if (
    process.env.NODE_ENV !== 'production' &&
    !slashEnabledOnMountRef.current &&
    slashCommands !== undefined &&
    !slashWarnedRef.current
  ) {
    slashWarnedRef.current = true;
    // eslint-disable-next-line no-console
    console.warn(
      '[MarkdownEditor] `slashCommands` flipped from undefined to a list ' +
        'after mount. Tiptap only installs the SlashCommandNode extension ' +
        'on first render — the in-editor chip will NOT appear for this ' +
        'editor instance. Pass `[]` from the very first render and mutate ' +
        'the array in place (or swap references) so the conversion effect ' +
        'sees the new verbs.',
    );
  }

  // ── Dev-mode trap detector ──
  // Tiptap initialises the editor once with the extensions array from
  // first render. If `mentions` is undefined on mount and becomes
  // truthy later, the Mention extension is never installed and the
  // user-visible @-trigger silently does nothing. Catch this early so
  // future consumers don't spend hours debugging why @ does nothing.
  const initialMentionsDefinedRef = useRef<boolean>(mentions !== undefined);
  const warnedRef = useRef(false);
  if (
    process.env.NODE_ENV !== 'production' &&
    !initialMentionsDefinedRef.current &&
    mentions !== undefined &&
    !warnedRef.current
  ) {
    warnedRef.current = true;
    // eslint-disable-next-line no-console
    console.warn(
      '[MarkdownEditor] `mentions` flipped from undefined to a config ' +
        'after mount. Tiptap only installs the Mention extension on first ' +
        'render — the @-popover will NOT work for this editor instance. ' +
        'Pass `{ items: [] }` from the very first render and mutate `.items` ' +
        'in place instead.',
    );
  }

  const extensions = useMemo(() => {
    const exts: AnyExtension[] = [
      StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
      Placeholder.configure({ placeholder }),
      Markdown,
      // SubmitOnEnter — when the consumer wired an onSubmit, intercept
      // Enter at the keymap level (before StarterKit's HardBreak).
      // The extension calls through `onSubmitRef.current` so handler
      // identity changes don't require an editor rebuild. See
      // submitOnEnter.ts for the keymap-vs-wrapper-handler rationale.
      SubmitOnEnter.configure({
        onSubmit: () => {
          const h = onSubmitRef.current;
          if (!h) return false; // no handler → let Tiptap insert HardBreak
          return h();
        },
      }),
    ];

    // SlashCommandNode — TipTap atom that paints the `/verb` chip.
    // Registered whenever `slashCommands` was defined on first render
    // (the empty array still counts as "I want this feature"). The
    // node has no suggestion plugin of its own — the chat composer's
    // floating menu drives selection and the editor only needs to
    // know how to render + serialize the atom.
    if (slashEnabledOnMountRef.current) {
      exts.push(SlashCommandNode);
    }

    if (mentions) {
      // ── Why .extend() with renderMarkdown ──
      //
      // Tiptap's `Mention` extension ships a default markdown serializer
      // (via `createInlineMarkdownSpec`, see @tiptap/extension-mention)
      // that emits a shortcode like `[@ id="..." label="..."]`. That's
      // round-trippable but useless for most consumers: a chat composer
      // feeding an LLM wants `@<label>`, a deep-linking app wants a
      // markdown link, etc.
      //
      // `renderText` only affects `editor.getText()` and the rendered
      // node — it does NOT influence `@tiptap/markdown`'s serializer.
      // The serializer reads `renderMarkdown` off the extension config
      // (see MarkdownManager.registerExtension → getExtensionField).
      // Overriding it via `.extend({ renderMarkdown })` replaces the
      // shortcode output with whatever the consumer supplied (or the
      // `plainAt` default — see ./mentionPresets.ts).
      //
      // Renderer-choice capture: useEditor only initialises once, so the
      // chosen renderer is captured by closure on first render. This is
      // intentional — swapping renderers per render would mean tearing
      // the editor down anyway, which we don't do for any other prop.
      //
      // Round-trip note: we intentionally do NOT also override
      // `parseMarkdown` / `markdownTokenizer`. Once a mention is
      // serialized, parsing it back would need the original mention
      // items list to look up the id, which we don't have at parse time.
      // Mentions are write-only by design here — `setContent(value)`
      // after submit gets back a plain string, which is fine for the
      // chat use case.
      const renderMarkdown = mentions.renderMarkdown ?? mentionPresets.plainAt;
      exts.push(
        Mention.extend({
          renderMarkdown(node) {
            const raw = node.attrs as { label?: string | null; id?: string | null };
            const attrs: MentionAttrs = {
              id: raw?.id ?? '',
              label: raw?.label ?? '',
            };
            // Defensive: if both are empty (shouldn't happen for a real
            // mention node, but `setContent` of malformed data could),
            // emit nothing rather than a stray "@" or invalid link.
            if (!attrs.id && !attrs.label) return '';
            return renderMarkdown(attrs);
          },
        }).configure({
          HTMLAttributes: { class: 'markdown-mention' },
          suggestion: createMentionSuggestion(mentions),
          renderText: ({ node }) => `@${node.attrs.label}`,
        }),
      );
    }

    return exts;
  }, [placeholder, mentions]);

  const editor = useEditor({
    immediatelyRender: false,
    editable: !disabled,
    extensions,
    content: value,
    // `value` is a markdown string — without this Tiptap treats the
    // content as JSON and renders `# Hello` literally instead of an <h1>.
    contentType: 'markdown',
    onUpdate: ({ editor }) => {
      if (isExternalUpdate.current) return;
      // Promote a freshly-typed `/verb ` text run into a chip so users
      // who type the command without going through the menu still see
      // the highlight — matches the plain mirror's behaviour where
      // `extractSlashToken` works for any leading slash. The helper is
      // idempotent and a no-op when no leading text-slash exists.
      if (slashEnabledOnMountRef.current) {
        const replaced = syncLeadingSlashNode(editor, slashCommandsRef.current);
        if (replaced) {
          // The chain() above triggers an extra onUpdate. The string
          // serialisation is unchanged (atom flattens to its token via
          // `renderText`), so we still want the outer onChange to fire
          // with the canonical value. Falling through is fine.
        }
      }
      onChange(getMarkdown(editor));

      if (onMentionIdsChange) {
        onMentionIdsChange(extractMentionIds(editor));
      }
    },
    editorProps: {
      attributes: {
        class: 'markdown-editor-content focus:outline-none text-sm',
        style: `min-height: ${minHeight}px`,
      },
    },
  });

  // Sync external `value` → editor. `setContent` is given `contentType:
  // 'markdown'` so the string is parsed (not treated as JSON), and
  // `emitUpdate: false` so it does NOT re-fire `onUpdate` (which would
  // loop back into `onChange`). `isExternalUpdate` stays as a guard.
  useEffect(() => {
    if (!editor) return;
    const current = getMarkdown(editor);
    if (current !== value) {
      isExternalUpdate.current = true;
      editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
      // After the new content is written, convert a leading `/verb` text
      // run into a `SlashCommandNode` atom so the chip appears. The
      // helper is a no-op when (a) slash commands are not enabled,
      // (b) the buffer doesn't start with a known verb, or (c) the
      // document already begins with the atom. `emitUpdate: false` on
      // setContent + `isExternalUpdate.current` still being `true`
      // suppresses the onUpdate -> onChange feedback loop the chain()
      // call would otherwise trigger.
      if (slashEnabledOnMountRef.current) {
        syncLeadingSlashNode(editor, slashCommandsRef.current);
      }
      isExternalUpdate.current = false;
    }
  }, [value, editor]);

  // Keep editability in sync with the `disabled` prop. `useEditor` only
  // reads `editable` on init, so toggling `disabled` later is otherwise
  // a no-op.
  useEffect(() => {
    editor?.setEditable(!disabled);
  }, [editor, disabled]);

  // Declarative autoFocus — runs once the editor instance exists. Hosts
  // that want per-file focus reset should pair this with `key={path}`.
  useEffect(() => {
    if (!autoFocus || !editor) return;
    editor.commands.focus('end');
  }, [autoFocus, editor]);

  // Cmd/Ctrl+S → save. Uses the shared `useHotkey` (inInput=true by
  // default for modifier combos). Guarded to fire only when focus is
  // inside this editor's ProseMirror DOM, so multiple sibling editors
  // don't all fire on one chord.
  const onSaveRef = useRef(onSave);
  onSaveRef.current = onSave;
  useHotkey(
    'mod+s',
    () => {
      const h = onSaveRef.current;
      if (!h || !editor) return;
      const dom = editor.view.dom;
      const active = document.activeElement;
      if (!active || !dom.contains(active)) return;
      h(getMarkdown(editor));
    },
    { enabled: !!editor && !!onSave },
  );

  // Imperative API for hosts that drive the editor without owning a
  // TipTap ref directly — chat composer registration, voice slot,
  // focus-on-stream-end.
  useImperativeHandle(
    ref,
    (): MarkdownEditorHandle => ({
      focus: () => {
        editor?.commands.focus();
      },
      moveCursorToEnd: () => {
        editor?.commands.focus('end');
      },
      getEditor: () => editor ?? null,
    }),
    [editor],
  );

  // `unstyled` drops the editor's own frame (border / bg / focus ring)
  // so it can sit inside a host surface that already draws one.
  const chromeClass = unstyled
    ? 'markdown-editor--unstyled'
    : 'rounded-md border border-input bg-background';
  const wrapperClass = `markdown-editor ${chromeClass} ${disabled ? 'opacity-60' : ''} ${className}`.trim();

  return (
    <div className={wrapperClass}>
      {showToolbar && editor && <MarkdownToolbar editor={editor} />}
      <div className="px-3 py-2">
        <EditorContent editor={editor} />
      </div>
    </div>
  );
});

// ── Toolbar ──

interface ToolbarItem {
  icon: LucideIcon;
  action: () => void;
  active: boolean;
  title: string;
}

function MarkdownToolbar({ editor }: { editor: Editor }) {
  const items = useMemo<(ToolbarItem | null)[]>(() => [
    { icon: Bold, title: 'Bold', action: () => editor.chain().focus().toggleBold().run(), active: editor.isActive('bold') },
    { icon: Italic, title: 'Italic', action: () => editor.chain().focus().toggleItalic().run(), active: editor.isActive('italic') },
    { icon: Strikethrough, title: 'Strike', action: () => editor.chain().focus().toggleStrike().run(), active: editor.isActive('strike') },
    { icon: Code, title: 'Code', action: () => editor.chain().focus().toggleCode().run(), active: editor.isActive('code') },
    null,
    { icon: Heading1, title: 'H1', action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), active: editor.isActive('heading', { level: 1 }) },
    { icon: Heading2, title: 'H2', action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), active: editor.isActive('heading', { level: 2 }) },
    { icon: Heading3, title: 'H3', action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), active: editor.isActive('heading', { level: 3 }) },
    null,
    { icon: List, title: 'Bullet list', action: () => editor.chain().focus().toggleBulletList().run(), active: editor.isActive('bulletList') },
    { icon: ListOrdered, title: 'Ordered list', action: () => editor.chain().focus().toggleOrderedList().run(), active: editor.isActive('orderedList') },
    { icon: Quote, title: 'Quote', action: () => editor.chain().focus().toggleBlockquote().run(), active: editor.isActive('blockquote') },
    { icon: Minus, title: 'Divider', action: () => editor.chain().focus().setHorizontalRule().run(), active: false },
  ], [editor]);

  return (
    <div
      className="flex items-center gap-0.5 px-2 py-1.5 border-b border-border"
      role="toolbar"
      aria-label="Text formatting"
    >
      {items.map((item, i) => {
        if (!item) {
          return <div key={i} className="w-px h-4 bg-border mx-1" aria-hidden="true" />;
        }
        const Icon = item.icon;
        const btnClass = `markdown-toolbar-btn ${item.active ? 'active' : ''}`;
        return (
          <button
            key={i}
            type="button"
            // Prevent the button from stealing focus from the editor on
            // mousedown — keeps the selection intact so the formatting
            // command applies to the user's current selection.
            onMouseDown={(e) => e.preventDefault()}
            onClick={item.action}
            title={item.title}
            aria-label={item.title}
            aria-pressed={item.active}
            className={btnClass}
          >
            <Icon style={{ width: 14, height: 14 }} aria-hidden="true" />
          </button>
        );
      })}
    </div>
  );
}
