"use client";

import { useState, useMemo, type CSSProperties } from "react";
import type {
  Block,
  TextBlockProps,
  ButtonBlockProps,
  ImageBlockProps,
  VideoBlockProps,
  SectionBlockProps,
  DividerBlockProps,
  ListBlockProps,
  ListItemNode,
  FormBlockProps,
  FormInputBlockProps,
  FormSelectBlockProps,
  FormCheckboxBlockProps,
  FormRadioBlockProps,
  FormSelectOption,
  FormRadioOption,
  FormCheckboxOption,
} from "@/features/editor/state/editorStore";
import {
  normalizeVideoSourceUrl,
  resolveVideoPlatform,
  buildVideoEmbedUrl,
  computeVideoPublicTokens,
} from "@/features/editor/utils/videoHelpers";
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
import { computeTextPublicTokens, computeTextPbTokens } from "@/features/editor/utils/textHelpers";
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
import { computeListPublicTokens } from "@/features/editor/utils/listHelpers";
import { computeSectionPublicTokens, computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
import {
  computeFormInputPublicTokens,
  computeFormSelectPublicTokens,
  computeFormCheckboxPublicTokens,
  computeFormRadioPublicTokens,
  computeFormControllerPublicTokens,
} from "@/features/editor/utils/formHelpers";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPageRenderer";

// 섹션 레이아웃/스타일 토큰을 실제 Tailwind 클래스 조합으로 변환하는 유틸
// (에디터/퍼블릭 렌더러에서 동일한 규칙을 재사용하기 위해 export 한다.)
export function getSectionLayoutConfig(props: SectionBlockProps) {
  const backgroundClass =
    props.background === "muted"
      ? "bg-slate-900"
      : props.background === "primary"
      ? "bg-sky-900"
      : "bg-slate-950";

  // 섹션 수직 패딩: builder.css 의 pb-section-py-sm/md/lg (1rem/1.5rem/2.5rem)에 대응
  // - sm  → 1rem  → py-4
  // - md  → 1.5rem → py-6
  // - lg  → 2.5rem → py-10
  const paddingYClass =
    props.paddingY === "sm" ? "py-4" : props.paddingY === "lg" ? "py-10" : "py-6";

  const maxWidthClass =
    props.maxWidthMode === "narrow"
      ? "max-w-3xl"
      : props.maxWidthMode === "wide"
      ? "max-w-6xl"
      : "max-w-5xl";

  const gapXClass =
    props.gapX === "sm" ? "gap-4" : props.gapX === "lg" ? "gap-10" : "gap-8";

  const alignItemsClass =
    props.alignItems === "center"
      ? "items-center"
      : props.alignItems === "bottom"
      ? "items-end"
      : "items-start";

  return {
    backgroundClass,
    paddingYClass,
    maxWidthClass,
    gapXClass,
    alignItemsClass,
  } as const;
}

// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
  blocks: Block[];
  projectSlug?: string;
}

const pxToEm = (px: number, base = 16) => `${px / base}em`;

const convertPxStringToEm = (value?: string | null) => {
  if (!value) return null;
  const match = value.trim().match(/-?\d+(?:\.\d+)?/);
  if (!match) return null;
  const px = parseFloat(match[0]);
  if (!Number.isFinite(px)) return null;
  return pxToEm(px);
};

export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
  const locale = useAppLocale();
  const m = getPublicPageRendererMessages(locale);
  const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
  const [formMessage, setFormMessage] = useState<string>("");

  const [activeFormId, setActiveFormId] = useState<string | null>(null);

  // 폼 컨트롤러 기준으로 필수 필드 맵을 구성한다.
  const requiredFieldIdSet = useMemo(() => {
    const set = new Set<string>();
    for (const block of blocks) {
      if (block.type !== "form") continue;
      const props = block.props as any;
      const fieldIds: string[] = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
      for (const id of fieldIds) {
        if (id) set.add(id);
      }
    }
    return set;
  }, [blocks]);

  const submitFormByController = async (formBlock: Block) => {
    const props = formBlock.props as FormBlockProps;
    const tokens = computeFormControllerPublicTokens(formBlock, blocks);
    const fields = tokens.fields;

    const missingRequiredLabels: string[] = [];

    fields.forEach((field) => {
      if (!field.required) {
        return;
      }

      const name = field.name;
      if (!name) {
        return;
      }

      let isEmpty = false;

      if (field.type === "checkbox") {
        const nodes = document.querySelectorAll<HTMLInputElement>(
          `input[type="checkbox"][name="${name}"]`,
        );
        const anyChecked = Array.from(nodes).some((el) => el.checked);
        isEmpty = !anyChecked;
      } else if (field.type === "radio") {
        const selected = document.querySelector<HTMLInputElement>(
          `input[type="radio"][name="${name}"]:checked`,
        );
        isEmpty = !selected;
      } else if (field.type === "select") {
        const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
        if (!selectEl) {
          isEmpty = true;
        } else {
          const value = selectEl.value;
          isEmpty = value == null || `${value}`.trim() === "";
        }
      } else {
        const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
        const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);

        const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
        if (!valueSource) {
          isEmpty = true;
        } else {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          const rawValue = (valueSource as any).value;
          const value = typeof rawValue === "string" ? rawValue : rawValue != null ? String(rawValue) : "";
          isEmpty = value.trim() === "";
        }
      }

      if (isEmpty) {
        const label = (field.label || field.name || "").trim();
        if (label && !missingRequiredLabels.includes(label)) {
          missingRequiredLabels.push(label);
        }
      }
    });

    if (missingRequiredLabels.length > 0) {
      setActiveFormId(formBlock.id);
      setFormStatus("error");

      const bulletLines = missingRequiredLabels.map((label) => `- ${label}`).join("\n");
      const message = `${m.requiredFieldsPrefix}\n${bulletLines}`;

      setFormMessage(message);
      return;
    }

    const data = new FormData();
    data.append("__config", JSON.stringify(props));
    if (projectSlug && projectSlug.trim() !== "") {
      data.append("__projectSlug", projectSlug.trim());
    }

    fields.forEach((field) => {
      const name = field.name;
      if (!name) {
        return;
      }

      if (field.type === "checkbox") {
        const nodes = document.querySelectorAll<HTMLInputElement>(
          `input[type="checkbox"][name="${name}"]`,
        );
        nodes.forEach((el) => {
          if (el.checked) {
            data.append(name, el.value);
          }
        });
        return;
      }

      if (field.type === "radio") {
        const selected = document.querySelector<HTMLInputElement>(
          `input[type="radio"][name="${name}"]:checked`,
        );
        if (selected) {
          data.append(name, selected.value);
        }
        return;
      }

      if (field.type === "select") {
        const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
        if (selectEl) {
          data.append(name, selectEl.value);
        }
        return;
      }

      const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
      const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);

      const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
      if (valueSource) {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const value = (valueSource as any).value ?? "";
        data.append(name, value);
      }
    });

    try {
      setActiveFormId(formBlock.id);
      setFormStatus("submitting");
      setFormMessage("");

      const res = await fetch("/api/forms/submit", {
        method: "POST",
        body: data,
      });

      if (res.ok) {
        setFormStatus("success");
        setFormMessage(props.successMessage ?? m.submitSuccessDefault);
      } else {
        setFormStatus("error");
        setFormMessage(props.errorMessage ?? m.submitErrorDefault);
      }
    } catch (error) {
      console.error("[PublicPageRenderer] form submit error", error);
      setFormStatus("error");
      setFormMessage(props.errorMessage ?? m.submitErrorDefault);
    }
  };

  const renderBlock = (block: Block) => {
    if (block.type === "text") {
      const props = block.props as TextBlockProps;
      const publicTokens = computeTextPublicTokens(props);
      const text = typeof props.text === "string" ? props.text : "";

      const pbTokens = computeTextPbTokens({
        text,
        align: props.align ?? "left",
        size: props.size ?? "base",
        fontSizeMode: props.fontSizeMode ?? "scale",
        fontSizeScale: props.fontSizeScale ?? undefined,
        fontSizeCustom: props.fontSizeCustom ?? undefined,
        lineHeightMode: props.lineHeightMode ?? "scale",
        lineHeightScale: props.lineHeightScale ?? undefined,
        lineHeightCustom: props.lineHeightCustom ?? undefined,
        fontWeightMode: props.fontWeightMode ?? "scale",
        fontWeightScale: props.fontWeightScale ?? undefined,
        fontWeightCustom: props.fontWeightCustom ?? undefined,
        colorMode: props.colorMode ?? undefined,
        colorPalette: props.colorPalette ?? undefined,
        colorCustom: props.colorCustom ?? undefined,
        backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
        maxWidthMode: props.maxWidthMode ?? "scale",
        maxWidthScale: props.maxWidthScale ?? undefined,
        underline: props.underline ?? false,
        strike: props.strike ?? false,
        italic: props.italic ?? false,
      });

      const className = [
        pbTokens.alignClass,
        pbTokens.sizeClass,
        pbTokens.leadingClass,
        pbTokens.weightClass,
        pbTokens.colorClass,
        pbTokens.maxWidthClass,
        ...pbTokens.extraClasses,
      ]
        .filter(Boolean)
        .join(" ");

      const textStyle: CSSProperties = { ...publicTokens.styleOverrides };

      return (
        <p key={block.id} className={className} style={textStyle}>
          {props.text}
        </p>
      );
    }

    if (block.type === "formInput") {
      const props = block.props as FormInputBlockProps;
      const tokens = computeFormInputPublicTokens(props);
      const isRequired = requiredFieldIdSet.has(block.id);

      const labelDisplay = (props as any).labelDisplay ?? "visible";
      const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs`;

      const rawPlaceholder =
        typeof props.placeholder === "string" ? props.placeholder.trim() : "";
      const rawLabel = typeof props.label === "string" ? props.label.trim() : "";

      // 플로팅 라벨 모드에서는 placeholder 텍스트를 인풋 안에 직접 렌더링하지 않고,
      // 라벨이 인풋 안/위에서 이동하도록 사용한다. placeholder 가 라벨과 다를 경우에만
      // 인풋 하단의 보조 설명 텍스트로 노출한다.
      const helperText =
        labelDisplay === "floating" && rawPlaceholder !== "" && rawPlaceholder !== rawLabel
          ? rawPlaceholder
          : null;

      const inputId = props.formFieldName || block.id;

      if (labelDisplay === "floating") {
        // builder.css 기반 플로팅 라벨 마크업: pb-form-field/pb-form-field--floating + pb-form-label + pb-input
        // Export 경로와 동일한 DOM 구조를 사용해 프리뷰/퍼블릭/Export 가 모두 같은 레이아웃을 갖도록 맞춘다.

        // paddingY 값을 사용해 플로팅 라벨과 인풋 높이를 함께 제어하기 위해 CSS 변수에 전달한다.
        const paddingY =
          typeof (props as any).paddingY === "number" && (props as any).paddingY >= 0
            ? (props as any).paddingY
            : null;

        // 플로팅 필드 wrapper 에도 토큰 기반 wrapperStyle 을 전달해,
        // --pb-input-border-color 와 같은 CSS 변수가 pb-form-field--floating 에 적용되도록 한다.
        const floatingFieldStyle: CSSProperties = { ...tokens.wrapperStyle };
        if (paddingY !== null) {
          (floatingFieldStyle as any)["--pb-input-padding-y"] = `${paddingY / 16}rem`;
        }

        return (
          <div
            key={block.id}
            data-testid="preview-form-input-wrapper"
            className="pb-form-field pb-form-field--floating"
            style={floatingFieldStyle}
          >
            {props.inputType === "textarea" ? (
              <textarea
                id={inputId}
                data-testid="preview-form-input"
                className={`pb-textarea ${tokens.widthClass}`}
                style={tokens.inputStyle}
                name={props.formFieldName}
                required={isRequired}
                // 공백 placeholder 로 라벨이 인풋 안에서 겹치지 않도록 한다.
                placeholder=" "
              />
            ) : (
              <input
                id={inputId}
                data-testid="preview-form-input"
                className={`pb-input ${tokens.widthClass}`}
                style={tokens.inputStyle}
                type={props.inputType === "email" ? "email" : "text"}
                name={props.formFieldName}
                required={isRequired}
                placeholder=" "
              />
            )}
            {helperText && (
              <p className="mt-1 text-[10px]">{helperText}</p>
            )}
            <label htmlFor={inputId} className="pb-form-label">
              {props.label}
            </label>
          </div>
        );
      }

      const labelSpan =
        labelDisplay === "hidden" ? (
          <span className="sr-only">{props.label}</span>
        ) : (
          <span>{props.label}</span>
        );

      return (
        <label
          key={block.id}
          data-testid="preview-form-input-wrapper"
          className={wrapperClassName}
          style={tokens.wrapperStyle}
        >
          {labelSpan}
          {props.inputType === "textarea" ? (
            <textarea
              data-testid="preview-form-input"
              className={`pb-textarea ${tokens.widthClass}`}
              style={tokens.inputStyle}
              name={props.formFieldName}
              required={isRequired}
              placeholder={props.placeholder || props.label}
            />
          ) : (
            <input
              data-testid="preview-form-input"
              className={`pb-input ${tokens.widthClass}`}
              style={tokens.inputStyle}
              type={props.inputType === "email" ? "email" : "text"}
              name={props.formFieldName}
              required={isRequired}
              placeholder={props.placeholder || props.label}
            />
          )}
        </label>
      );
    }

    if (block.type === "formSelect") {
      const props = block.props as FormSelectBlockProps;
      const tokens = computeFormSelectPublicTokens(props);

      const isRequired = requiredFieldIdSet.has(block.id);

      const labelDisplay = (props as any).labelDisplay ?? "visible";
      const labelLayout = (props as any).labelLayout ?? "stacked";
      const isInlineLayout = labelLayout === "inline" && labelDisplay === "visible";

      const labelSpan =
        labelDisplay === "hidden" ? (
          <span className="sr-only">{props.label}</span>
        ) : (
          <span className="pb-form-label">{props.label}</span>
        );

      const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
      const gapEm = pxToEm(gapPx);

      const wrapperClassName = [
        isInlineLayout ? "flex items-center" : "flex flex-col gap-1",
      ]
        .filter(Boolean)
        .join(" ");

      const wrapperStyle: CSSProperties = isInlineLayout
        ? { columnGap: gapEm }
        : {};

      return (
        <label key={block.id} className={wrapperClassName} style={wrapperStyle}>
          {labelSpan}
          <div
            data-testid="preview-form-select"
            className={`${tokens.widthClass}`}
            style={tokens.wrapperStyle}
          >
            <select
              className="pb-select"
              style={tokens.selectStyle}
              name={props.formFieldName}
              required={isRequired}
              defaultValue={props.options[0]?.value ?? ""}
            >
              {props.options.map((opt) => (
                <option key={opt.value} value={opt.value}>
                  {opt.label}
                </option>
              ))}
            </select>
          </div>
        </label>
      );
    }

    if (block.type === "formCheckbox") {
      const props = block.props as FormCheckboxBlockProps;
      const tokens = computeFormCheckboxPublicTokens(props);
      const groupLabelMode = props.groupLabelMode ?? "text";

      const isRequired = requiredFieldIdSet.has(block.id);

      const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
      const labelLayout = (props as any).labelLayout ?? "stacked";
      const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
      const optionLayout = (props as any).optionLayout ?? "stacked";
      const optionsLayoutClass = [
        "pb-form-options",
        optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
      ].join(" ");

      const groupLabelSpan =
        groupLabelDisplay === "hidden" ? (
          <span className="sr-only" style={tokens.groupTextStyle}>
            {props.groupLabel}
          </span>
        ) : (
          <span style={tokens.groupTextStyle}>{props.groupLabel}</span>
        );

      const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
      const gapEm = pxToEm(gapPx);

      const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
      const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";

      const groupStyle: CSSProperties = {
        ...tokens.groupStyle,
        ...(isInlineLayout ? { columnGap: gapEm } : {}),
      };

      return (
        <div
          key={block.id}
          data-testid="preview-form-checkbox-group"
          className={[layoutClass, baseClass].filter(Boolean).join(" ")}
          style={groupStyle}
        >
          {groupLabelMode === "image" && props.groupLabelImageUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={props.groupLabelImageUrl}
              alt={props.groupLabel || m.checkboxGroupLabelImageAltFallback}
              className="inline-block max-w-full h-auto"
              style={tokens.groupTextStyle}
            />
          ) : (
            groupLabelSpan
          )}
          <div
            className={optionsLayoutClass}
            data-testid="preview-form-checkbox-options"
            style={tokens.optionsStyle}
          >
            {props.options.map((opt, index) => (
              <label
                key={opt.value}
                data-testid="preview-form-checkbox-option-container"
                className="pb-form-option"
                style={tokens.optionContainerStyle}
              >
                <input
                  type="checkbox"
                  className="h-4 w-4 rounded"
                  name={props.formFieldName}
                  value={opt.value}
                  required={isRequired && index === 0}
                />
                {opt.labelImageUrl ? (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    src={opt.labelImageUrl}
                    alt={opt.label || props.groupLabel || m.checkboxOptionImageAltFallback}
                    className="inline-block max-w-full h-auto"
                    style={tokens.optionTextStyle}
                  />
                ) : (
                  <span data-testid="preview-form-checkbox-option" style={tokens.optionTextStyle}>
                    {opt.label}
                  </span>
                )}
              </label>
            ))}
          </div>
        </div>
      );
    }

    if (block.type === "formRadio") {
      const props = block.props as FormRadioBlockProps;
      const tokens = computeFormRadioPublicTokens(props);
      const groupLabelMode = props.groupLabelMode ?? "text";

      const isRequired = requiredFieldIdSet.has(block.id);

      const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
      const labelLayout = (props as any).labelLayout ?? "stacked";
      const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
      const optionLayout = (props as any).optionLayout ?? "stacked";
      const optionsLayoutClass = [
        "pb-form-options",
        optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
      ].join(" ");

      const groupLabelSpan =
        groupLabelDisplay === "hidden" ? (
          <span className="sr-only" style={tokens.groupTextStyle}>
            {props.groupLabel}
          </span>
        ) : (
          <span style={tokens.groupTextStyle}>{props.groupLabel}</span>
        );

      const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
      const gapEm = pxToEm(gapPx);

      const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
      const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";

      const groupStyle: CSSProperties = {
        ...tokens.groupStyle,
        ...(isInlineLayout ? { columnGap: gapEm } : {}),
      };

      return (
        <div
          key={block.id}
          data-testid="preview-form-radio-group"
          className={[layoutClass, baseClass].filter(Boolean).join(" ")}
          style={groupStyle}
        >
          {groupLabelMode === "image" && props.groupLabelImageUrl ? (
            // eslint-disable-next-line @next/next/no-img-element
            <img
              src={props.groupLabelImageUrl}
              alt={props.groupLabel || m.checkboxGroupLabelImageAltFallback}
              className="inline-block max-w-full h-auto"
              style={tokens.groupTextStyle}
            />
          ) : (
            groupLabelSpan
          )}
          <div
            className={optionsLayoutClass}
            data-testid="preview-form-radio-options"
            style={tokens.optionsStyle}
          >
            {props.options.map((opt, index) => (
              <label
                key={opt.value}
                className="pb-form-option"
                style={tokens.optionContainerStyle}
              >
                <input
                  type="radio"
                  className="h-4 w-4"
                  name={props.formFieldName}
                  value={opt.value}
                  required={isRequired && index === 0}
                />
                {opt.labelImageUrl ? (
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    src={opt.labelImageUrl}
                    alt={opt.label || props.groupLabel || m.radioOptionImageAltFallback}
                    className="inline-block max-w-full h-auto"
                    style={tokens.optionTextStyle}
                  />
                ) : (
                  <span data-testid="preview-form-radio-option" style={tokens.optionTextStyle}>
                    {opt.label}
                  </span>
                )}
              </label>
            ))}
          </div>
        </div>
      );
    }

    if (block.type === "button") {
      const props = block.props as ButtonBlockProps;

      const pbTokens = computeButtonPbTokens({
        align: props.align ?? "left",
        size: props.size ?? "md",
        variant: props.variant ?? "solid",
        colorPalette: props.colorPalette ?? "primary",
        borderRadius: props.borderRadius ?? "md",
        fullWidth: props.fullWidth ?? false,
        widthMode: props.widthMode ?? undefined,
        widthPx: props.widthPx ?? undefined,
        paddingX: props.paddingX ?? undefined,
        paddingY: props.paddingY ?? undefined,
        fillColorCustom: props.fillColorCustom ?? undefined,
        strokeColorCustom: props.strokeColorCustom ?? undefined,
        textColorCustom: props.textColorCustom ?? undefined,
      });

      const publicTokens = computeButtonPublicTokens(props);
      const buttonStyle: CSSProperties = { ...publicTokens.styleOverrides };

      const wrapperClass = pbTokens.alignClass;
      const buttonClassName = [
        "pb-btn-base",
        pbTokens.sizeClass,
        pbTokens.variantClass,
        pbTokens.radiusClass,
        publicTokens.widthClass,
      ]
        .filter(Boolean)
        .join(" ");

      const hasImage = typeof (props as any).imageSrc === "string" && (props as any).imageSrc.trim() !== "";
      const placement = ((props as any).imagePlacement as string | undefined) ?? "left";

      let innerContent: React.ReactNode;
      if (!hasImage) {
        innerContent = props.label;
      } else {
        const src = (props as any).imageSrc as string;
        const altRaw = (props as any).imageAlt as string | undefined;
        const alt = altRaw && altRaw.trim() !== "" ? altRaw.trim() : props.label;

        const imgEl = (
          // eslint-disable-next-line @next/next/no-img-element
          <img src={src} alt={alt} className="inline-block max-w-full h-auto" />
        );

        const labelSpan = <span className="whitespace-pre-wrap">{props.label}</span>;
        const isVertical = placement === "top" || placement === "bottom";
        const wrapperClassNames = [
          "inline-flex",
          "items-center",
          "gap-2",
          isVertical ? "flex-col" : "",
        ]
          .filter(Boolean)
          .join(" ");

        const first = placement === "left" || placement === "top" ? imgEl : labelSpan;
        const second = placement === "left" || placement === "top" ? labelSpan : imgEl;

        innerContent = (
          <span className={wrapperClassNames}>
            {first}
            {second}
          </span>
        );
      }

      const owningFormBlock = blocks.find(
        (b) => b.type === "form" && (b.props as FormBlockProps).submitButtonId === block.id,
      );
      const isSubmitButton = (() => {
        if (!owningFormBlock) return false;
        const tokens = computeFormControllerPublicTokens(owningFormBlock, blocks);
        return (tokens.fields ?? []).length > 0;
      })();

      const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
        if (!isSubmitButton || !owningFormBlock) {
          return;
        }

        e.preventDefault();
        await submitFormByController(owningFormBlock);
      };

      const anchor = (
        <a
          href={props.href}
          className={buttonClassName}
          style={buttonStyle}
          onClick={isSubmitButton ? handleClick : undefined}
        >
          {innerContent}
        </a>
      );

      if (!isSubmitButton) {
        return (
          <div key={block.id} className={wrapperClass}>
            {anchor}
          </div>
        );
      }

      return (
        <div key={block.id} className={wrapperClass}>
          <div className="flex flex-col items-start gap-1">
            {anchor}
            {owningFormBlock && activeFormId === owningFormBlock.id && formStatus !== "idle" && formMessage ? (
              <p
                className={`text-xs mt-1 ${
                  formStatus === "success" ? "text-emerald-400" : "text-rose-400"
                }`}
              >
                {formMessage}
              </p>
            ) : null}
          </div>
        </div>
      );
    }

    if (block.type === "divider") {
      const props = block.props as DividerBlockProps;
      const tokens = computeDividerPublicTokens(props);

      return (
        <div
          key={block.id}
          data-testid="preview-divider"
          className={`flex w-full ${tokens.alignClass}`}
          style={{ marginTop: tokens.wrapperMarginEm, marginBottom: tokens.wrapperMarginEm }}
        >
          <div className={tokens.innerWidthClass} style={tokens.innerStyle}>
            <div
              data-testid="preview-divider-line"
              className={tokens.thicknessClass}
              style={tokens.lineStyle}
            />
          </div>
        </div>
      );
    }

    if (block.type === "list") {
      const props = block.props as ListBlockProps;
      const tokens = computeListPublicTokens(props);

      const itemsTree: ListItemNode[] =
        (props as any).itemsTree && (props as any).itemsTree.length > 0
          ? ((props as any).itemsTree as ListItemNode[])
          : props.items && props.items.length > 0
            ? props.items.map((text, index) => ({
                id: `${block.id}_item_${index + 1}`,
                text,
                children: [],
              }))
            : [
                {
                  id: `${block.id}_item_1`,
                  text: "List item 1",
                  children: [],
                },
              ];

      const renderListNodes = (nodes: ListItemNode[], level: number): React.ReactNode => {
        const isOrderedLevel = tokens.bulletStyle === "decimal";
        const ListTag = isOrderedLevel ? "ol" : "ul";

        return (
          <ListTag
            className={`pb-list ${tokens.alignClass}`}
            style={{
              ...tokens.listStyle,
              listStyleType:
                tokens.bulletStyle === "none"
                  ? "none"
                  : tokens.bulletStyle,
            }}
          >
            {nodes.map((node, index) => (
              <li
                key={node.id}
                style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
              >
                {node.text}
                {node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
              </li>
            ))}
          </ListTag>
        );
      };

      return renderListNodes(itemsTree, 0);
    }

    if (block.type === "video") {
      const props = block.props as VideoBlockProps;
      const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");

      if (!rawUrl) {
        return null;
      }

      const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
      const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });

      const tokens = computeVideoPublicTokens(props);

      const startTimeSec =
        typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
          ? (props as any).startTimeSec
          : null;
      const endTimeSec =
        typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
          ? (props as any).endTimeSec
          : null;

      const isEmbed = platform === "youtube" || platform === "vimeo";

      return (
        <div key={block.id} className={`w-full flex ${tokens.justifyClass}`}>
          <div>
            <div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
              {isEmbed ? (
                <iframe
                  title={props.titleText && props.titleText.trim() !== "" ? props.titleText.trim() : "비디오"}
                  src={embedUrl}
                  className="pb-video-frame"
                  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
                  allowFullScreen
                />
              ) : (
                <video
                  data-testid="preview-video"
                  className="pb-video-frame"
                  src={rawUrl}
                  style={tokens.videoStyle}
                  poster={props.posterImageSrc && props.posterImageSrc.trim() !== "" ? props.posterImageSrc.trim() : undefined}
                  aria-label={props.ariaLabel && props.ariaLabel.trim() !== "" ? props.ariaLabel.trim() : undefined}
                  onLoadedMetadata={(event) => {
                    if (startTimeSec != null) {
                      const el = event.currentTarget;
                      try {
                        el.currentTime = startTimeSec;
                      } catch {
                        // ignore
                      }
                    }
                  }}
                  onTimeUpdate={(event) => {
                    if (endTimeSec != null) {
                      const el = event.currentTarget;
                      if (el.currentTime >= endTimeSec) {
                        el.pause();
                        try {
                          if (!Number.isNaN(endTimeSec)) {
                            el.currentTime = endTimeSec;
                          }
                        } catch {
                          // ignore
                        }
                      }
                    }
                  }}
                  controls={props.controls !== false}
                  autoPlay={!!props.autoplay}
                  loop={!!props.loop}
                  muted={!!props.muted}
                />
              )}
            </div>
            {props.captionText && props.captionText.trim() !== "" && (
              <p data-testid="preview-video-caption" className="pb-video-caption">
                {props.captionText}
              </p>
            )}
          </div>
        </div>
      );
    }

    if (block.type === "form") {
      return null;
    }

    if (block.type === "image") {
      const props = block.props as ImageBlockProps;
      const tokens = computeImagePublicTokens(props);

      const rawSrc = typeof props.src === "string" ? props.src.trim() : "";
      const hasSrc = rawSrc !== "";

      return (
        <div key={block.id} className={`w-full flex ${tokens.alignClass}`} style={tokens.wrapperStyle}>
          {hasSrc && (
            // eslint-disable-next-line @next/next/no-img-element
            <img src={rawSrc} alt={props.alt} className="object-contain" style={tokens.imageStyle} />
          )}
        </div>
      );
    }

    return null;
  };

  const renderSection = (section: Block) => {
    const props = section.props as SectionBlockProps;

    const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
    const tokens = computeSectionPublicTokens(props);
    const exportTokens = computeSectionExportTokens(props);

    const alignItems =
      props.alignItems === "center" ? "center" : props.alignItems === "bottom" ? "flex-end" : "flex-start";

    return (
      <section
        key={section.id}
        data-testid="preview-section"
        data-section-id={section.id}
        className={exportTokens.sectionClasses}
        style={tokens.sectionStyle}
      >
        {tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
          <video
            data-testid="preview-section-bg-video"
            className="pb-section-bg-video"
            src={tokens.backgroundVideoSrc}
            autoPlay
            loop
            muted
            playsInline
          />
        )}
        <div
          data-testid="preview-section-inner"
          className="pb-section-inner"
          style={tokens.innerWrapperStyle}
        >
          <div
            data-testid="preview-section-columns"
            className="pb-section-columns"
            style={{
              ...tokens.columnsContainerStyle,
              alignItems,
            }}
          >
            {columns.map((col) => {
              const basis = `${(col.span / 12) * 100}%`;
              const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);

              return (
                <div
                  key={col.id}
                  className="pb-section-column"
                  style={{ flexBasis: basis, display: "flex", flexDirection: "column", rowGap: "1rem" }}
                >
                  {columnBlocks.map((b) => (
                    <div key={b.id}>{renderBlock(b)}</div>
                  ))}
                </div>
              );
            })}
          </div>
        </div>
      </section>
    );
  };

  type LayoutChunk =
    | { kind: "root"; blocks: Block[] }
    | { kind: "section"; section: Block };

  const layoutChunks: LayoutChunk[] = [];

  for (const block of blocks) {
    if (block.type === "section") {
      layoutChunks.push({ kind: "section", section: block });
      continue;
    }

    if (!block.sectionId) {
      const last = layoutChunks[layoutChunks.length - 1];
      if (!last || last.kind !== "root") {
        layoutChunks.push({ kind: "root", blocks: [block] });
      } else {
        last.blocks.push(block);
      }
    }
  }

  return (
    <div className="flex-1 flex flex-col">
      {layoutChunks.map((chunk, index) => {
        if (chunk.kind === "root") {
          if (chunk.blocks.length === 0) {
            return null;
          }

          return (
            <section key={`root-${index}`} className="pb-root">
              <div className="pb-root-inner">
                {chunk.blocks.map((b) => (
                  <div key={b.id}>{renderBlock(b)}</div>
                ))}
              </div>
            </section>
          );
        }

        return renderSection(chunk.section);
      })}
    </div>
  );
}
