import { IconPencil } from "@tabler/icons-react";
import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";

import { ltrCodeBlockProps } from "../code-block-direction.js";
import { defineBlock } from "../types.js";
import type {
  BlockReadProps,
  BlockEditProps,
  BlockRenderContext,
} from "../types.js";
import { useBlockCopy } from "./block-copy.js";
import {
  sanitizeWireframeCss,
  sanitizeWireframeHtml,
  scopeDesignCss,
} from "./sanitize-html.js";
import { renderWireframeIconHtml } from "./wireframe-icons.js";
import {
  HTML_ROUGH_SELECTOR,
  KitConfigContext,
  RoughOverlay,
  Screen,
  renderNodes,
  toggleWireframeStyle,
  useIsDark,
  useWireframeStyle,
} from "./wireframe-kit.js";
import {
  wireframeSchema,
  wireframeMdx,
  type WireframeData,
  type WireframeSurface,
} from "./wireframe.config.js";

/**
 * Shared `wireframe` block — a hand-drawn low-fi mockup of one screen, rendered
 * from either a declarative kit tree (`data.screen`) or a self-contained HTML
 * mockup (`data.html`), inside a surface-locked frame (desktop/mobile/popover/
 * panel/browser) with a rough.js sketch overlay. Lives in core so any app can
 * register it (it originated in the plan template).
 *
 * DECOUPLING from the plan original:
 * - Theme: `useIsDark()` reads `document.documentElement.classList` instead of
 *   `next-themes` (the MermaidBlock precedent), so core stays dependency-light.
 * - HTML sanitize: the HTML path runs `data.html`/`data.css` through the
 *   app-injected `ctx.sanitizeHtml`. If no sanitizer is wired the HTML path is
 *   skipped (kit tree or an empty frame renders) — core never injects unsanitized
 *   author HTML.
 * - The plan-only prototype runtime, design-element selection, and legacy region
 *   fallback are intentionally NOT ported; those are plan-canvas features, not
 *   part of the document-block render. The kit element vocabulary, the `--wf-*`
 *   token contract, and the `.plan-wf` / `.wf-*` / `[data-rough]` classes the
 *   overlay measures are preserved exactly.
 *
 * The section carries the app-neutral `an-block` class plus the legacy
 * `plan-block` class so plan renders byte-identically while any other app gets
 * the theme-token treatment from core's `blocks.css`.
 *
 * The wireframe is canvas / agent-patch edited (node-addressable content patches
 * applied server-side), NOT schema-form edited in the browser — so `Edit` reuses
 * the same static render as `Read`, mirroring the plan `WireframeEditor`.
 */

type SurfacePreset = {
  width: number;
  /**
   * Floor height for the surface. The frame is AUTO-HEIGHT (content-driven): it
   * grows past this when content is tall and shrinks toward its content height
   * when content is short, but never collapses below this floor — so an empty or
   * near-empty frame still reads as that surface instead of a thin sliver. This
   * is a `min-height`, not a fixed `height`: it is the lower bound the old fixed
   * preset height used to also be the UPPER bound, which is what left a big empty
   * vertical band below short content (e.g. a header + one dropdown padded to a
   * tall fixed aspect).
   */
  minHeight: number;
  radius: number;
};

const SURFACE_PRESETS: Record<WireframeSurface, SurfacePreset> = {
  // mobile keeps a tall floor: a phone frame reads as a phone even when short.
  mobile: { width: 300, minHeight: 360, radius: 30 },
  desktop: { width: 840, minHeight: 200, radius: 14 },
  browser: { width: 900, minHeight: 200, radius: 14 },
  popover: { width: 360, minHeight: 120, radius: 16 },
  panel: { width: 420, minHeight: 200, radius: 16 },
};

function isHtmlData(data: WireframeData): boolean {
  return typeof data.html === "string" && data.html.trim().length > 0;
}

/* -------------------------------------------------------------------------- */
/* Shared frame shell: surface-locked WIDTH + auto (content-driven) height +   */
/* theme + rough overlay. The frame keeps each surface's footprint and chrome  */
/* but fits its content height instead of padding to a fixed aspect, so short  */
/* content yields a short frame and tall content grows. Pass `canvasSize` to   */
/* opt a fixed-aspect canvas artboard back into a hard pixel height.           */
/* -------------------------------------------------------------------------- */

function ArtboardFrame({
  surface,
  compact,
  canvasSize,
  canvasWidth,
  skeleton,
  renderMode,
  roughOverlay = true,
  showFrame = true,
  selector,
  caption,
  render,
}: {
  surface: WireframeSurface;
  compact?: boolean;
  /**
   * Force a FIXED pixel height instead of the auto-height (content-driven)
   * default. Reserved for fixed-aspect canvas artboards (pan/zoom). Document-flow
   * wireframes — what recaps render — leave this unset so the frame fits content.
   */
  canvasSize?: number;
  canvasWidth?: number;
  skeleton?: boolean;
  renderMode?: "wireframe" | "design";
  roughOverlay?: boolean;
  showFrame?: boolean;
  selector: string;
  caption?: string;
  render: (ctx: {
    theme: "light" | "dark";
    style: "sketchy" | "clean";
  }) => ReactNode;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const fitRef = useRef<HTMLDivElement>(null);
  const isDark = useIsDark();
  const theme: "light" | "dark" = isDark ? "dark" : "light";
  const preferredStyle = useWireframeStyle();
  const preset = SURFACE_PRESETS[surface] ?? SURFACE_PRESETS.desktop;
  const width = canvasWidth ?? preset.width;
  // AUTO-HEIGHT: with no explicit `canvasSize` the artboard height is driven by
  // its content (`height: auto`), floored at the surface's `minHeight` so a short
  // screen produces a short frame and a tall screen grows — instead of every
  // surface being padded to a fixed preset height that left a big empty band
  // below short content. A `canvasSize` (fixed-aspect canvas artboard) overrides
  // this with a hard pixel height.
  const fixedHeight = canvasSize;
  const minHeight = fixedHeight ?? preset.minHeight;
  const baseScale = compact ? Math.min(1, 320 / preset.width) : 1;
  const maxFrameWidth = compact ? preset.width * baseScale : width;
  const [fitScale, setFitScale] = useState(baseScale);
  // The scaled artboard is `transform: scale()`-ed, which does not change its
  // layout box, so the wrapper that reserves vertical space must track the
  // artboard's ACTUAL rendered height. With a fixed height that's known up front;
  // with auto-height we measure it.
  const [measuredHeight, setMeasuredHeight] = useState<number | null>(
    fixedHeight ?? null,
  );
  const designMode = renderMode === "design";
  const style = designMode ? "clean" : preferredStyle;
  const sketchy = !designMode && style === "sketchy" && !skeleton;
  const roughEnabled = sketchy && roughOverlay;
  const frameBorder = skeleton
    ? "var(--plan-placeholder-line, var(--plan-line, hsl(var(--border))))"
    : "var(--plan-line, hsl(var(--border)))";

  useEffect(() => {
    const element = fitRef.current;
    if (!element) return;
    const measure = () => {
      const availableWidth = element.clientWidth;
      const nextScale =
        availableWidth > 0
          ? Math.min(baseScale, availableWidth / width)
          : baseScale;
      setFitScale((current) =>
        Math.abs(current - nextScale) < 0.001 ? current : nextScale,
      );
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [baseScale, width]);

  // Track the auto-height artboard's rendered height so the (un-transformed)
  // wrapper reserves exactly the scaled space the frame occupies. Skipped when a
  // fixed height is supplied — there's nothing to measure.
  useEffect(() => {
    if (fixedHeight != null) return;
    const element = ref.current;
    if (!element) return;
    const measure = () => {
      const next = element.offsetHeight;
      setMeasuredHeight((current) =>
        current != null && Math.abs(current - next) < 0.5 ? current : next,
      );
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [fixedHeight]);

  // Height the wrapper reserves: the measured (or fixed) artboard height scaled
  // by the fit factor. Falls back to the surface floor before the first measure
  // so SSR / first paint reserves a sensible box rather than collapsing.
  const reservedHeight = (measuredHeight ?? minHeight) * fitScale;
  const reserveScaledHeight = fixedHeight != null || fitScale !== 1;

  return (
    <div
      ref={fitRef}
      className="plan-kit-wireframe"
      style={{
        width: "100%",
        maxWidth: maxFrameWidth,
      }}
    >
      <div
        className="group/wireframe-artboard relative"
        style={{
          width: "100%",
          maxWidth: maxFrameWidth,
          ...(reserveScaledHeight ? { height: reservedHeight } : {}),
          marginInline: "auto",
        }}
      >
        <div
          ref={ref}
          className="plan-kit-artboard relative"
          data-rough-scope="wireframe"
          data-frame={showFrame ? "show" : "hide"}
          style={{
            width,
            // Auto-height by default (content-driven, floored at `minHeight`);
            // a fixed `canvasSize` locks the height for canvas artboards.
            ...(fixedHeight != null ? { height: fixedHeight } : { minHeight }),
            borderRadius: preset.radius,
            ...(fitScale !== 1
              ? {
                  transform: `scale(${fitScale})`,
                  transformOrigin: "top left",
                }
              : {}),
          }}
        >
          {/* Content drives the artboard height in flow when auto-height; for a
              fixed height it's pinned to the box. Rounded corners clip overflow
              either way. */}
          <div
            className="overflow-hidden"
            style={{
              borderRadius: preset.radius,
              ...(fixedHeight != null
                ? { position: "absolute", inset: 0 }
                : { minHeight }),
            }}
          >
            {render({ theme, style })}
          </div>
          {!roughEnabled && showFrame && (
            <div
              className="pointer-events-none absolute inset-0"
              style={{
                borderRadius: preset.radius,
                border: `1.5px solid ${frameBorder}`,
              }}
            />
          )}
          <RoughOverlay
            scopeRef={ref}
            enabled={roughEnabled}
            drawFrame={showFrame}
            frameRadius={preset.radius}
            selector={selector}
          />
        </div>
        {!designMode && !skeleton && <WireframeStyleToggleButton />}
      </div>
      {caption && (
        <p className="mt-2 text-center text-xs text-plan-muted">{caption}</p>
      )}
    </div>
  );
}

function WireframeStyleToggleButton() {
  const style = useWireframeStyle();
  const copy = useBlockCopy();
  const nextStyle = style === "sketchy" ? "clean" : "sketchy";
  const label = nextStyle === "clean" ? copy.clean : copy.sketchy;
  const description = copy.switchVisualStyle.replace(
    "{{style}}",
    label.toLocaleLowerCase(),
  );

  return (
    <button
      type="button"
      data-plan-interactive
      data-rough="none"
      data-wireframe-style-toggle
      aria-label={description}
      title={description}
      onClick={(event) => {
        event.stopPropagation();
        toggleWireframeStyle();
      }}
      className="absolute right-2 top-2 z-30 inline-flex h-7 items-center gap-1 rounded-md border border-border/60 bg-background px-2 text-xs font-medium text-muted-foreground opacity-0 shadow-sm transition-[color,opacity] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/wireframe-artboard:opacity-100"
    >
      <IconPencil className="size-3.5" aria-hidden="true" />
      <span>{label}</span>
    </button>
  );
}

/* -------------------------------------------------------------------------- */
/* HTML artboard — author HTML, themed + roughened by the renderer.           */
/* -------------------------------------------------------------------------- */

function HtmlArtboard({
  data,
  ctx: _ctx,
  showFrame,
  compact,
}: {
  data: WireframeData;
  ctx: BlockRenderContext;
  showFrame: boolean;
  compact?: boolean;
}) {
  const renderMode = data.renderMode ?? "wireframe";
  const designMode = renderMode === "design";
  // Sanitize author HTML/CSS at the render point (defense-in-depth against stored
  // XSS). Self-contained in core via the shared block sanitizer (DOM-based in the
  // browser, regex fallback on the server) so the HTML mockup path renders in any
  // app without the host wiring a sanitizer hook.
  const safeHtml = useMemo(
    () =>
      renderWireframeIconHtml(
        sanitizeWireframeHtml(data.html, {
          preserveThemeClasses: designMode,
        }),
      ),
    [data.html, designMode],
  );
  const scopeId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const scopedCss = useMemo(() => {
    const safeCss = sanitizeWireframeCss(data.css);
    // Scope every author selector under this instance's artboard so global
    // selectors (body, *, .app-shell, :root) can't restyle/hide the host app.
    return safeCss
      ? scopeDesignCss(safeCss, `[data-plan-design-scope="${scopeId}"]`)
      : "";
  }, [data.css, scopeId]);

  return (
    <ArtboardFrame
      surface={data.surface}
      compact={compact}
      skeleton={data.skeleton}
      renderMode={renderMode}
      showFrame={showFrame}
      selector={HTML_ROUGH_SELECTOR}
      caption={data.caption}
      render={({ theme, style }) => (
        <div
          className="plan-html-frame"
          data-theme={theme}
          data-style={style}
          data-frame={showFrame ? "show" : "hide"}
          data-render-mode={renderMode}
          data-plan-design-scope={scopeId}
          data-skeleton={data.skeleton ? "true" : undefined}
        >
          {scopedCss && <style>{scopedCss}</style>}
          <div
            className="plan-html-frame-content"
            dangerouslySetInnerHTML={{ __html: safeHtml }}
          />
        </div>
      )}
    />
  );
}

/* -------------------------------------------------------------------------- */
/* Kit artboard — declarative kit tree.                                       */
/* -------------------------------------------------------------------------- */

function KitArtboard({
  data,
  showFrame,
  compact,
}: {
  data: WireframeData;
  showFrame: boolean;
  compact?: boolean;
}) {
  return (
    <ArtboardFrame
      surface={data.surface}
      compact={compact}
      skeleton={data.skeleton}
      renderMode={data.renderMode}
      showFrame={showFrame}
      selector="[data-rough]"
      caption={data.caption}
      render={({ theme, style }) => (
        <KitConfigContext.Provider
          value={{
            skeleton: data.skeleton,
            flushFrame: !showFrame,
            theme,
            style,
          }}
        >
          {renderKitScreen(data.screen ?? [])}
        </KitConfigContext.Provider>
      )}
    />
  );
}

function renderKitScreen(
  nodes: NonNullable<WireframeData["screen"]>,
): ReactNode {
  if (nodes.length === 1 && nodes[0]?.el === "screen") {
    return renderNodes(nodes);
  }
  // `minHeight` (not `height`) so the screen fills the auto-height artboard floor
  // but grows past it when content is tall, instead of locking to a fixed box.
  return (
    <Screen pad="calc(var(--pad) * 1.35)" style={{ minHeight: "100%" }}>
      {renderNodes(nodes)}
    </Screen>
  );
}

/**
 * The bare wireframe surface (no block section / title). Routes to the HTML
 * mockup when `data.html` is present and a sanitizer is wired; otherwise renders
 * the kit tree.
 */
function WireframeSurfaceView({
  data,
  ctx,
  compact,
}: {
  data: WireframeData;
  ctx: BlockRenderContext;
  compact?: boolean;
}) {
  const showFrame = resolveVisualFrame(data.frame, ctx);
  if (isHtmlData(data)) {
    return (
      <HtmlArtboard
        data={data}
        ctx={ctx}
        showFrame={showFrame}
        compact={compact}
      />
    );
  }
  return <KitArtboard data={data} showFrame={showFrame} compact={compact} />;
}

function resolveVisualFrame(
  frame: WireframeData["frame"],
  ctx: BlockRenderContext,
): boolean {
  const resolved =
    frame && frame !== "auto" ? frame : (ctx.visualFrame ?? "show");
  return resolved !== "hide";
}

/* -------------------------------------------------------------------------- */
/* Block Read / Edit                                                          */
/* -------------------------------------------------------------------------- */

/** Read-only renderer for a `wireframe` block. */
export function WireframeBlock({
  data,
  blockId,
  title,
  summary,
  ctx,
  compactVisuals,
}: BlockReadProps<WireframeData>) {
  return (
    <section
      {...ltrCodeBlockProps}
      className="an-block plan-block an-wireframe"
      data-block-id={blockId}
    >
      {title && <div className="an-block-label plan-block-label">{title}</div>}
      <WireframeSurfaceView data={data} ctx={ctx} compact={compactVisuals} />
      {summary && <p className="mt-5 text-plan-muted">{summary}</p>}
    </section>
  );
}

/**
 * Editor for the `wireframe` block. The wireframe is canvas / agent-patch edited
 * (it never calls `onChange`), so edit mode reuses the read surface — mirroring
 * the plan `WireframeEditor`. The host document editor already wraps the registry
 * edit path in a titled section, so this renders only the surface to avoid
 * double-nesting.
 */
export function WireframeEditor({ data, ctx }: BlockEditProps<WireframeData>) {
  return <WireframeSurfaceView data={data} ctx={ctx} />;
}

/** Full client spec for the shared `wireframe` block (schema + MDX + Read/Edit). */
export const wireframeBlock = defineBlock<WireframeData>({
  type: "wireframe",
  schema: wireframeSchema,
  mdx: wireframeMdx,
  Read: WireframeBlock,
  Edit: WireframeEditor,
  placement: ["block"],
  editSurface: "inline",
  label: "Wireframe",
  description:
    "A sketch wireframe of one screen built from kit primitives (or an HTML mockup), rendered in a chosen surface frame (desktop/mobile/popover/panel/browser).",
  // `surface` is the only required field; `screen` defaults to []. Start on the
  // desktop surface with an empty screen so the canvas/agent can fill it in.
  empty: () => ({ surface: "desktop", screen: [] }),
});
