/* ============================================================
   category-sample-library.jsx — 카테고리 샘플 DB 탐색·직접 선택
   SSOT: data/cj-catalog.json (런타임). data/category-sample-index.json 은 legacy ZIP 샘플용.
   ============================================================ */

// 리스트/그리드 카드 전용 CDN 160px WebP 썸네일.
// edit·담기·누끼·onUseSample 경로에는 절대 사용하지 않는다.
function listThumbUrl(imageOrProduct) {
  const sourceUrl =
    imageOrProduct.sourceUrl ||
    (imageOrProduct.images && imageOrProduct.images[0] && imageOrProduct.images[0].sourceUrl);
  if (sourceUrl) {
    const base = sourceUrl.split('?')[0];
    if (/fit-in\/\d+x\d+\//.test(base)) {
      return base.replace(/fit-in\/\d+x\d+\//, 'fit-in/160x160/filters:format(webp)/');
    }
    if (/\/unsafe\/\d+x\d+\//.test(base)) {
      return base.replace(/\/unsafe\/\d+x\d+\//, '/unsafe/fit-in/160x160/filters:format(webp)/');
    }
    return base;
  }
  return imageOrProduct.path || imageOrProduct.primaryImage || '';
}

/* 대>중>소 분류 경로. 마지막 항목만 굵게. */
function taxonomyParts(taxonomy) {
  const path = taxonomy && Array.isArray(taxonomy.path) ? taxonomy.path : [];
  return {
    large: path[0] || (taxonomy && taxonomy.large) || '',
    middle: path[1] || (taxonomy && taxonomy.middle) || '',
    small: path[2] || (taxonomy && taxonomy.small) || ''
  };
}

function SampleBreadcrumb({ taxonomy, label }) {
  const parts = [taxonomyParts(taxonomy).large, taxonomyParts(taxonomy).middle, taxonomyParts(taxonomy).small].filter(Boolean);
  if (!parts.length) return <React.Fragment>{label}</React.Fragment>;
  return (
    <React.Fragment>
      {parts.map((part, index) =>
      <React.Fragment key={part}>
        {index > 0 && <i className="sample-crumb-sep">›</i>}
        {index === parts.length - 1 ? <b>{part}</b> : part}
      </React.Fragment>)}
    </React.Fragment>);
}

function CategorySampleLibrary({ guides, onUseSample, rootCategory = null, subCategory = null, thirdCategories = [], onProductFocus, midFilter = null, onDbLoaded, onClearMidFilter, onNukkiModeChange, extraMidRows = [], extraBrowseRows = [], extraBrowseNode = null, extraBrowseLoading = false, extraBrowseError = '' }) {
  const [db, setDb] = React.useState(null);
  const [detailImagesByMid, setDetailImagesByMid] = React.useState({});
  const [localMergedProducts, setLocalMergedProducts] = React.useState({});
  const detailLoadingRef = React.useRef(new Set());
  // ── sessionStorage 위치 유지 (mount 1회 읽기) ──────────────────────
  const _ssCache = React.useRef(undefined);
  const _getSS = () => {
    if (_ssCache.current !== undefined) return _ssCache.current;
    try {
      const saved = JSON.parse(sessionStorage.getItem('bannerly.assistant.listPage') || 'null');
      _ssCache.current = (
        saved &&
        (saved.rootCategory || null) === (rootCategory || null) &&
        (saved.subCategory || null) === (subCategory || null)
      ) ? saved : null;
    } catch (_) { _ssCache.current = null; }
    return _ssCache.current;
  };
  // 마운트 직후 effect skip — 복원된 page/activeThird를 덮어쓰지 않기 위해
  const _skipSubCat = React.useRef(true);
  const _skipRootQuery = React.useRef(true);
  // ──────────────────────────────────────────────────────────────────
  const [error, setError] = React.useState('');
  const [activeId, setActiveId] = React.useState(null);
  const [focusedId, setFocusedId] = React.useState(null);
  const [selected, setSelected] = React.useState([]);
  const [query, setQuery] = React.useState(() => { const s = _getSS(); return s ? (s.query || '') : ''; });
  const [notice, setNotice] = React.useState('');
  const [activeThird, setActiveThird] = React.useState(() => { const s = _getSS(); return s ? (s.activeThird || null) : null; });
  const [page, setPage] = React.useState(() => { const s = _getSS(); return s ? (s.page || 1) : 1; });
  const [pageSize, setPageSize] = React.useState(() => { const s = _getSS(); return s ? (s.pageSize || 20) : 20; });
  const [jumpInput, setJumpInput] = React.useState('');
  const gridRef = React.useRef(null);
  const [forcedCat, setForcedCat] = React.useState(null); // 카테고리가 섞였을 때 사용자가 고른 배치 기준
  const [nukkiStage, setNukkiStage] = React.useState(null); // {guide, category, images, fromChat?}
  const [nukkiMarks, setNukkiMarks] = React.useState([]);   // 배경 제거할 이미지 (nukkiImages 기준 인덱스)
  const [nukkiPreview, setNukkiPreview] = React.useState(null); // 확대 보기 인덱스 (숫자 | null)
  const [imagePickStage, setImagePickStage] = React.useState(false); // 채팅에서 이미지 선택 진입 시 탐색 UI 숨김
  const [pickChipMid, setPickChipMid] = React.useState(null); // 이미지 선택 화면에서 열어 둔 상품 칩
  const [pickHoverLock, setPickHoverLock] = React.useState(null); // 방금 지정한 컷 — 호버 오버레이를 잠시 숨김
  const focusedIdRef = React.useRef(null);
  const nukkiCompleteRef = React.useRef(null);
  const nukkiPopupTimerRef = React.useRef(null);
  const confirmedWorkRef = React.useRef(null); // 확정·누끼 완료 이미지. 가운데 담기와 분리한다.
  const lastProductToggleRef = React.useRef({ mid: '', at: 0 });

  React.useEffect(() => { focusedIdRef.current = focusedId; }, [focusedId]);

  const clearNukkiPopupTimer = () => {
    if (nukkiPopupTimerRef.current) {
      window.clearTimeout(nukkiPopupTimerRef.current);
      nukkiPopupTimerRef.current = null;
    }
  };

  // images/marked/onDone/onError 계약을 받아 NukkiPopup 을 직접 여는 인라인 어댑터.
  // window.__nukkiHandoff(nukki-handoff.js)가 로드됐으면 그걸 쓰고,
  // 없으면(master 브랜치 등) NukkiPopup 을 직접 호출해 절대 스톨하지 않는다.
  const callHandoff = React.useCallback((images, marked, onDone, onError) => {
    const handoff = window.__nukkiHandoff;
    if (handoff) { handoff.open({ images, marked, onDone, onError }); return; }
    // ── 인라인 폴백: nukki-handoff.js 없을 때 NukkiPopup 직접 호출 ──
    if (!window.NukkiPopup) { onError && onError('누끼 팝업 모듈이 로드되지 않았습니다'); return; }
    const markedSet = (marked && marked.length) ? marked : images.map((_, i) => i);
    const popupImages = markedSet.map((idx) => {
      const rec = images[idx] || {};
      return { src: rec.path || rec.thumb || rec.url || '', name: String(idx), nukki: true };
    }).filter((im) => !!im.src);
    if (!popupImages.length) { onError && onError('배경 제거할 이미지 경로가 없습니다'); return; }
    window.NukkiPopup.open({
      images: popupImages,
      onComplete: (results) => {
        const replaced = images.map(() => null);
        (results || []).forEach((r) => {
          const idx = parseInt(r.name, 10);
          if (!isNaN(idx) && idx >= 0 && idx < images.length && r.nukki && r.ok && r.dataUrl)
            replaced[idx] = r.dataUrl;
        });
        onDone && onDone(replaced);
        if ((results || []).some((r) => r && r.ok && r.dataUrl) && window.__ga4 && window.__ga4.trackNukki) window.__ga4.trackNukki('asst');
      },
      onCancel: () => { onDone && onDone(images.map(() => null)); }
    });
  }, []);

  // 가운데를 누끼 페이지로 바꾸지 않고, 기존 팝업만 연다.
  const openNukkiPopupForStage = React.useCallback((stage, delay) => {
    clearNukkiPopupTimer();
    const images = (stage && stage.images) || [];
    if (!images.length) return;
    nukkiPopupTimerRef.current = window.setTimeout(() => {
      nukkiPopupTimerRef.current = null;
      callHandoff(
        images,
        images.map((_, index) => index),
        (replaced) => {
          setImagePickStage(false);
          nukkiCompleteRef.current && nukkiCompleteRef.current(stage.guide, replaced, stage);
        },
        (message) => setNotice(message)
      );
    }, typeof delay === 'number' ? delay : 0);
  }, [callHandoff]);

  const releaseFocusForMid = (mid, rows) => {
    const focused = focusedIdRef.current;
    if (!focused || !mid) return;
    const hit = (rows || []).find(({ product }) => product.id === focused);
    if (hit && String(hit.product.mid) === String(mid)) setFocusedId(null);
  };

  React.useEffect(() => {
    if (_skipSubCat.current) { _skipSubCat.current = false; return; }
    setActiveThird(null); setPage(1);
  }, [subCategory]);
  React.useEffect(() => {
    if (_skipRootQuery.current) { _skipRootQuery.current = false; return; }
    setPage(1);
  }, [rootCategory, query]);

  // ── sessionStorage 위치 저장 ────────────────────────────────────────
  React.useEffect(() => {
    try {
      sessionStorage.setItem('bannerly.assistant.listPage', JSON.stringify({
        page,
        pageSize,
        rootCategory: rootCategory || null,
        subCategory: subCategory || null,
        query,
        activeThird: activeThird || null
      }));
    } catch (_) {}
  }, [page, pageSize, rootCategory, subCategory, query, activeThird]);
  // ───────────────────────────────────────────────────────────────────

  // 채팅에서 "사용할 이미지 선택" 후 가운데 본문으로 배경 제거 선택을 이어받는다.
  React.useEffect(() => {
    const onPick = (event) => {
      const detail = event.detail || {};
      if (!detail.guide || !Array.isArray(detail.images) || !detail.images.length) return;
      setNukkiStage({ guide: detail.guide, category: detail.category, images: detail.images, fromChat: true });
      setNukkiMarks([]);
      setNukkiPreview(null);
      setNotice('');
    };
    const onAbort = () => {
      setNukkiStage((prev) => {
        if (!prev || !prev.fromChat) return prev;
        setNukkiMarks([]);
        setNukkiPreview(null);
        return null;
      });
    };
    window.addEventListener('bannerly-nukki-pick', onPick);
    window.addEventListener('bannerly-nukki-pick-abort', onAbort);
    return () => {
      window.removeEventListener('bannerly-nukki-pick', onPick);
      window.removeEventListener('bannerly-nukki-pick-abort', onAbort);
    };
  }, []);

  // 배경 제거·이미지 선택 중에는 좌측 카테고리 패널을 숨긴다.
  React.useEffect(() => {
    onNukkiModeChange && onNukkiModeChange(!!nukkiStage || imagePickStage);
    return () => { onNukkiModeChange && onNukkiModeChange(false); };
  }, [nukkiStage, imagePickStage]);

  React.useEffect(() => {
    const onOpen = () => { if (selected.length) setImagePickStage(true); };
    const onClose = () => setImagePickStage(false);
    window.addEventListener('bannerly-image-pick-open', onOpen);
    window.addEventListener('bannerly-image-pick-close', onClose);
    return () => {
      window.removeEventListener('bannerly-image-pick-open', onOpen);
      window.removeEventListener('bannerly-image-pick-close', onClose);
    };
  }, [selected.length]);

  React.useEffect(() => {
    if (!selected.length) setImagePickStage(false);
  }, [selected.length]);

  React.useEffect(() => {
    if (!imagePickStage) {
      setPickChipMid(null);
      window.dispatchEvent(new CustomEvent('bannerly-image-pick-close'));
      return;
    }
    const mids = [];
    selected.forEach((item) => {
      const key = String(item.mid);
      if (mids.indexOf(key) < 0) mids.push(key);
    });
    setPickChipMid((prev) => (prev && mids.indexOf(prev) >= 0 ? prev : (mids[0] || null)));
  }, [imagePickStage, selected]);

  // 담은 이미지 목록을 채팅 담기 창과 동기화한다.
  React.useEffect(() => {
    const groups = [];
    selected.forEach((item) => {
      const key = String(item.mid);
      let group = groups.find((g) => g.mid === key);
      if (!group) {
        group = { mid: key, productName: item.productName || '상품', brand: item.brand || '', count: 0 };
        groups.push(group);
      }
      group.count += 1;
    });

    const cc = window.__commerceCategory;
    const commerceCats = [];
    if (db && cc && cc.fromRoot) {
      const seen = new Set();
      selected.forEach((item) => {
        const hit = catalogRows.find(({ product }) => String(product.mid) === String(item.mid));
        if (!hit) return;
        const name = cc.fromRoot(taxonomyParts(hit.category.taxonomy).large);
        if (name && !seen.has(name)) { seen.add(name); commerceCats.push(name); }
      });
    }

    window.dispatchEvent(new CustomEvent('bannerly-cart-update', {
      detail: {
        items: selected, groups, total: selected.length,
        mixedCategory: commerceCats.length > 1,
        commerceCats,
        forcedCat
      }
    }));
  }, [selected, db, forcedCat]);

  React.useEffect(() => {
    const onRemove = (event) => {
      const path = event.detail && event.detail.path;
      const mid = event.detail && event.detail.mid;
      if (path) {
        setSelected((prev) => prev.filter((item) => item.path !== path));
        return;
      }
      if (!mid) return;
      setSelected((prev) => prev.filter((item) => String(item.mid) !== String(mid)));
      if (db) releaseFocusForMid(mid, catalogRows);
    };
    const onClear = () => {
      clearNukkiPopupTimer();
      confirmedWorkRef.current = null;
      setSelected([]);
      setForcedCat(null);
      setFocusedId(null);
      setImagePickStage(false);
    };
    const onSetForced = (event) => {
      const name = event.detail && event.detail.name;
      if (name) setForcedCat(name);
    };
    const onStartBanner = (event) => {
      const detail = event.detail || {};
      if (!detail.guide || !detail.category) return;
      // 우선순위: (1) 이벤트에 명시된 items → (2) confirmedWorkRef → (3) 현재 selected
      const tagged =
        (Array.isArray(detail.items) && detail.items.length) ? detail.items :
        (confirmedWorkRef.current && confirmedWorkRef.current.length) ? confirmedWorkRef.current :
        selected.filter((item) => item.placeAs === 'editorial' || item.placeAs === 'nukki');
      if (!tagged.length) return;
      const needsNukki = tagged.filter((item) => item.placeAs === 'nukki' && !item.nukki);
      setImagePickStage(false);
      setNotice('');
      if (!needsNukki.length) {
        onUseSample && onUseSample(detail.guide, tagged, forcedCat);
        return;
      }
      openNukkiPopupForStage({
        guide: detail.guide, category: detail.category,
        images: needsNukki,
        editorial: tagged.filter((item) => item.placeAs === 'editorial' || item.nukki),
        fromChat: true
      }, 0);
    };
    window.addEventListener('bannerly-cart-remove', onRemove);
    window.addEventListener('bannerly-cart-clear', onClear);
    window.addEventListener('bannerly-cart-set-forced-cat', onSetForced);
    window.addEventListener('bannerly-start-banner', onStartBanner);
    return () => {
      window.removeEventListener('bannerly-cart-remove', onRemove);
      window.removeEventListener('bannerly-cart-clear', onClear);
      window.removeEventListener('bannerly-cart-set-forced-cat', onSetForced);
      window.removeEventListener('bannerly-start-banner', onStartBanner);
    };
  }, [selected, db, forcedCat, openNukkiPopupForStage]);

  React.useEffect(() => {
    let alive = true;
    fetch('data/cj-catalog.json?v=gnb-leaf-20260819')
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.json();
      })
      .then((value) => {
        if (!alive) return;
        setDb(value);
        onDbLoaded && onDbLoaded(value); // 채팅의 MID 조회가 같은 DB를 쓰도록 위로 올린다(중복 fetch 방지)
        const first = value.categories && value.categories[0];
        setActiveId(first ? first.id : null);
        setFocusedId(null);
      })
      .catch((reason) => alive && setError(`이미지 데이터베이스를 불러오지 못했습니다: ${reason.message}`));
    return () => { alive = false; };
  }, []);

  React.useEffect(() => {
    if (!db) return;
    const scopedCategories = rootCategory ?
      (db.categories || []).filter((category) => taxonomyParts(category.taxonomy).large === rootCategory) :
      db.categories || [];
    const hasActive = scopedCategories.some((category) => category.id === activeId);
    if (hasActive) return;
    const first = scopedCategories[0];
    setActiveId(first ? first.id : null);
    setFocusedId(null);
  }, [db, rootCategory, activeId]);

  // 채팅/목록에서 진입하면 가운데가 보이도록 스크롤한다.
  React.useEffect(() => {
    if (!nukkiStage) return;
    const timer = window.setTimeout(() => {
      const el = document.querySelector('[data-testid="category-sample-nukki"]');
      if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 0);
    return () => window.clearTimeout(timer);
  }, [nukkiStage]);

  React.useEffect(() => () => clearNukkiPopupTimer(), []);

  if (error) return <div className="sample-db-error" role="alert">{error}</div>;
  if (!db) return <div className="sample-db-loading">등록 이미지 데이터베이스를 불러오는 중…</div>;

  const categories = db.categories || [];
  const baseCatalogRows = categories.flatMap((category) =>
    category.products.map((product) => ({ category, product })));
  const mergeLocalProduct = (row) => {
    const mid = row && row.product && String(row.product.mid);
    const local = mid && localMergedProducts[mid];
    return local ? { ...row, product: { ...row.product, ...local } } : row;
  };
  const extraRows = []
    .concat(Array.isArray(extraMidRows) ? extraMidRows : [])
    .concat(Array.isArray(extraBrowseRows) ? extraBrowseRows : []);
  const catalogRows = (() => {
    const byKey = new Map();
    baseCatalogRows.concat(extraRows).forEach((row) => {
      if (!row || !row.product || !row.category) return;
      const key = `${row.category.id || row.category.categoryId || 'cat'}|${row.product.mid}`;
      byKey.set(key, mergeLocalProduct(row));
    });
    return Array.from(byKey.values());
  })();
  const browseRows = (Array.isArray(extraBrowseRows) ? extraBrowseRows : []).map(mergeLocalProduct);
  const hasExtraBrowseNode = !!(extraBrowseNode && extraBrowseNode.path && extraBrowseNode.path.length);
  const extraBrowsePath = hasExtraBrowseNode ? extraBrowseNode.path.join(' > ') : '';
  const extraBrowseDepth = hasExtraBrowseNode ? extraBrowseNode.path.length : 0;
  const visibleCategories = categories.filter((category) => {
    const taxonomy = category.taxonomy || {};
    const parts = taxonomyParts(taxonomy);
    if (rootCategory && parts.large !== rootCategory) return false;
    if (subCategory && parts.middle !== subCategory) return false;
    if (activeThird && parts.small !== activeThird) return false;
    return true;
  });
  const activeCategory = visibleCategories.find((category) => category.id === activeId) || visibleCategories[0];
  const allVisibleRows = visibleCategories.flatMap((category) => category.products.map((product) => ({ category, product })));
  const normalizedQuery = query.trim().toLowerCase();
  // 채팅에서 MID 를 넣으면 대분류·검색어를 무시하고 전체 DB에서 그 상품들만 보여준다(입력 순서 유지).
  const midRows = (midFilter && midFilter.length) ?
    midFilter.map((mid) => catalogRows.
      find(({ product }) => String(product.mid) === String(mid))).filter(Boolean) :
    null;
  const productRows = midRows ? midRows :
    normalizedQuery ?
    catalogRows.filter(({ category, product }) =>
      `${category.label} ${product.searchText || ''}`.toLowerCase().includes(normalizedQuery)) :
    hasExtraBrowseNode ? browseRows :
    // 대분류를 고르지 않은 '전체'에서는 모든 카테고리의 상품을 한 목록으로 편다.
    (!rootCategory ? allVisibleRows :
    activeCategory ? activeCategory.products.map((product) => ({ category: activeCategory, product })) : []);
  const totalCount = productRows.length;
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
  const currentPage = Math.min(page, totalPages);
  const pagedRows = productRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
  // 기본으로 1~15페이지를 보여 주고, 이후에는 현재·마지막 페이지로 이동할 수 있게 한다.
  const pageList = (() => {
    const visibleFirst = Math.min(15, totalPages);
    const list = Array.from({ length: visibleFirst }, (_, index) => index + 1);
    if (totalPages <= 15) return list;
    if (currentPage > 15) {
      list.push(null);
      if (currentPage < totalPages) list.push(currentPage);
    }
    if (currentPage < totalPages - 1) list.push(null);
    list.push(totalPages);
    return list;
  })();
  const goPage = (p) => {
    setPage(Math.max(1, Math.min(totalPages, p)));
    setFocusedId(null);
    window.requestAnimationFrame(() => {
      if (gridRef.current) gridRef.current.scrollIntoView({ block: 'start', behavior: 'smooth' });
    });
  };
  const doJump = () => {
    const num = parseInt(jumpInput, 10);
    if (!isNaN(num)) goPage(num);
    setJumpInput('');
  };
  const focusedRow = focusedId ?
    productRows.find(({ product }) => product.id === focusedId) || null : null;

  const toSelectionRecord = (image, product, category, placeAs) => ({
    ...image,
    mid: product.mid,
    productName: product.name,
    brand: product.brand,
    categoryId: category.categoryId,
    categoryLabel: category.label,
    categoryRoot: taxonomyParts(category.taxonomy).large, // 편집기 커머스 카테고리 판정용 대분류
    categoryPath: category.taxonomy && category.taxonomy.path,
    sourceCategoryPath: product.sourceCategoryPath || category.label,
    placeAs: placeAs || null, // 'editorial'(화보) | 'nukki'(누끼) — 사용자가 지정. 자동 분류 금지
    imageType: placeAs === 'editorial' ? 'editorial' : placeAs === 'nukki' ? 'nukki' : null
  });
  const toChatProduct = (product, category) => ({
    tag: `상품 · ${product.name}`,
    title: `${product.name}\n${product.brand || '브랜드 미상'} · MID ${product.mid}`,
    sourceGuide: 'data/CJ_banner_LLM_data_lookup_canvas_guide.md',
    // 채팅에서 "만들기" 를 누를 때 그대로 onUseSample 로 넘기는 선택 레코드(그리드 선택과 같은 형식)
    sourceImages: (product.images || []).map((image) => toSelectionRecord(image, product, category)),
    product: {
      mid: String(product.mid),
      product_name: product.name,
      brand: product.brand || null,
      price: product.price,
      benefits: product.benefits ? String(product.benefits).split('@').filter(Boolean) : [],
      review_count: product.reviewCount,
      item_order_type: product.orderType,
      category: {
        id: category.categoryId,
        path: category.label,
        large: taxonomyParts(category.taxonomy).large,
        middle: taxonomyParts(category.taxonomy).middle,
        small: taxonomyParts(category.taxonomy).small
      },
      product_url: product.productUrl,
      crawled_at: product.crawledAt,
      primary_image: {
        order: 1,
        filename: product.images && product.images[0] ? product.images[0].filename : '',
        url: product.primaryImage,
        width: product.images && product.images[0] ? product.images[0].width : 470,
        height: product.images && product.images[0] ? product.images[0].height : 470
      },
      images: (product.images || []).map((image) => ({
        order: image.order,
        filename: image.filename,
        url: image.path,
        width: image.width,
        height: image.height
      }))
    }
  });
  // 담을 수 있는 장수 = 편집기 개수 규칙(유형 중 가장 넉넉한 값). 유형은 담은 뒤에 고르므로 여기서 미리 좁히지 않는다.
  //  기준 카테고리는 먼저 담은 이미지의 대분류(없으면 지금 보고 있는 상품). 규칙을 못 읽으면 제한 없이 담게 둔다.
  const limitRoot = (selected[0] && selected[0].categoryRoot) ||
    (focusedRow && taxonomyParts(focusedRow.category.taxonomy).large) ||
    (productRows[0] && taxonomyParts(productRows[0].category.taxonomy).large) || null;
  const cc = window.__commerceCategory;
  const pickLimit = (cc && cc.maxLimitFor && cc.maxLimitFor(limitRoot)) || null;
  const maxPick = pickLimit ? pickLimit.total : Infinity;

  const selectedOf = (path) => selected.find((item) => item.path === path) || null;

  // 그리드 호버에서 화보/누끼를 사용자가 고른다. 자동 판별하지 않는다.
  const assignPlaceAs = (image, product, category, placeAs) => {
    setNotice('');
    setSelected((prev) => {
      const mid = String(product.mid);
      const next = prev.filter((item) => !(String(item.mid) === mid && !item.placeAs && item.path !== image.path));
      const idx = next.findIndex((item) => item.path === image.path);
      if (idx >= 0) {
        const copy = next.slice();
        copy[idx] = { ...copy[idx], placeAs, imageType: placeAs };
        return copy;
      }
      if (next.length >= maxPick) {
        setNotice(`${limitRoot || '이 카테고리'}는 한 번에 최대 ${maxPick}장까지 담을 수 있습니다.`);
        return prev;
      }
      return [...next, toSelectionRecord(image, product, category, placeAs)];
    });
  };

  const cancelImagePick = () => {
    clearNukkiPopupTimer();
    confirmedWorkRef.current = null;
    window.dispatchEvent(new CustomEvent('bannerly-image-pick-cancel'));
    setSelected([]);
    setForcedCat(null);
    setPickHoverLock(null);
    setNotice('');
    setImagePickStage(false);
  };

  // 최종 선택 X = 화보/누끼 지정만 해제. 담아 둔 상품 자체는 유지한다.
  const removePickedImage = (path) => {
    setSelected((prev) => prev.map((item) =>
      item.path === path ? { ...item, placeAs: null, imageType: null } : item
    ));
  };

  const pickedCountOf = (mid) => selected.filter((item) => String(item.mid) === String(mid)).length;
  const mergeDetailImages = (product, detailRows) => {
    if (!Array.isArray(detailRows) || !detailRows.length) return product;
    const images = detailRows.map((row, index) => ({
      order: index + 1,
      filename: `MID-${product.mid}-${String(index + 1).padStart(3, '0')}`,
      path: row.url,
      sourceUrl: row.url,
      thumb: row.thumbUrl || row.url,
      width: 0,
      height: 0,
      category: row.category,
      rule: row.rule,
      label: row.label
    }));
    return { ...product, images, primaryImage: images[0].path, imageCount: images.length };
  };

  const updateCatalogProduct = (mid, mergedProduct) => {
    setLocalMergedProducts((previous) => ({ ...previous, [String(mid)]: mergedProduct }));
    setDb((previous) => {
      if (!previous || !Array.isArray(previous.categories)) return previous;
      return {
        ...previous,
        categories: previous.categories.map((entry) => ({
          ...entry,
          products: (entry.products || []).map((item) =>
            String(item.mid) === String(mid) ? { ...item, ...mergedProduct } : item)
        }))
      };
    });
  };

  const focusProduct = async (product, category) => {
    setFocusedId(product.id);
    const mid = String(product.mid || '');
    const cached = detailImagesByMid[mid];
    if (cached) {
      const merged = mergeDetailImages(product, cached);
      updateCatalogProduct(mid, merged);
      onProductFocus && onProductFocus(toChatProduct(merged, category));
      return;
    }
    if (!mid || detailLoadingRef.current.has(mid)) {
      onProductFocus && onProductFocus(toChatProduct(product, category));
      return;
    }
    detailLoadingRef.current.add(mid);
    try {
      const response = await fetch(`/api/cj-item-images?mid=${encodeURIComponent(mid)}`);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const payload = await response.json();
      const rows = Array.isArray(payload.images) ? payload.images : [];
      setDetailImagesByMid((prev) => ({ ...prev, [mid]: rows }));
      const merged = mergeDetailImages(product, rows);
      updateCatalogProduct(mid, merged);
      onProductFocus && onProductFocus(toChatProduct(merged, category));
      setNotice(rows.length > 1 ? `MID ${mid}의 연관 이미지 ${rows.length}장을 불러왔습니다.` : '상품 상세 이미지를 확인했습니다.');
    } catch (error) {
      onProductFocus && onProductFocus(toChatProduct(product, category));
      setNotice(`연관 이미지를 불러오지 못했습니다: ${error.message}`);
    } finally {
      detailLoadingRef.current.delete(mid);
    }
  };
  // 담기 = 대표컷 1장 담기. 이미 담긴 상품이면 그 상품의 이미지를 모두 뺀다.
  const toggleProduct = (product, category) => {
    const now = Date.now();
    const lastToggle = lastProductToggleRef.current;
    if (lastToggle.mid === String(product.mid) && now - lastToggle.at < 400) return;
    lastProductToggleRef.current = { mid: String(product.mid), at: now };
    if (confirmedWorkRef.current) {
      if (!window.confirm('지금까지의 작업을 리셋하고 새로 시작하시겠습니까?')) return;
      confirmedWorkRef.current = null;
      clearNukkiPopupTimer();
      setSelected([]);
      setForcedCat(null);
      setImagePickStage(false);
      window.dispatchEvent(new CustomEvent('bannerly-work-reset'));
    }
    setNotice('');
    if (pickedCountOf(product.mid)) {
      setSelected((prev) => prev.filter((item) => String(item.mid) !== String(product.mid)));
      setFocusedId((id) => id === product.id ? null : id);
      return;
    }
    const first = (product.images || [])[0];
    if (!first) return;
    setSelected((prev) => {
      if (prev.length >= maxPick) {
        setNotice(`${limitRoot || '이 카테고리'}는 한 번에 최대 ${maxPick}장까지 담을 수 있습니다.`);
        return prev;
      }
      return [...prev, toSelectionRecord(first, product, category)];
    });
  };

  // 담은 상품 묶음 — 담은 순서 유지. 이미지 선택창을 상품별 줄로 나누는 데 쓴다.
  const pickedGroups = [];
  selected.forEach((item) => {
    const key = String(item.mid);
    let group = pickedGroups.find((g) => g.mid === key);
    if (!group) {
      const hit = catalogRows.find(({ product }) => String(product.mid) === key);
      if (!hit) return;
      group = { mid: key, product: hit.product, category: hit.category, count: 0 };
      pickedGroups.push(group);
    }
    group.count += 1;
  });
  // 담은 상품만 이미지 선택창에 표시한다.
  const pickerGroups = pickedGroups;

  const pickedCommerceCats = cc && cc.fromRoot ?
      pickedGroups.map((g) => cc.fromRoot(taxonomyParts(g.category.taxonomy).large)).
      filter((name, index, all) => name && all.indexOf(name) === index) : [];
  const mixedCategory = pickedCommerceCats.length > 1;

  const confirmImagePick = () => {
    if (mixedCategory && !forcedCat) {
      setNotice('배치 기준 카테고리를 먼저 골라주세요.');
      return;
    }
    const tagged = selected.filter((item) => item.placeAs === 'editorial' || item.placeAs === 'nukki');
    if (!tagged.length) {
      setNotice('이미지에 올려 화보 또는 누끼로 지정해 주세요.');
      return;
    }
    const nukkiOnes = tagged.filter((item) => item.placeAs === 'nukki' && !item.nukki);
    const editorialOnes = tagged.filter((item) => item.placeAs === 'editorial' || item.nukki);
    window.dispatchEvent(new CustomEvent('bannerly-image-pick-confirm', {
      detail: {
        needsNukki: nukkiOnes.length > 0,
        items: tagged.map((item) => ({
          path: item.path,
          thumb: item.thumb || item.path,
          productName: item.productName || '상품',
          placeAs: item.placeAs
        }))
      }
    }));
    if (nukkiOnes.length) {
      openNukkiPopupForStage({
        images: nukkiOnes,
        editorial: editorialOnes,
        fromChat: true,
        awaitType: true
      }, 2800);
      return;
    }
    confirmedWorkRef.current = tagged;
    setSelected([]);
    setForcedCat(null);
    setImagePickStage(false);
  };

  const nukkiImages = (nukkiStage && nukkiStage.images) || selected;

  const clearNukkiStage = () => {
    setNukkiStage(null);
    setNukkiMarks([]);
    setNukkiPreview(null);
  };

  // 편집기로 인계. replaced 가 있으면 그 인덱스의 이미지를 배경 제거 결과로 바꿔 보낸다(원본 대신).
  const handOff = (guide, replaced, stageOverride) => {
    const stage = stageOverride || nukkiStage;
    const source = (stage && stage.images) || nukkiImages;
    const nukkiDone = source.map((record, index) => {
      const url = replaced && replaced[index];
      return {
        ...record,
        originalPath: record.originalPath || record.path,
        path: url || record.path,
        thumb: url || record.thumb || record.path,
        nukki: !!url,
        placeAs: 'nukki',
        imageType: 'nukki'
      };
    });
    const editorial = (stage && stage.editorial) || [];
    const images = editorial.concat(nukkiDone);
    const nukkiCount = images.filter((image) => image.nukki || image.placeAs === 'nukki').length;
    const category = stage && stage.category;
    const summary = `이미지 ${images.length}장${nukkiCount ? ` (누끼 ${nukkiCount}장)` : ''}으로 ${category} 편집기를 엽니다.`;
    const fromChat = !!(stage && stage.fromChat);
    const awaitType = !!(stage && stage.awaitType);
    clearNukkiStage();
    if (awaitType) {
      confirmedWorkRef.current = images;
      setSelected([]);
      setForcedCat(null);
      setImagePickStage(false);
      window.dispatchEvent(new CustomEvent('bannerly-nukki-pick-result', {
        detail: {
          status: 'done',
          awaitType: true,
          items: images.map((item) => ({
            path: item.path,
            originalPath: item.originalPath || item.path,
            thumb: item.nukki ? item.path : (item.thumb || item.path),
            productName: item.productName || '상품',
            placeAs: item.placeAs,
            nukki: !!item.nukki
          }))
        }
      }));
      return;
    }
    if (fromChat) window.dispatchEvent(new CustomEvent('bannerly-nukki-pick-result', { detail: { status: 'done', summary } }));
    onUseSample && onUseSample(guide, images, forcedCat);
  };
  nukkiCompleteRef.current = handOff;

  const cancelNukkiStage = () => {
    const fromChat = !!(nukkiStage && nukkiStage.fromChat);
    const awaitType = !!(nukkiStage && nukkiStage.awaitType);
    clearNukkiStage();
    setImagePickStage(true);
    if (awaitType) window.dispatchEvent(new CustomEvent('bannerly-image-pick-open'));
    if (fromChat) window.dispatchEvent(new CustomEvent('bannerly-nukki-pick-result', {
      detail: { status: 'cancel', awaitType }
    }));
  };

  const openNukkiTool = () => {
    const images = (nukkiStage && nukkiStage.images) || nukkiImages;
    const marked = nukkiMarks.length ? nukkiMarks : images.map((_, index) => index);
    callHandoff(
      images,
      marked,
      (replaced) => handOff(nukkiStage && nukkiStage.guide, replaced, nukkiStage),
      (message) => setNotice(message)
    );
  };

  const toggleNukkiMark = (index) => {
    setNukkiMarks((prev) => prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]);
  };

  // 상품 그룹 단위로 배경 제거 대상을 한꺼번에 켜거나 끈다.
  const toggleNukkiGroup = (indices) => {
    setNukkiMarks((prev) => {
      const allOn = indices.every((index) => prev.includes(index));
      if (allOn) return prev.filter((index) => !indices.includes(index));
      const next = new Set(prev);
      indices.forEach((index) => next.add(index));
      return Array.from(next).sort((a, b) => a - b);
    });
  };

  // 배경 제거 선택 모드 — 등록 이미지 탐색 UI를 숨기고 본문 전체를 큰 선택 화면으로 쓴다.
  if (nukkiStage) {
    const previewImage = nukkiPreview != null ? nukkiImages[nukkiPreview] : null;
    // 어떤 상품 컷인지 헷갈리지 않게 담은 순서 기준 상품별 그룹으로 나눈다.
    const nukkiGroups = [];
    nukkiImages.forEach((image, index) => {
      const key = String(image.mid || image.productName || index);
      let group = nukkiGroups.find((g) => g.key === key);
      if (!group) {
        group = {
          key,
          productName: image.productName || '상품명 없음',
          brand: image.brand || '',
          mid: image.mid || '',
          items: []
        };
        nukkiGroups.push(group);
      }
      group.items.push({ image, index });
    });
    return (
      <section className="sample-db sample-db-nukki" aria-labelledby="sample-nukki-title" data-testid="category-sample-nukki">
        <div className="sample-nukki-page-head">
          <button type="button" className="sample-nukki-back" onClick={cancelNukkiStage} aria-label="뒤로">
            <S2Icon name="chevronRight" size={20} style={{ transform: 'rotate(180deg)' }} />
          </button>
          <div className="sample-nukki-page-titles">
            <h2 id="sample-nukki-title">배경 제거할 이미지 선택</h2>
            <p>카드 클릭으로 선택 · 돋보기로 크게 보기</p>
          </div>
          <span className="sample-nukki-page-count">{nukkiMarks.length}/{nukkiImages.length}장 선택</span>
        </div>

        <div className="sample-nukki-page-groups">
          {nukkiGroups.map((group) => {
            const indices = group.items.map((item) => item.index);
            const selectedInGroup = indices.filter((index) => nukkiMarks.includes(index)).length;
            const allOn = selectedInGroup === indices.length;
            return (
            <div key={group.key} className="sample-nukki-group">
              <div className="sample-nukki-group-head">
                <div className="sample-nukki-group-title">
                  <b title={group.productName}>{group.productName}</b>
                  <span>
                    {[group.brand, group.mid ? `MID ${group.mid}` : '', `${group.items.length}장`].filter(Boolean).join(' · ')}
                    {selectedInGroup ? ` · ${selectedInGroup}장 선택` : ''}
                  </span>
                </div>
                <button type="button" className={`sample-nukki-group-all${allOn ? ' on' : ''}`}
                  onClick={() => toggleNukkiGroup(indices)}>
                  {allOn ? '이 상품 선택 해제' : '이 상품 전체 선택'}
                </button>
              </div>
              <div className="sample-nukki-page-grid">
                {group.items.map(({ image, index }) => {
                  const on = nukkiMarks.includes(index);
                  return (
                    <div key={image.path || index} className={`sample-nukki-card${on ? ' on' : ''}`}
                      role="button" tabIndex={0} aria-pressed={on}
                      title={`${group.productName} — 클릭해 ${on ? '선택 해제' : '선택'}`}
                      onClick={() => toggleNukkiMark(index)}
                      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleNukkiMark(index); } }}>
                      <button type="button" className="sample-nukki-card-main" aria-hidden="true" tabIndex={-1}>
                        <img src={image.path || image.thumb} alt="" loading="lazy" />
                        {on && <span className="sample-image-check"><S2Icon name="check" size={14} strokeWidth={3} /></span>}
                      </button>
                      <button type="button" className="sample-nukki-zoom" title="크게 보기"
                        onClick={(e) => { e.stopPropagation(); setNukkiPreview(index); }} aria-label="크게 보기">
                        <S2Icon name="search" size={14} />
                      </button>
                      {image.width ?
                      <small>{image.width}×{image.height}</small> :
                      <small>{image.order != null ? `${image.order}번 컷` : '이미지'}</small>}
                    </div>);
                })}
              </div>
            </div>);
          })}
        </div>

        {notice && <div className="sample-nukki-page-notice" role="status">{notice}</div>}

        <div className="sample-nukki-page-actions">
          <button type="button" className="primary" disabled={!nukkiMarks.length} onClick={openNukkiTool}>
            {nukkiMarks.length ? `${nukkiMarks.length}장 배경 제거 편집기 열기` : '배경 제거할 이미지를 선택해 주세요'}
          </button>
          <button type="button" onClick={() => handOff(nukkiStage.guide, null)}>
            {nukkiStage.awaitType ? '배경 제거 없이 완료' : '배경 제거 없이 만들기'}
          </button>
        </div>

        {previewImage &&
        <div className="sample-nukki-lightbox" role="dialog" aria-modal="true" aria-label="이미지 확대">
          <button type="button" className="sample-nukki-lightbox-backdrop" aria-label="닫기" onClick={() => setNukkiPreview(null)} />
          <div className="sample-nukki-lightbox-body">
            <div className="sample-nukki-lightbox-meta">
              <b>{previewImage.productName || '상품명 없음'}</b>
              <span>{[previewImage.brand, previewImage.mid ? `MID ${previewImage.mid}` : ''].filter(Boolean).join(' · ')}</span>
            </div>
            <img src={previewImage.path || previewImage.thumb} alt={previewImage.productName || ''} />
            <div className="sample-nukki-lightbox-bar">
              <button type="button" className={nukkiMarks.includes(nukkiPreview) ? 'on' : ''}
                onClick={() => toggleNukkiMark(nukkiPreview)}>
                {nukkiMarks.includes(nukkiPreview) ? '선택 해제' : '이 장 배경 제거'}
              </button>
              <button type="button" onClick={() => setNukkiPreview(null)}>닫기</button>
            </div>
          </div>
        </div>}
      </section>
    );
  }

  const taggedSelected = selected.filter((item) => item.placeAs === 'editorial' || item.placeAs === 'nukki');
  const rolesReady = taggedSelected.length > 0;
  const activePickGroup = pickerGroups.find((group) => group.mid === pickChipMid) || pickerGroups[0] || null;
  const pickImages = (activePickGroup && activePickGroup.product.images) || [];

  const renderImagePicker = () =>
    <div className="sample-image-picker" data-testid="category-sample-images">
      <div className="sample-image-picker-head">
        <div><b>사용할 이미지 선택</b><span>상품 칩을 고른 뒤, 이미지에 올려 화보 또는 누끼로 지정하세요</span></div>
        <span>최종 {taggedSelected.length}장</span>
      </div>
      {notice &&
      <p className="sample-image-notice" role="status">{notice}</p>}

      <div className="sample-pick-chips" role="tablist" aria-label="담은 상품">
        {pickerGroups.map((group) => {
          const tagged = selected.filter((item) => String(item.mid) === group.mid && (item.placeAs === 'editorial' || item.placeAs === 'nukki'));
          const on = activePickGroup && activePickGroup.mid === group.mid;
          return (
            <button key={group.mid} type="button" role="tab" aria-selected={!!on}
              className={`sample-pick-chip${on ? ' on' : ''}`}
              onClick={() => { setPickChipMid(group.mid); setPickHoverLock(null); }}>
              <span className="sample-pick-chip-meta">
                <b title={group.product.name}>{group.product.name}</b>
                <span>{tagged.length ? `${tagged.length}장 지정` : '미지정'}</span>
              </span>
            </button>);
        })}
      </div>

      {activePickGroup &&
      <div className="sample-pick-grid" aria-label={`${activePickGroup.product.name} 이미지`}>
        {pickImages.map((image) => {
          const hit = selectedOf(image.path);
          const role = hit && hit.placeAs;
          const lockHover = pickHoverLock === image.path;
          return (
            <div key={image.id + image.filename}
              className={`sample-pick-card${role ? ' picked' : ''}${lockHover ? ' just-picked' : ''}`}
              onPointerLeave={() => { if (lockHover) setPickHoverLock(null); }}>
              <img src={listThumbUrl(image)} alt={`${activePickGroup.product.name} 이미지 ${image.order}`} loading="lazy" />
              {role === 'editorial' && <span className="sample-image-role">화보</span>}
              {role === 'nukki' && <span className="sample-image-role">누끼</span>}
              <div className="sample-pick-card-actions">
                <button type="button" className={role === 'editorial' ? 'on' : ''}
                  onClick={(event) => {
                    assignPlaceAs(image, activePickGroup.product, activePickGroup.category, 'editorial');
                    setPickHoverLock(image.path);
                    event.currentTarget.blur();
                  }}>
                  화보
                </button>
                <button type="button" className={role === 'nukki' ? 'on' : ''}
                  onClick={(event) => {
                    assignPlaceAs(image, activePickGroup.product, activePickGroup.category, 'nukki');
                    setPickHoverLock(image.path);
                    event.currentTarget.blur();
                  }}>
                  누끼
                </button>
              </div>
            </div>);
        })}
      </div>}

      <div className="sample-pick-final">
        <div className="sample-pick-final-head">
          <b>최종 선택 {taggedSelected.length}장</b>
          <span>{taggedSelected.length ? '올려서 X로 빼기' : '아직 지정한 이미지가 없습니다'}</span>
        </div>
        {taggedSelected.length > 0 &&
        <div className="sample-pick-final-list">
          {taggedSelected.map((item) =>
            <div key={item.path} className="sample-pick-final-item">
              <img src={listThumbUrl(item)} alt={item.productName || ''} />
              <em>{item.placeAs === 'editorial' ? '화보' : '누끼'}</em>
              <button type="button" className="chatp-pick-remove" aria-label="빼기"
                onClick={() => removePickedImage(item.path)}>
                <S2Icon name="x" size={12} />
              </button>
            </div>)}
        </div>}
        <div className="sample-image-picker-foot">
          {mixedCategory &&
          <div className="sample-cat-ask">
            <span>카테고리가 섞여 있습니다. 어느 기준으로 배치할까요?</span>
            <div>
              {pickedCommerceCats.map((name) =>
                <button key={name} type="button" className={forcedCat === name ? 'on' : ''}
                  onClick={() => setForcedCat(name)}>{name}</button>)}
            </div>
          </div>}
          <div className="sample-actions">
            <button type="button"
              disabled={!rolesReady || (mixedCategory && !forcedCat)}
              onClick={confirmImagePick}>
              선택 확정하기
            </button>
          </div>
        </div>
      </div>
    </div>;

  if (imagePickStage && pickedGroups.length > 0) {
    return (
      <section className="sample-db sample-db-image-pick" aria-labelledby="sample-image-pick-title" data-testid="category-sample-library">
        <div className="sample-nukki-page-head">
          <button type="button" className="sample-nukki-back" onClick={cancelImagePick} aria-label="선택 취소">
            <S2Icon name="chevronRight" size={20} style={{ transform: 'rotate(180deg)' }} />
          </button>
          <div className="sample-nukki-page-titles">
            <h2 id="sample-image-pick-title">사용할 이미지 선택</h2>
            <p>담은 상품 {pickedGroups.length}개 · {selected.length}장</p>
          </div>
        </div>
        {renderImagePicker()}
      </section>
    );
  }

  return (
    <section className="sample-db" aria-labelledby="sample-db-title" data-testid="category-sample-library">
      <div className="sample-db-head">
        <div>
          <h2 id="sample-db-title">등록 이미지에서 선택</h2>
          <p>카테고리와 상품 이미지를 골라 바로 배너 만들기를 시작하세요.</p>
        </div>
      </div>

      <label className="sample-db-search">
        <S2Icon name="search" size={15} />
        <input value={query} onChange={(event) => { setQuery(event.target.value); setFocusedId(null); }} placeholder="대·중·소·세·브랜드·상품명·MID 검색" />
      </label>

      {!hasExtraBrowseNode && thirdCategories.length > 0 &&
      <div className="sample-cat-list" aria-label="3차 카테고리">
        {thirdCategories.map((name) =>
          <button key={name} className={`sample-cat${activeThird === name ? ' on' : ''}`}
            onClick={() => { setActiveThird(activeThird === name ? null : name); setPage(1); }}>
            <span><b>{name}</b></span>
          </button>)}
      </div>}

      <div className="sample-db-section-title">
        <span>{midRows ? 'MID 검색 결과' : normalizedQuery ? `검색 결과` : hasExtraBrowseNode ? extraBrowsePath : !rootCategory ? '전체' : activeCategory ? <SampleBreadcrumb taxonomy={activeCategory.taxonomy} label={activeCategory.label} /> : `${rootCategory} 등록 이미지`}</span>
        <small className="sample-db-total">총 <b>{totalCount}</b>건</small>
        {midRows &&
        <React.Fragment>
          <button type="button" className="sample-mid-clear sample-mid-addall"
            onClick={() => midRows.forEach(({ product, category }) => { if (!pickedCountOf(product.mid)) toggleProduct(product, category); })}>
            검색 결과 전체 담기
          </button>
          <button type="button" className="sample-mid-clear" onClick={() => onClearMidFilter && onClearMidFilter()}>전체 보기</button>
        </React.Fragment>}
        <label className="sample-db-pagesize">
          페이지당
          <select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPage(1); }}>
            {[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n}개</option>)}
          </select>
        </label>
      </div>

      {extraBrowseLoading &&
        <div className="sample-empty-state">상품속성 카테고리 상품을 불러오는 중입니다.</div>}

      {extraBrowseError &&
        <div className="sample-empty-state">{extraBrowseError}</div>}

      {!extraBrowseLoading && !extraBrowseError && !totalCount &&
        <div className="sample-empty-state">
          {hasExtraBrowseNode && extraBrowseDepth < 4 ? '세 단계까지 선택하면 해당 상품을 표시합니다. 브랜드까지 선택하면 더 좁혀 볼 수 있습니다.' :
          hasExtraBrowseNode ? `${extraBrowsePath}에 연결할 신규 상품이 없습니다.` :
          rootCategory ? `${rootCategory}에 등록된 상품 이미지가 아직 없습니다.` : '등록된 상품 이미지가 없습니다.'}
        </div>}

      {/* 카드 = 클릭하면 아래에서 이미지를 고르는 대상(포커스). 카드 안의 '담기'는 대표컷 1장을 바로 담는다.
          버튼 중첩은 잘못된 마크업이므로 카드는 div + role=button 으로 둔다. */}
      <div className="sample-product-grid" ref={gridRef}>
        {pagedRows.map(({ category, product }) => {
          const picked = pickedCountOf(product.mid);
          return (
            <div key={`${category.id}-${product.id}`} role="button" tabIndex={0} title={product.name}
              className={`sample-product${focusedId === product.id ? ' on' : ''}${picked ? ' picked' : ''}`}
              onClick={() => focusProduct(product, category)}
              onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); focusProduct(product, category); } }}>
              <span className="sample-product-image">
                <img src={listThumbUrl(product)} alt={product.name} loading="lazy"
                  onError={(event) => {
                    const image = event.currentTarget;
                    image.hidden = true;
                    if (image.parentElement) image.parentElement.classList.add('is-error');
                  }} />
                <span className="sample-product-image-fallback" role="status">미리보기 없음</span>
                {(product.images || []).length > 1 &&
                <span className="sample-product-image-strip" aria-label={`${product.images.length}개 상품 이미지`}>
                  {product.images.slice(1, 7).map((image, index) =>
                    <img key={image.filename || image.path || index} src={image.thumb || image.path || image.sourceUrl}
                      alt="" loading="lazy" onError={(event) => { event.currentTarget.parentElement.removeChild(event.currentTarget); }} />)}
                </span>}
                <em>{product.imageCount || (product.images || []).length || 0}장</em>
              </span>
              <span className="sample-product-name">{product.name}</span>
              <span className="sample-product-meta">{product.brand || '브랜드 미상'} · MID {product.mid}</span>
              {product.sourceCategoryPath &&
              <span className="sample-product-path">{product.sourceCategoryPath}</span>}
              <button type="button" className={`sample-product-add${picked ? ' on' : ''}`}
                onClick={(event) => { event.stopPropagation(); toggleProduct(product, category); }}>
                {picked ? '빼기' : '+ 담기'}
              </button>
            </div>);
        })}
      </div>

      {totalPages > 1 &&
      <nav className="sample-pagination" aria-label="상품 목록 페이지">
        <button className="sample-page-nav" onClick={() => goPage(currentPage - 1)} disabled={currentPage <= 1} aria-label="이전 페이지">
          <S2Icon name="chevronRight" size={15} style={{ transform: 'rotate(180deg)' }} />
        </button>
        {pageList.map((p, index) =>
          p === null ?
          <button key={`gap${index}`} type="button" className="sample-page-gap"
            onClick={() => goPage(index < pageList.length / 2 ? currentPage - 10 : currentPage + 10)}
            title={index < pageList.length / 2 ? '10페이지 이전으로 이동' : '10페이지 이후로 이동'}>
            ···
          </button> :
          <button key={p} className={`sample-page${p === currentPage ? ' on' : ''}`} onClick={() => goPage(p)} aria-current={p === currentPage ? 'page' : undefined}>{p}</button>)}
        <button className="sample-page-nav" onClick={() => goPage(currentPage + 1)} disabled={currentPage >= totalPages} aria-label="다음 페이지">
          <S2Icon name="chevronRight" size={15} />
        </button>
        {totalPages > 7 &&
        <span className="sample-page-jump">
          <input
            type="number" min="1" max={totalPages}
            value={jumpInput}
            onChange={(e) => setJumpInput(e.target.value)}
            onKeyDown={(e) => { if (e.key === 'Enter') { doJump(); e.preventDefault(); } }}
            placeholder="페이지"
            aria-label={`페이지 번호 입력 (1–${totalPages})`}
          />
          <button type="button" onClick={doJump}>이동</button>
        </span>}
      </nav>}

    </section>
  );
}

window.CategorySampleLibrary = CategorySampleLibrary;
