"use client";

import { Box, Group, Text, Textarea } from "@mantine/core";
import React, { Suspense, lazy, useEffect, useRef, useState } from "react";

/**
 * Syntax-highlighted HTML editor for email templates.
 *
 * CodeMirror is loaded lazily and only in the browser. It touches `window` and
 * `document` at module scope, so importing it eagerly would crash the server
 * render — this route is server-rendered by React Router, and a loader-driven
 * page cannot opt out of that.
 *
 * Until it has loaded, a monospace textarea stands in with the same value and
 * onChange. That is deliberate rather than a spinner: a template author who
 * starts typing immediately must not lose keystrokes to a chunk still in
 * flight, and the fallback is exactly what this field used to be.
 */
const CodeMirror = lazy(() => import("@uiw/react-codemirror"));

interface Props {
  value: string;
  onChange: (value: string) => void;
  readOnly?: boolean;
  height?: number;
  placeholder?: string;
  /** 1-based lines with an open change request, highlighted in the editor. */
  markedLines?: number[];
  /** Follows the caret, so a review comment can be filed against where you are. */
  onCursorLine?: (line: number) => void;
  /** Bump to jump to a line — set from clicking a change request. */
  revealLine?: number | null;
}

/** Gmail truncates a message past this, hiding everything below the cut. */
const GMAIL_CLIP_BYTES = 102 * 1024;

const MARKED_CLASS = "cm-edm-change-requested";

export function EDMHtmlEditor({
  value,
  onChange,
  readOnly = false,
  height = 460,
  placeholder = "Paste or edit HTML source here…",
  markedLines = [],
  onCursorLine,
  revealLine = null,
}: Props) {
  const [mounted, setMounted] = useState(false);
  const [extensions, setExtensions] = useState<unknown[]>([]);
  /**
   * CodeMirror API surface, resolved after mount.
   *
   * Taken from @uiw/react-codemirror rather than @codemirror/view directly: the
   * wrapper re-exports the whole of view and state, and the app does not depend
   * on those packages by name, so importing them directly would resolve only by
   * accident of hoisting.
   */
  const [cm, setCm] = useState<any>(null);
  const editorRef = useRef<any>(null);

  useEffect(() => {
    setMounted(true);
    // Same reason as the lazy import: these pull in browser-only code, so they
    // are resolved after mount rather than at module load.
    void Promise.all([
      import("@codemirror/lang-html"),
      import("@uiw/react-codemirror"),
    ]).then(([htmlMod, cmMod]) => {
      setCm(cmMod);
      setExtensions([htmlMod.html({ autoCloseTags: true })]);
    });
  }, []);

  /**
   * Line highlights for open change requests.
   *
   * Rebuilt as a plain extension whenever the set changes rather than held in a
   * StateField: the list comes from the server and changes far less often than
   * the document, so recomputing it is cheaper than keeping it in sync through
   * every keystroke's transaction.
   */
  const markExtension = React.useMemo(() => {
    if (!cm || markedLines.length === 0) return null;

    const { Decoration, EditorView, RangeSetBuilder, StateField } = cm;
    if (!Decoration || !EditorView || !RangeSetBuilder || !StateField) return null;

    const lineDeco = Decoration.line({ class: MARKED_CLASS });
    const wanted = [...new Set(markedLines)].sort((a, b) => a - b);

    const build = (state: any) => {
      const builder = new RangeSetBuilder();
      for (const lineNo of wanted) {
        // A change request can outlive the lines it pointed at — someone deletes
        // the block it was about. Skip rather than throw.
        if (lineNo < 1 || lineNo > state.doc.lines) continue;
        builder.add(state.doc.line(lineNo).from, state.doc.line(lineNo).from, lineDeco);
      }
      return builder.finish();
    };

    const field = StateField.define({
      create: build,
      update: (deco: any, tr: any) => (tr.docChanged ? build(tr.state) : deco),
      provide: (f: any) => EditorView.decorations.from(f),
    });

    return [
      field,
      EditorView.theme({
        [`.${MARKED_CLASS}`]: {
          backgroundColor: "rgba(255, 107, 107, 0.14)",
          boxShadow: "inset 3px 0 0 var(--mantine-color-red-6)",
        },
      }),
    ];
  }, [cm, markedLines]);

  // Jumping to a line is a side effect on the live view, not a prop it reads.
  useEffect(() => {
    if (!revealLine || !cm) return;
    const view = editorRef.current?.view;
    if (!view) return;
    if (revealLine < 1 || revealLine > view.state.doc.lines) return;

    const pos = view.state.doc.line(revealLine).from;
    view.dispatch({
      selection: { anchor: pos },
      effects: cm.EditorView.scrollIntoView(pos, { y: "center" }),
    });
    view.focus();
  }, [revealLine, cm]);

  const bytes = new TextEncoder().encode(value).length;
  const overClip = bytes > GMAIL_CLIP_BYTES;

  const fallback = (
    <Textarea
      value={value}
      onChange={(e) => onChange(e.currentTarget.value)}
      readOnly={readOnly}
      autosize
      minRows={12}
      maxRows={24}
      styles={{ input: { fontFamily: "monospace", fontSize: 12 } }}
      placeholder={placeholder}
    />
  );

  return (
    <Box>
      {mounted ? (
        <Suspense fallback={fallback}>
          <Box
            style={{
              border: "1px solid var(--mantine-color-gray-3)",
              borderRadius: 8,
              overflow: "hidden",
            }}
          >
            <CodeMirror
              ref={editorRef}
              value={value}
              height={`${height}px`}
              extensions={
                (markExtension
                  ? [...extensions, ...markExtension]
                  : extensions) as never
              }
              onChange={onChange}
              onUpdate={(update: any) => {
                if (!onCursorLine) return;
                if (!update.selectionSet && !update.docChanged) return;
                const head = update.state.selection.main.head;
                onCursorLine(update.state.doc.lineAt(head).number);
              }}
              readOnly={readOnly}
              placeholder={placeholder}
              basicSetup={{
                lineNumbers: true,
                foldGutter: true,
                highlightActiveLine: true,
                bracketMatching: true,
                autocompletion: true,
                highlightSelectionMatches: true,
                // Email HTML routinely has single lines thousands of characters
                // long; without wrapping you are scrolling horizontally forever.
                searchKeymap: true,
              }}
            />
          </Box>
        </Suspense>
      ) : (
        fallback
      )}

      <Group justify="space-between" mt={6}>
        <Text size="xs" c="dimmed">
          {value.split("\n").length} lines · {(bytes / 1024).toFixed(1)} KB
        </Text>
        {overClip && (
          <Text size="xs" c="orange">
            Over Gmail&apos;s 102 KB clip threshold — anything below the cut is
            hidden behind “View entire message”.
          </Text>
        )}
      </Group>
    </Box>
  );
}
