import { useEffect, useRef, useState } from "react";
import {
  Compartment,
  EditorSelection,
  EditorState,
  StateEffect,
  StateField,
} from "@codemirror/state";
import {
  Decoration,
  EditorView,
  WidgetType,
  highlightWhitespace,
  keymap,
  lineNumbers,
  type DecorationSet,
} from "@codemirror/view";
import {
  defaultKeymap,
  history,
  historyKeymap,
  indentWithTab,
} from "@codemirror/commands";
import { searchKeymap } from "@codemirror/search";
import type { EditorMark } from "./editor.types";
import type { SourceRange } from "../regex/model/syntax";

const setMarks = StateEffect.define<readonly EditorMark[]>();

function decorationSet(
  marks: readonly EditorMark[],
  documentLength: number,
): DecorationSet {
  return Decoration.set(
    marks.flatMap((mark) => {
      const from = Math.max(0, Math.min(documentLength, mark.range.startUtf16));
      const to = Math.max(from, Math.min(documentLength, mark.range.endUtf16));
      if (mark.zeroWidth || from === to) {
        return [
          Decoration.widget({
            widget: new ZeroWidthWidget(mark.className, mark.label),
            side: 1,
          }).range(from),
        ];
      }
      return [
        Decoration.mark({
          class: mark.className,
          attributes: mark.label
            ? { "aria-label": mark.label, title: mark.label }
            : undefined,
        }).range(from, to),
      ];
    }),
    true,
  );
}

class ZeroWidthWidget extends WidgetType {
  private readonly className: string;
  private readonly label: string | undefined;

  constructor(className: string, label?: string) {
    super();
    this.className = className;
    this.label = label;
  }

  toDOM(): HTMLElement {
    const marker = document.createElement("span");
    marker.className = `cm-zero-width ${this.className}`;
    marker.textContent = "▏";
    marker.setAttribute("aria-label", this.label ?? "Zero-width match");
    return marker;
  }
}

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

export interface CodeEditorProps {
  readonly value: string;
  readonly onChange?: (value: string) => void;
  readonly onSelectionChange?: (range: SourceRange) => void;
  readonly onCursorPositionChange?: (position: {
    readonly offsetUtf16: number;
    readonly line: number;
    readonly column: number;
  }) => void;
  readonly label: string;
  readonly className?: string;
  readonly marks?: readonly EditorMark[];
  readonly selectedRange?: SourceRange;
  readonly lineNumberGutter?: boolean;
  readonly readOnly?: boolean;
  readonly placeholder?: string;
  readonly maximumLengthUtf16?: number;
  readonly maximumUtf8Bytes?: number;
  readonly showWhitespace?: boolean;
}

export function CodeEditor({
  value,
  onChange,
  onSelectionChange,
  onCursorPositionChange,
  label,
  className,
  marks = [],
  selectedRange,
  lineNumberGutter = false,
  readOnly = false,
  placeholder,
  maximumLengthUtf16,
  maximumUtf8Bytes,
  showWhitespace = false,
}: CodeEditorProps) {
  const host = useRef<HTMLDivElement>(null);
  const view = useRef<EditorView | null>(null);
  const onChangeRef = useRef(onChange);
  const onSelectionRef = useRef(onSelectionChange);
  const onCursorPositionRef = useRef(onCursorPositionChange);
  const applyingExternalUpdate = useRef(false);
  const whitespaceConfiguration = useRef(new Compartment());
  const [limitMessage, setLimitMessage] = useState<string>();

  useEffect(() => {
    onChangeRef.current = onChange;
  }, [onChange]);

  useEffect(() => {
    onSelectionRef.current = onSelectionChange;
  }, [onSelectionChange]);

  useEffect(() => {
    onCursorPositionRef.current = onCursorPositionChange;
  }, [onCursorPositionChange]);

  useEffect(() => {
    if (!host.current) return;
    const encoder = new TextEncoder();
    let documentUtf8Bytes =
      maximumUtf8Bytes === undefined
        ? undefined
        : encoder.encode(value).byteLength;
    const editor = new EditorView({
      parent: host.current,
      state: EditorState.create({
        doc: value,
        extensions: [
          ...(lineNumberGutter ? [lineNumbers()] : []),
          history(),
          keymap.of([
            indentWithTab,
            ...defaultKeymap,
            ...historyKeymap,
            ...searchKeymap,
          ]),
          marksField,
          whitespaceConfiguration.current.of(
            showWhitespace ? highlightWhitespace() : [],
          ),
          EditorState.transactionFilter.of((transaction) => {
            if (!transaction.docChanged) return transaction;
            if (
              maximumLengthUtf16 !== undefined &&
              transaction.newDoc.length > maximumLengthUtf16
            ) {
              setLimitMessage(
                `${label} is limited to ${maximumLengthUtf16.toLocaleString()} UTF-16 units.`,
              );
              return [];
            }
            if (
              maximumUtf8Bytes !== undefined &&
              documentUtf8Bytes !== undefined
            ) {
              let nextBytes = documentUtf8Bytes;
              transaction.changes.iterChanges(
                (fromA, toA, _fromB, _toB, inserted) => {
                  nextBytes -= encoder.encode(
                    transaction.startState.doc.sliceString(fromA, toA),
                  ).byteLength;
                  nextBytes += encoder.encode(inserted.toString()).byteLength;
                },
              );
              if (nextBytes > maximumUtf8Bytes) {
                setLimitMessage(
                  `${label} is limited to ${maximumUtf8Bytes.toLocaleString()} UTF-8 bytes.`,
                );
                return [];
              }
              documentUtf8Bytes = nextBytes;
            }
            return transaction;
          }),
          EditorState.readOnly.of(readOnly),
          EditorView.lineWrapping,
          EditorView.contentAttributes.of({
            "aria-label": label,
            "aria-multiline": "true",
            ...(placeholder ? { "data-placeholder": placeholder } : {}),
          }),
          EditorView.updateListener.of((update) => {
            if (update.docChanged && !applyingExternalUpdate.current) {
              setLimitMessage(undefined);
              onChangeRef.current?.(update.state.doc.toString());
            }
            if (
              update.selectionSet &&
              !update.docChanged &&
              !applyingExternalUpdate.current
            ) {
              const selection = update.state.selection.main;
              onSelectionRef.current?.({
                startUtf16: selection.from,
                endUtf16: selection.to,
              });
            }
            if (update.selectionSet || update.docChanged) {
              const offsetUtf16 = update.state.selection.main.head;
              const line = update.state.doc.lineAt(offsetUtf16);
              onCursorPositionRef.current?.({
                offsetUtf16,
                line: line.number,
                column: offsetUtf16 - line.from + 1,
              });
            }
          }),
        ],
      }),
    });
    view.current = editor;
    return () => {
      editor.destroy();
      view.current = null;
    };
    // Editor construction is intentionally one-time. Mutable callbacks and
    // document/decoration synchronization are handled through refs/effects.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    const editor = view.current;
    if (!editor || editor.state.doc.toString() === value) return;
    applyingExternalUpdate.current = true;
    try {
      editor.dispatch({
        changes: {
          from: 0,
          to: editor.state.doc.length,
          insert: value,
        },
      });
    } finally {
      applyingExternalUpdate.current = false;
    }
  }, [value]);

  useEffect(() => {
    view.current?.dispatch({ effects: setMarks.of(marks) });
  }, [marks]);

  useEffect(() => {
    view.current?.dispatch({
      effects: whitespaceConfiguration.current.reconfigure(
        showWhitespace ? highlightWhitespace() : [],
      ),
    });
  }, [showWhitespace]);

  useEffect(() => {
    const editor = view.current;
    if (!editor || !selectedRange) return;
    const start = Math.max(
      0,
      Math.min(editor.state.doc.length, selectedRange.startUtf16),
    );
    const end = Math.max(
      start,
      Math.min(editor.state.doc.length, selectedRange.endUtf16),
    );
    const current = editor.state.selection.main;
    if (current.from === start && current.to === end) return;
    applyingExternalUpdate.current = true;
    try {
      editor.dispatch({
        selection: EditorSelection.single(start, end),
        effects: EditorView.scrollIntoView(start, { y: "center" }),
      });
    } finally {
      applyingExternalUpdate.current = false;
    }
  }, [selectedRange]);

  return (
    <div
      className={`code-editor ${className ?? ""}`}
      data-testid={`editor-${label.toLowerCase().replaceAll(" ", "-")}`}
    >
      <div ref={host} className="code-editor-host" />
      {limitMessage ? (
        <p className="editor-limit-message" role="alert">
          {limitMessage}
        </p>
      ) : null}
    </div>
  );
}
