import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { EditorView as EditorViewType } from '@codemirror/view';

export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';

export interface CodeEditorProps {
  value: string;
  onChange: (value: string) => void;
  /** 'auto' behaves like 'html' -- the HTML language mode already highlights
   *  embedded <script>/<style> blocks, which covers the common "auto" case
   *  of mixed markup. */
  language?: CodeEditorLanguage;
  height?: number | string;
  placeholder?: string;
}

export interface CodeEditorHandle {
  /** Replace the current selection (or insert at the caret) with `text`.
   *  Emits onChange. If `caretOffset` is given, the caret lands that many
   *  characters after the insertion start instead of at its end. */
  insertAtCursor(text: string, caretOffset?: number): void;
  /** Current document text. */
  getValue(): string;
}

/* ----------------------------------------------------------------
   Lazy-loaded CodeMirror 6.

   All @codemirror/* packages are pulled in via dynamic import() so they
   land in their own chunk(s) (see vite.config.ts chunkFileNames) instead of
   bloating the main editor.js bundle -- most sessions never open a code
   editor. The module set is fetched once (module-level promise, shared
   across every CodeEditor instance on the page) and cached forever.

   While the import is in flight -- or if it ever fails (offline, CDN
   hiccup, an environment where CodeMirror can't mount e.g. some headless
   test runners) -- callers get a plain <textarea> so editing never breaks,
   just loses syntax highlighting/autocomplete.
   ---------------------------------------------------------------- */

interface CmModules {
  EditorState: typeof import('@codemirror/state').EditorState;
  EditorView: typeof import('@codemirror/view').EditorView;
  keymap: typeof import('@codemirror/view').keymap;
  lineNumbers: typeof import('@codemirror/view').lineNumbers;
  highlightActiveLine: typeof import('@codemirror/view').highlightActiveLine;
  highlightActiveLineGutter: typeof import('@codemirror/view').highlightActiveLineGutter;
  drawSelection: typeof import('@codemirror/view').drawSelection;
  defaultKeymap: typeof import('@codemirror/commands').defaultKeymap;
  history: typeof import('@codemirror/commands').history;
  historyKeymap: typeof import('@codemirror/commands').historyKeymap;
  indentWithTab: typeof import('@codemirror/commands').indentWithTab;
  syntaxHighlighting: typeof import('@codemirror/language').syntaxHighlighting;
  defaultHighlightStyle: typeof import('@codemirror/language').defaultHighlightStyle;
  bracketMatching: typeof import('@codemirror/language').bracketMatching;
  indentOnInput: typeof import('@codemirror/language').indentOnInput;
  foldGutter: typeof import('@codemirror/language').foldGutter;
  autocompletion: typeof import('@codemirror/autocomplete').autocompletion;
  completionKeymap: typeof import('@codemirror/autocomplete').completionKeymap;
  closeBrackets: typeof import('@codemirror/autocomplete').closeBrackets;
  closeBracketsKeymap: typeof import('@codemirror/autocomplete').closeBracketsKeymap;
  html: typeof import('@codemirror/lang-html').html;
  css: typeof import('@codemirror/lang-css').css;
  javascript: typeof import('@codemirror/lang-javascript').javascript;
  oneDark: typeof import('@codemirror/theme-one-dark').oneDark;
}

let cmModulesPromise: Promise<CmModules> | null = null;

function loadCodeMirror(): Promise<CmModules> {
  if (!cmModulesPromise) {
    cmModulesPromise = Promise.all([
      import('@codemirror/state'),
      import('@codemirror/view'),
      import('@codemirror/commands'),
      import('@codemirror/language'),
      import('@codemirror/autocomplete'),
      import('@codemirror/lang-html'),
      import('@codemirror/lang-css'),
      import('@codemirror/lang-javascript'),
      import('@codemirror/theme-one-dark'),
    ]).then(([state, view, commands, language, autocomplete, langHtml, langCss, langJs, theme]) => ({
      EditorState: state.EditorState,
      EditorView: view.EditorView,
      keymap: view.keymap,
      lineNumbers: view.lineNumbers,
      highlightActiveLine: view.highlightActiveLine,
      highlightActiveLineGutter: view.highlightActiveLineGutter,
      drawSelection: view.drawSelection,
      defaultKeymap: commands.defaultKeymap,
      history: commands.history,
      historyKeymap: commands.historyKeymap,
      indentWithTab: commands.indentWithTab,
      syntaxHighlighting: language.syntaxHighlighting,
      defaultHighlightStyle: language.defaultHighlightStyle,
      bracketMatching: language.bracketMatching,
      indentOnInput: language.indentOnInput,
      foldGutter: language.foldGutter,
      autocompletion: autocomplete.autocompletion,
      completionKeymap: autocomplete.completionKeymap,
      closeBrackets: autocomplete.closeBrackets,
      closeBracketsKeymap: autocomplete.closeBracketsKeymap,
      html: langHtml.html,
      css: langCss.css,
      javascript: langJs.javascript,
      oneDark: theme.oneDark,
    }));
  }
  return cmModulesPromise;
}

// Guards against a caller passing a `caretOffset` outside [0, text.length]
// (e.g. a stale offset computed against different snippet text), which
// would otherwise let `insertAtCursor` compute a caret position past the
// text it just inserted.
function clampCaretOffset(caretOffset: number | undefined, textLength: number): number {
  if (caretOffset === undefined) return textLength;
  return Math.min(Math.max(caretOffset, 0), textLength);
}

function languageExtension(mods: CmModules, language: CodeEditorLanguage) {
  switch (language) {
    case 'css':
      return mods.css();
    case 'javascript':
      return mods.javascript();
    case 'html':
    case 'auto':
    default:
      // lang-html already highlights + completes embedded <script>/<style>
      // blocks as JS/CSS, which is exactly what "auto" wants for mixed
      // HTML snippets (head code, HTML blocks).
      return mods.html({ autoCloseTags: true });
  }
}

const fallbackStyle: React.CSSProperties = {
  width: '100%',
  height: '100%',
  padding: 14,
  background: '#0d0d0f',
  color: '#e4e4e7',
  border: '1px solid #3f3f46',
  borderRadius: 8,
  fontFamily: 'Source Code Pro, Consolas, monospace',
  fontSize: 13,
  lineHeight: 1.6,
  resize: 'none',
  outline: 'none',
  tabSize: 2,
  boxSizing: 'border-box',
};

export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
  value,
  onChange,
  language = 'html',
  height = 320,
  placeholder,
}: CodeEditorProps, ref) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const viewRef = useRef<EditorViewType | null>(null);
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;
  // Tracks the last value this component itself emitted, so the
  // value-sync effect below doesn't stomp on in-progress typing when the
  // parent re-renders with the exact same string it was just handed.
  const lastEmittedRef = useRef(value);
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');

  useEffect(() => {
    let cancelled = false;
    setStatus('loading');

    loadCodeMirror()
      .then((mods) => {
        if (cancelled || !containerRef.current) return;
        const updateListener = mods.EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            const next = update.state.doc.toString();
            lastEmittedRef.current = next;
            onChangeRef.current(next);
          }
        });
        const state = mods.EditorState.create({
          doc: value,
          extensions: [
            mods.lineNumbers(),
            mods.highlightActiveLineGutter(),
            mods.highlightActiveLine(),
            mods.history(),
            mods.foldGutter(),
            mods.drawSelection(),
            mods.indentOnInput(),
            mods.syntaxHighlighting(mods.defaultHighlightStyle, { fallback: true }),
            mods.bracketMatching(),
            mods.closeBrackets(),
            mods.autocompletion(),
            mods.keymap.of([
              ...mods.closeBracketsKeymap,
              ...mods.historyKeymap,
              ...mods.completionKeymap,
              ...mods.defaultKeymap,
              mods.indentWithTab,
            ]),
            languageExtension(mods, language),
            mods.oneDark,
            mods.EditorView.lineWrapping,
            updateListener,
          ],
        });
        const view = new mods.EditorView({ state, parent: containerRef.current });
        viewRef.current = view;
        setStatus('ready');
      })
      .catch((err) => {
        // eslint-disable-next-line no-console
        console.error('CodeEditor: CodeMirror failed to load, falling back to a plain textarea', err);
        if (!cancelled) setStatus('error');
      });

    return () => {
      cancelled = true;
      viewRef.current?.destroy();
      viewRef.current = null;
    };
    // Re-mount on language change (language is fixed for HTML/CSS/JS
    // targets in this app -- it never flips on an already-open editor --
    // but re-creating cleanly if it ever does is simpler/safer than trying
    // to reconfigure the LanguageSupport extension in place).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [language]);

  // Keep the live CodeMirror doc in sync if `value` changes from outside
  // (e.g. the modal is reused for a different field) without clobbering
  // the cursor position/selection on every keystroke-driven re-render.
  useEffect(() => {
    const view = viewRef.current;
    if (!view || status !== 'ready') return;
    if (value === lastEmittedRef.current) return;
    const current = view.state.doc.toString();
    if (current === value) return;
    view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
    lastEmittedRef.current = value;
  }, [value, status]);

  useImperativeHandle(ref, (): CodeEditorHandle => ({
    getValue: () => {
      const view = viewRef.current;
      if (view) return view.state.doc.toString();
      return textareaRef.current?.value ?? value;
    },
    insertAtCursor: (text: string, caretOffset?: number) => {
      const view = viewRef.current;
      if (view) {
        const { from, to } = view.state.selection.main;
        const caret = from + clampCaretOffset(caretOffset, text.length);
        view.dispatch({
          changes: { from, to, insert: text },
          selection: { anchor: caret },
        });
        view.focus();
        return;
      }
      // Textarea fallback: CodeMirror never mounted yet (still loading, or
      // its lazy chunks failed). The toolbar must keep working either way.
      //
      // Deliberately do NOT touch lastEmittedRef here -- same as the plain
      // textarea onChange handler below, which never touches it either.
      // The mount effect's dynamic import() closes over `value` at the time
      // it started, so CodeMirror can finish loading with a stale doc if it
      // resolves after this insertion. The only thing that catches that is
      // the value-sync effect above, which is gated on `value !==
      // lastEmittedRef.current`. If this insertion advanced lastEmittedRef
      // to `next`, that effect would see value === lastEmittedRef.current
      // once the parent re-renders and silently skip pushing the insertion
      // into the freshly-mounted (stale) doc -- losing it for good.
      const ta = textareaRef.current;
      if (!ta) return;
      const from = ta.selectionStart ?? ta.value.length;
      const to = ta.selectionEnd ?? from;
      const next = ta.value.slice(0, from) + text + ta.value.slice(to);
      const caret = from + clampCaretOffset(caretOffset, text.length);
      onChangeRef.current(next);
      ta.value = next;
      ta.selectionStart = caret;
      ta.selectionEnd = caret;
      ta.focus();
    },
  }), [value]);

  const showFallback = status !== 'ready';

  return (
    <div style={{ position: 'relative', height, minHeight: height }}>
      <div
        ref={containerRef}
        data-testid="code-editor-cm-root"
        style={{
          height: '100%',
          overflow: 'auto',
          borderRadius: 8,
          border: '1px solid #3f3f46',
          display: showFallback ? 'none' : 'block',
        }}
      />
      {showFallback && (
        <textarea
          ref={textareaRef}
          data-testid="code-editor-fallback"
          data-language={language}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          placeholder={status === 'loading' ? (placeholder || 'Loading editor…') : placeholder}
          spellCheck={false}
          style={fallbackStyle}
        />
      )}
    </div>
  );
});
