import * as React from 'react';
import { X } from 'lucide-react';

import { GLOSSARY } from '@/content/glossary';
import type { GlossaryEntry, GlossaryKey } from '@/content/glossary';
import { cn } from '@/lib/utils';

/**
 * A glossed word inside running text.
 *
 * The glossary lives in `@/content/glossary`; this is the one component that
 * renders it inline. A term is a dotted-underlined word of the same size as
 * the words around it — never a 44px target inflating a sentence — with the
 * gloss in a panel that opens on hover and on keyboard focus, and, because
 * hover does not exist on a phone, pins open on tap. A pinned panel carries
 * the 44px close target; the term itself keeps its inline size. Outside tap
 * or Escape dismisses it.
 *
 * One panel, not a Radix Tooltip plus a popover. Radix's uncontrolled state
 * (react-use-controllable-state 1.2.x under React 19) fires `onChange` in a
 * post-commit effect, AFTER the freshly mounted content has registered its
 * `tooltip.open` listener — so a tooltip opened by keyboard focus closes
 * itself on the same commit. Measured, not guessed: the trigger went
 * `closed → instant-open → closed` inside one tick. Hover survived only by
 * accident of timing. A term the keyboard cannot open fails the whole point of
 * glossing, so the panel is ours: three inputs, one boolean, no portal.
 *
 * Screen readers get the gloss through `aria-describedby` on a hidden span
 * that is always in the DOM, so the description does not depend on which
 * visual affordance happens to be open.
 *
 * Demos cannot import this (rule 9), so glossing happens in shell chrome and
 * pages only; surfaces stay literal. A Term is never placed inside a link —
 * it is a button, and a button inside an anchor is a keyboard trap.
 */

const byKey: ReadonlyMap<string, GlossaryEntry> = new Map(GLOSSARY.map((entry) => [entry.key, entry]));

/* ------------------------------------------------------------------ scope */

/**
 * "First occurrence per panel gets the underline; later ones do not."
 *
 * A scope is a set of keys already glossed within one panel. Terms claim their
 * key in a layout effect and release it on unmount, so React StrictMode's
 * mount/unmount/mount rehearsal cannot leave a key claimed by nobody. Without
 * a provider every Term shares one page-wide scope, which is the right default
 * for a page with a single panel.
 */
interface Scope {
  claim: (key: string) => boolean;
  release: (key: string) => void;
}

function createScope(): Scope {
  const seen = new Set<string>();
  return {
    claim: (key) => {
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    },
    release: (key) => {
      seen.delete(key);
    },
  };
}

const ScopeContext = React.createContext<Scope>(createScope());

/** Wrap a tab panel in one of these so each panel gets its own "first use". */
export function TermScope({ children }: { children: React.ReactNode }) {
  const scope = React.useMemo(createScope, []);
  return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
}

/* ------------------------------------------------------------------- term */

export interface TermProps {
  /** A glossary key, e.g. `'rollout'`. Typed, so a typo fails the build, not the reader. */
  term: GlossaryKey;
  /** The word as it appears in the sentence. Defaults to the glossary's own term. */
  children?: React.ReactNode;
  className?: string;
}

export function Term({ term, children, className }: TermProps) {
  const entry = byKey.get(term);
  const scope = React.useContext(ScopeContext);
  const id = React.useId();
  const [first, setFirst] = React.useState(false);
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [pinned, setPinned] = React.useState(false);
  const [align, setAlign] = React.useState<'left' | 'right'>('left');
  const rootRef = React.useRef<HTMLSpanElement>(null);
  const panelRef = React.useRef<HTMLSpanElement>(null);

  const open = pinned || hovered || focused;

  React.useLayoutEffect(() => {
    if (!entry) return undefined;
    const got = scope.claim(entry.key);
    setFirst(got);
    return () => {
      if (got) scope.release(entry.key);
    };
  }, [scope, entry]);

  // Outside tap or Escape dismisses. Listeners exist only while the panel is
  // open, so a page with forty terms is not forty document listeners.
  React.useEffect(() => {
    if (!open) return undefined;
    const onPointer = (event: PointerEvent) => {
      if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
        setPinned(false);
        setHovered(false);
      }
    };
    const onKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setPinned(false);
        setHovered(false);
        // Stays dismissed until the next focus event, so Escape means "go
        // away" and not "flicker".
        setFocused(false);
      }
    };
    document.addEventListener('pointerdown', onPointer);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('pointerdown', onPointer);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  // Anchor the panel to whichever edge keeps it on screen. Measured once per
  // open, after paint, so the first frame is already in the right place.
  React.useLayoutEffect(() => {
    if (!open) return;
    const panel = panelRef.current;
    if (!panel) return;
    const rect = panel.getBoundingClientRect();
    setAlign(rect.right > window.innerWidth - 16 ? 'right' : 'left');
  }, [open]);

  // An unknown key renders the plain word. A sentence must never fail to
  // render because the glossary is missing an entry.
  if (!entry) return <>{children ?? term}</>;

  const glossId = `${id}-gloss`;

  return (
    <span ref={rootRef} className="relative inline">
      <button
        type="button"
        aria-describedby={glossId}
        aria-expanded={pinned}
        onClick={() => setPinned((value) => !value)}
        // Touch pointers never hover; a tap is a click, handled above.
        onPointerEnter={(event) => {
          if (event.pointerType !== 'touch') setHovered(true);
        }}
        onPointerLeave={() => setHovered(false)}
        // Keyboard focus only. A mouse click also focuses the button, and
        // opening on that would double up with the click toggle.
        onFocus={(event) => {
          if (event.currentTarget.matches(':focus-visible')) setFocused(true);
        }}
        onBlur={() => setFocused(false)}
        className={cn(
          // Inherit everything: the term is a word in a sentence, not a
          // control that happens to hold a word.
          'inline cursor-help appearance-none rounded-sm bg-transparent p-0 text-inherit [font:inherit] [letter-spacing:inherit]',
          first
            ? 'underline decoration-border-strong decoration-dotted decoration-[1.5px] underline-offset-[0.2em]'
            : 'no-underline',
          className,
        )}
      >
        {children ?? entry.term}
      </button>

      <span id={glossId} className="sr-only">
        {entry.term}: {entry.gloss}
      </span>

      {open ? (
        <span
          ref={panelRef}
          data-term-panel={pinned ? 'pinned' : 'hover'}
          // Pinned, it is a small dialog with its own close control. Otherwise
          // it is a purely visual echo of the sr-only description above, so it
          // is hidden from the accessibility tree rather than read twice.
          role={pinned ? 'dialog' : undefined}
          aria-label={pinned ? entry.term : undefined}
          aria-hidden={pinned ? undefined : true}
          className={cn(
            'absolute top-full z-50 mt-1.5 flex w-max max-w-[min(36ch,calc(100vw-2.5rem))] items-start gap-2 rounded-md border border-border bg-surface p-3 text-left font-sans text-[0.8125rem] font-normal normal-case leading-[1.45] tracking-normal text-fg-2 shadow-overlay',
            pinned && 'pr-1',
            align === 'left' ? 'left-0' : 'right-0',
          )}
        >
          <span className={cn('min-w-0', pinned && 'py-2')}>
            <span className="mr-1.5 font-mono text-label uppercase tracking-label text-muted">
              {entry.term}
            </span>
            {entry.gloss}
          </span>
          {pinned ? (
            <button
              type="button"
              aria-label="Close"
              onClick={() => setPinned(false)}
              className="tap -mr-0.5 -mt-1 inline-flex shrink-0 items-center justify-center rounded-md text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
            >
              <X aria-hidden="true" className="size-4" />
            </button>
          ) : null}
        </span>
      ) : null}
    </span>
  );
}
