import {
  forwardRef,
  useCallback,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentPropsWithoutRef,
  type ReactNode,
  type Ref,
} from 'react';
import { cx } from '../utils';

/* ------------------------------------------------------------------ *
 * Typing animation (osu!-lazer style)
 *
 * Native inputs draw their own text, so individual letters can't be
 * animated. Instead the real field renders transparent (caret stays
 * visible) and a mirrored per-character <span> overlay sits behind it:
 * newly typed letters rise + fade in, erased letters fall + fade out.
 * ------------------------------------------------------------------ */

type FieldElement = HTMLInputElement | HTMLTextAreaElement;

interface CharEntry {
  id: number;
  char: string;
  leaving: boolean;
  x?: number;
  y?: number;
}

/** Longest-common-subsequence match so unchanged letters keep their id
 *  (and thus don't replay the appear animation on every keystroke). */
function diffChars(prev: ReadonlyArray<{ id: number; char: string }>, next: string) {
  const n = prev.length;
  const m = next.length;
  const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = m - 1; j >= 0; j--) {
      dp[i][j] =
        prev[i].char === next[j]
          ? dp[i + 1][j + 1] + 1
          : Math.max(dp[i + 1][j], dp[i][j + 1]);
    }
  }
  const reusedId: Array<number | null> = new Array(m).fill(null);
  const keptPrev = new Array(n).fill(false);
  let i = 0;
  let j = 0;
  while (i < n && j < m) {
    if (prev[i].char === next[j]) {
      reusedId[j] = prev[i].id;
      keptPrev[i] = true;
      i++;
      j++;
    } else if (dp[i + 1][j] >= dp[i][j + 1]) {
      i++;
    } else {
      j++;
    }
  }
  return { reusedId, keptPrev };
}

function useFieldAnimation(
  multiline: boolean,
  externalRef: Ref<FieldElement>,
  controlledValue: ComponentPropsWithoutRef<'input'>['value'],
  initial: ComponentPropsWithoutRef<'input'>['defaultValue'],
) {
  const innerRef = useRef<FieldElement | null>(null);
  const overlayRef = useRef<HTMLDivElement | null>(null);
  const spanRefs = useRef<Map<number, HTMLSpanElement>>(new Map());
  const present = useRef<Array<{ id: number; char: string }>>([]);
  const nextId = useRef(0);
  const idPrefix = useId();

  const [text, setText] = useState(() => String(controlledValue ?? initial ?? ''));
  const [entries, setEntries] = useState<CharEntry[]>([]);

  const setRef = useCallback(
    (node: FieldElement | null) => {
      innerRef.current = node;
      if (typeof externalRef === 'function') externalRef(node);
      else if (externalRef) (externalRef as { current: FieldElement | null }).current = node;
    },
    [externalRef],
  );

  const syncScroll = useCallback(() => {
    const el = innerRef.current;
    const ov = overlayRef.current;
    if (el && ov) ov.style.transform = `translate(${-el.scrollLeft}px, ${-el.scrollTop}px)`;
  }, []);

  // Reconcile the overlay whenever the text changes.
  useLayoutEffect(() => {
    const prev = present.current;
    const { reusedId, keptPrev } = diffChars(prev, text);

    const nextPresent: Array<{ id: number; char: string }> = [];
    for (let k = 0; k < text.length; k++) {
      const id = reusedId[k] ?? nextId.current++;
      nextPresent.push({ id, char: text[k] });
    }

    // Letters that were removed fall away — pin them where they last sat.
    const leaving: CharEntry[] = [];
    for (let k = 0; k < prev.length; k++) {
      if (keptPrev[k]) continue;
      const el = spanRefs.current.get(prev[k].id);
      if (el) {
        leaving.push({
          id: prev[k].id,
          char: prev[k].char,
          leaving: true,
          x: el.offsetLeft,
          y: el.offsetTop,
        });
      }
    }

    present.current = nextPresent;
    setEntries((current) => [
      ...nextPresent.map((e) => ({ ...e, leaving: false })),
      ...current.filter((e) => e.leaving),
      ...leaving,
    ]);
    syncScroll();
  }, [text, syncScroll]);

  // Stay in sync when used as a controlled component.
  useLayoutEffect(() => {
    if (controlledValue !== undefined) setText(String(controlledValue));
  }, [controlledValue]);

  const handleChange = useCallback((value: string) => setText(value), []);

  const onLeaveEnd = useCallback((id: number) => {
    spanRefs.current.delete(id);
    setEntries((current) => current.filter((e) => e.id !== id));
  }, []);

  const overlay = (
    <div
      ref={overlayRef}
      aria-hidden="true"
      className={cx('mta-field-overlay', multiline && 'mta-field-overlay--multiline')}
    >
      {entries.map((e) =>
        e.leaving ? (
          <span
            key={`${idPrefix}-${e.id}`}
            className="mta-field-char mta-field-char--leaving"
            style={{ left: e.x, top: e.y }}
            onAnimationEnd={() => onLeaveEnd(e.id)}
          >
            {e.char}
          </span>
        ) : (
          <span
            key={`${idPrefix}-${e.id}`}
            ref={(node) => {
              if (node) spanRefs.current.set(e.id, node);
              else spanRefs.current.delete(e.id);
            }}
            className={cx('mta-field-char', !multiline && 'mta-field-char--composited')}
          >
            {e.char}
          </span>
        ),
      )}
    </div>
  );

  return { setRef, overlay, handleChange, syncScroll };
}

type TextFieldProps = ComponentPropsWithoutRef<'input'> & { animated?: boolean };
type TextAreaProps = ComponentPropsWithoutRef<'textarea'> & { animated?: boolean };

export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  ({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
    const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
      false,
      ref,
      props.value,
      props.defaultValue,
    );
    if (!animated) {
      return (
        <input
          ref={ref}
          className={cx('mta-field', className)}
          style={style}
          onChange={onChange}
          onScroll={onScroll}
          {...props}
        />
      );
    }
    return (
      <div className={cx('mta-field-wrap', className)} style={style}>
        <input
          ref={setRef}
          className="mta-field mta-field--animated"
          onChange={(e) => {
            handleChange(e.currentTarget.value);
            onChange?.(e);
          }}
          onScroll={(e) => {
            syncScroll();
            onScroll?.(e);
          }}
          {...props}
        />
        {overlay}
      </div>
    );
  },
);
TextField.displayName = 'TextField';

export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
  ({ className, style, onChange, onScroll, animated = true, ...props }, ref) => {
    const { setRef, overlay, handleChange, syncScroll } = useFieldAnimation(
      true,
      ref,
      props.value,
      props.defaultValue,
    );
    if (!animated) {
      return (
        <textarea
          ref={ref}
          className={cx('mta-field', className)}
          style={style}
          onChange={onChange}
          onScroll={onScroll}
          {...props}
        />
      );
    }
    return (
      <div
        className={cx('mta-field-wrap', 'mta-field-wrap--multiline', className)}
        style={style}
      >
        <textarea
          ref={setRef}
          className="mta-field mta-field--animated"
          onChange={(e) => {
            handleChange(e.currentTarget.value);
            onChange?.(e);
          }}
          onScroll={(e) => {
            syncScroll();
            onScroll?.(e);
          }}
          {...props}
        />
        {overlay}
      </div>
    );
  },
);
TextArea.displayName = 'TextArea';

export const SearchField = ({
  icon,
  ...props
}: TextFieldProps & { icon: ReactNode }) => (
  <div className="mta-search">
    <span className="ph">{icon}</span>
    <TextField {...props} />
  </div>
);
