import { Handle, type Node, type NodeProps, Position, useUpdateNodeInternals } from '@xyflow/react';
import { Maximize2 } from 'lucide-react';
import { type CSSProperties, type ReactNode, type RefObject, memo, useEffect, useRef } from 'react';
import { cn } from '../lib/cn.ts';
import { colorTokenStyle } from '../lib/color-tokens.ts';
import { debouncedResizeObserver } from '../lib/debounced-resize-observer.ts';
import { injectSanitizedHtml } from '../lib/inject-sanitized-html.ts';
import { ensureTailwindLoaded } from '../lib/tailwind-runtime.ts';
import type { HtmlNodeData } from '../types.ts';
import { Icon } from '../ui/icon.tsx';
import { PlaceholderCard } from './placeholder-card.tsx';
import { ResizeControls } from './resize-controls.tsx';
import { useResizeGesture } from './use-resize-gesture.ts';

export type HtmlNodeRuntimeData = HtmlNodeData & {
  onResize?: (
    nodeId: string,
    dims: { width: number; height: number; x: number; y: number },
  ) => void;
  onResizeEnd?: (
    nodeId: string,
    dims: { width: number; height: number; x: number; y: number },
  ) => void;
  setResizing?: (on: boolean) => void;
  // When wired (edit mode only), the renderer's "Fit to content" button calls
  // this. The host's handler PATCHes { autoSize: true } through the adapter,
  // which strips width/height server-side per the autoSize invariant.
  onFitToContent?: (nodeId: string) => void;
} & Record<string, unknown>;
export type HtmlNodeType = Node<HtmlNodeRuntimeData, 'htmlNode'>;

export const HTML_DEFAULT_SIZE = { width: 320, height: 200 } as const;

const MIN_W = 80;
const MIN_H = 40;

const HANDLE_CLASS = 'sf:opacity-0 sf:transition-opacity';

function HtmlNodeImpl({ id, data, selected, isConnectable }: NodeProps<HtmlNodeType>) {
  const { isResizing, onResizeStart, onResizeEvent, onResizeEnd } = useResizeGesture({
    onResize: (dims) => data.onResize?.(id, dims),
    onResizeEnd: (dims) => data.onResizeEnd?.(id, dims),
    setResizing: data.setResizing,
  });
  // autoSize defaults to true (field absent → auto-size is the default for
  // new htmlNodes per the studio adapter invariant). `isResizing` temporarily
  // forces user-sized layout so the drag has dimensions to grab against from
  // the first frame, before the autoSize: false write echoes back from disk.
  const autoSize = data.autoSize ?? true;
  const userSized = isResizing || !autoSize;

  // US-014: htmlNode defaults to a transparent / borderless wrapper so author
  // HTML can paint edge-to-edge. Only fields the author has SET land in the
  // style object — `colorTokenStyle` is used so theming stays consistent with
  // every other visual node.
  //
  // Chrome (border / bg / radius / font) lives on the INNER wrapper so its
  // `overflow:hidden` can clip author HTML to the rounded corners without also
  // clipping the connector handles and resize corners, which paint outside the
  // node's bounding box on selection (see styles/index.css handle/resize
  // transforms). Sizing stays on the OUTER wrapper so React Flow's positioned
  // children (Handle, NodeResizeControl) measure against the right box.
  const chromeStyle: CSSProperties = {
    ...(data.backgroundColor !== undefined
      ? { backgroundColor: colorTokenStyle(data.backgroundColor, 'node').backgroundColor }
      : {}),
    ...(data.borderColor !== undefined
      ? { borderColor: colorTokenStyle(data.borderColor, 'node').borderColor }
      : {}),
    ...(data.borderSize !== undefined ? { borderWidth: data.borderSize } : {}),
    ...(data.borderStyle !== undefined ? { borderStyle: data.borderStyle } : {}),
    ...(data.cornerRadius !== undefined ? { borderRadius: data.cornerRadius } : {}),
    ...(data.fontSize !== undefined ? { fontSize: `${data.fontSize}px` } : {}),
    ...colorTokenStyle(data.textColor, 'text'),
  };

  // US-012: load the Tailwind v4 browser runtime at mount so author HTML's
  // utility classes actually paint. Idempotent — only the first htmlNode on
  // the page injects the script; subsequent mounts are no-ops.
  useEffect(() => {
    ensureTailwindLoaded();
  }, []);

  const measureRef = useRef<HTMLDivElement | null>(null);
  // Content is inlined into `data.html` by the studio's file-ref resolver —
  // no async fetch needed at render time. Empty string means the author
  // hasn't written anything yet; the placeholder card surfaces that state.
  const html = data.html ?? '';
  const hasContent = html.length > 0;
  // Observer is mounted only when auto-sizing + content is non-empty.
  const observerActive = autoSize && hasContent;

  let body: ReactNode;
  if (hasContent) {
    // US-013 / US-014: the trust boundary lives in `injectSanitizedHtml` —
    // every site that mounts untrusted author HTML threads through that
    // helper. The sanitizer drops <script>, <style>, <iframe>, on*=
    // attributes, and javascript: URLs before the HTML is returned.
    //
    // Auto-size branch uses an inline-block measuring container so React
    // Flow's wrapper shrink-wraps the content's natural dimensions (capped
    // at 800×600). User-sized branch fills the outer (which carries explicit
    // width/height) and scrolls inside.
    body = userSized ? (
      <div
        data-testid="html-node-content"
        className="sf:h-full sf:w-full sf:overflow-auto"
        {...injectSanitizedHtml(html)}
      />
    ) : (
      <div
        ref={measureRef}
        data-testid="html-node-content"
        className="sf:inline-block"
        style={{ maxWidth: 800, maxHeight: 600, overflow: 'auto' }}
        {...injectSanitizedHtml(html)}
      />
    );
  } else {
    body = <PlaceholderCard message="Empty htmlNode — write to view.html" />;
  }

  // While there's no content, the measuring container isn't present, so
  // React Flow has nothing to size to. Fall back to HTML_DEFAULT_SIZE for the
  // placeholder card's bounding box.
  const placeholderFallback =
    !userSized && !hasContent
      ? { width: HTML_DEFAULT_SIZE.width, height: HTML_DEFAULT_SIZE.height }
      : {};

  const outerStyle: CSSProperties = {
    ...(userSized ? { width: data.width, height: data.height } : {}),
    ...placeholderFallback,
  };

  // Inner shrink-wraps to the body's natural size only when we're auto-sizing
  // a non-empty htmlNode (body is `inline-block` then). In every other case —
  // user-sized, or empty placeholder — the outer has an explicit size and the
  // inner fills it.
  const innerShrinkWraps = !userSized && hasContent;

  return (
    <div
      className={cn('sf:group sf:relative', userSized ? 'sf:h-full sf:w-full' : '')}
      style={outerStyle}
      data-testid="html-node"
      data-node-type="htmlNode"
    >
      {observerActive ? <AutoSizeObserver nodeId={id} measureRef={measureRef} /> : null}
      <ResizeControls
        visible={!!selected && !!data.onResize}
        cornerVariant="visible"
        minWidth={MIN_W}
        minHeight={MIN_H}
        onResizeStart={onResizeStart}
        onResize={onResizeEvent}
        onResizeEnd={onResizeEnd}
      />
      {selected && !autoSize && !isResizing && typeof data.onFitToContent === 'function' ? (
        <button
          type="button"
          data-testid="html-node-fit-to-content"
          title="Fit to content"
          aria-label="Fit to content"
          className="sf:absolute sf:top-1 sf:right-1 sf:z-10 sf:flex sf:h-5 sf:w-5 sf:cursor-pointer sf:items-center sf:justify-center sf:rounded sf:bg-background/80 sf:text-muted-foreground sf:opacity-0 sf:transition-opacity sf:hover:text-foreground sf:group-hover:opacity-100 sf:focus:opacity-100"
          onClick={(e) => {
            e.stopPropagation();
            data.onFitToContent?.(id);
          }}
        >
          <Maximize2 size={12} aria-hidden />
        </button>
      ) : null}
      <Handle
        type="target"
        position={Position.Top}
        id="t"
        isConnectable={isConnectable}
        className={cn(HANDLE_CLASS, selected && 'sf:opacity-100!')}
      />
      <Handle
        type="target"
        position={Position.Left}
        id="l"
        isConnectable={isConnectable}
        className={cn(HANDLE_CLASS, selected && 'sf:opacity-100!')}
      />
      <div
        data-testid="html-node-chrome"
        className={cn(
          'sf:overflow-hidden',
          innerShrinkWraps ? 'sf:inline-block' : 'sf:h-full sf:w-full',
        )}
        style={chromeStyle}
      >
        {body}
      </div>
      <Handle
        type="source"
        position={Position.Right}
        id="r"
        isConnectable={isConnectable}
        className={cn(HANDLE_CLASS, selected && 'sf:opacity-100!')}
      />
      <Handle
        type="source"
        position={Position.Bottom}
        id="b"
        isConnectable={isConnectable}
        className={cn(HANDLE_CLASS, selected && 'sf:opacity-100!')}
      />
      {data.name !== undefined && data.name !== '' ? (
        <div
          data-testid="html-node-label"
          className="sf:-bottom-5 sf:absolute sf:right-0 sf:left-0 sf:truncate sf:text-center sf:text-[11px] sf:text-muted-foreground"
        >
          {data.icon ? (
            <div className="sf:flex sf:items-center sf:justify-center sf:gap-1">
              <Icon name={data.icon} size={12} aria-hidden />
              <span className="truncate">{data.name}</span>
            </div>
          ) : (
            data.name
          )}
        </div>
      ) : null}
    </div>
  );
}

// Auto-size only: observe the measuring container and tell React Flow to
// re-read this node's bounding rect once size changes settle. The xyflow hook
// call lives here (not in HtmlNodeImpl) so the hook-shim renderer in tests
// never has to provide an xyflow StoreContext.
function AutoSizeObserver({
  nodeId,
  measureRef,
}: {
  nodeId: string;
  measureRef: RefObject<HTMLDivElement | null>;
}): null {
  const updateNodeInternals = useUpdateNodeInternals();
  useEffect(() => {
    const el = measureRef.current;
    if (el === null) return;
    return debouncedResizeObserver(el, 150, () => {
      updateNodeInternals(nodeId);
    });
  }, [nodeId, updateNodeInternals, measureRef]);
  return null;
}

function arePropsEqual(prev: NodeProps<HtmlNodeType>, next: NodeProps<HtmlNodeType>): boolean {
  return (
    prev.selected === next.selected &&
    prev.data === next.data &&
    prev.width === next.width &&
    prev.height === next.height
  );
}

export const HtmlNode = memo(HtmlNodeImpl, arePropsEqual);
