// Phase 2 KI-005 + strict-review fix #4 (KI-013 Slice A):
// editor UI for `clb/product-grid`.
//
// Two complementary panels feed the same `productIds` attribute:
//
//   1. "Search providers" panel (KI-013 Slice A) — provider tab
//      strip (Amazon / 楽天市場 / Yahoo!ショッピング) + keyword
//      input that calls `GET /clb/v1/products/search` and exposes
//      a per-row "Save & Add" affordance which POSTs the row to
//      `/clb/v1/products` (or skips the POST when `is_saved`) and
//      appends the persisted product id to `productIds`. Mirrors
//      the search panel landed in `clb/product-card`'s editor in
//      commit 7ffa110 so editors get a consistent provider-search
//      surface across both blocks.
//
//   2. "Saved products" panel (Phase 10 KI-005, retained) — REST-
//      backed autocomplete that hits `GET /clb/v1/products?search=...`
//      to enumerate already-saved products. Useful when the
//      product is already in the catalog (e.g. saved via
//      product-card or via the wp-admin Add New page tracked by
//      KI-014).
//
// Selected ids are materialized below both panels as removable
// chips that the editor can reorder with up / down buttons.
//
// Rendered entirely through `wp.element.createElement()` so the
// block ships without a JSX build step. The script depends on the
// canonical WordPress globals (`wp.blocks`, `wp.element`,
// `wp.apiFetch`, `wp.i18n`); it short-circuits on environments
// where any of them are missing so the bootstrap remains safe to
// load on legacy editors.
(function () {
  if (typeof window === "undefined" || !window.wp) {
    return;
  }

  var wp = window.wp;
  var blocks = wp.blocks;
  var element = wp.element;
  var apiFetch = wp.apiFetch;
  var blockEditor = wp.blockEditor || null;
  var components = wp.components || null;
  var i18n = wp.i18n || {
    __: function (s) {
      return s;
    },
  };

  if (!blocks || !element || typeof blocks.registerBlockType !== "function") {
    return;
  }

  var __ = i18n.__;
  var createElement = element.createElement;
  var useState = element.useState;
  var useEffect = element.useEffect;
  var useCallback = element.useCallback;
  var useRef = element.useRef;
  var Fragment = element.Fragment || "div";

  // Spec §5 テンプレート採用許可セット (Phase 5 P1)。
  // grid template + per-item template を inspector で選択させる。
  var GRID_TEMPLATE_OPTIONS = [
    { value: "grid-default", label: "デフォルトグリッド" },
  ];
  var ITEM_TEMPLATE_OPTIONS = [
    { value: "single-default-card", label: "デフォルトカード" },
    { value: "single-compact-card", label: "コンパクトカード" },
    { value: "single-minimal-cta", label: "ミニマル CTA" },
  ];

  if (typeof useState !== "function" || typeof useEffect !== "function") {
    // Editor host predates the hooks API on wp.element; fall
    // back to the placeholder rather than registering a broken
    // editor experience.
    blocks.registerBlockType("clb/product-grid", {
      edit: function () {
        return createElement(
          "div",
          { className: "clb-product-grid-editor-placeholder" },
          __(
            "Commerce Link Builder / 商品グリッド (レガシーエディター — オートコンプリート UI を利用するには WordPress を更新してください)。",
            "commerce-link-builder",
          ),
        );
      },
      save: function () {
        return null;
      },
    });
    return;
  }

  var SEARCH_DEBOUNCE_MS = 250;
  var SEARCH_PAGE_SIZE = 10;
  var DOMAIN = "commerce-link-builder";

  // Spec §4.1 canonical provider keys (single-provider search
  // surface; cross-provider `'all'` token is intentionally
  // omitted from the editor tab strip).
  var PROVIDERS = [
    { key: "amazon", label: "Amazon" },
    { key: "rakuten", label: "楽天市場" },
    { key: "yahoo_shopping", label: "Yahoo!ショッピング" },
  ];

  /**
   * Pull the search-result list out of a §4.2 success envelope
   * or any of the historical flatter shapes the REST surface has
   * returned across phases. Mirrors the resilient parser used by
   * `clb/product-card`'s editor.
   */
  function extractSearchItems(response) {
    if (response && response.data && Array.isArray(response.data.items)) {
      return response.data.items;
    }
    if (response && Array.isArray(response.items)) {
      return response.items;
    }
    if (response && Array.isArray(response.data)) {
      return response.data;
    }
    if (Array.isArray(response)) {
      return response;
    }
    return [];
  }

  /**
   * Build the §8.4 POST `/clb/v1/products` body for a single
   * search-result row. Returns null when the row lacks a field
   * the spec marks as required (provider / provider_item_id /
   * title / detail_url) so the caller can surface a row-level
   * error instead of issuing a doomed request. Mirrors the
   * helper in `clb/product-card`'s editor.
   */
  function buildProviderSavePayload(row) {
    if (!row || typeof row !== "object") {
      return null;
    }
    var provider = typeof row.provider === "string" ? row.provider : "";
    var providerItemId =
      typeof row.provider_item_id === "string" ? row.provider_item_id : "";
    var title = typeof row.title === "string" ? row.title : "";
    var detailUrl =
      row.links && typeof row.links.detail_url === "string"
        ? row.links.detail_url
        : "";
    if (
      provider === "" ||
      providerItemId === "" ||
      title === "" ||
      detailUrl === ""
    ) {
      return null;
    }
    var snapshot = { title: title, detail_url: detailUrl };
    if (typeof row.brand === "string" && row.brand !== "") {
      snapshot.brand = row.brand;
    }
    if (
      typeof row.image_primary_url === "string" &&
      row.image_primary_url !== ""
    ) {
      snapshot.image_primary_url = row.image_primary_url;
    }
    if (row.price && typeof row.price === "object") {
      var priceFragment = {};
      if (typeof row.price.amount === "string" && row.price.amount !== "") {
        priceFragment.amount = row.price.amount;
      }
      if (
        typeof row.price.currency === "string" &&
        row.price.currency !== "" &&
        row.price.currency !== "UNKNOWN"
      ) {
        priceFragment.currency = row.price.currency;
      }
      if (typeof row.price.label === "string" && row.price.label !== "") {
        priceFragment.label = row.price.label;
      }
      if (priceFragment.amount && priceFragment.currency) {
        snapshot.price = priceFragment;
      }
    }
    return {
      provider: provider,
      provider_item_id: providerItemId,
      snapshot: snapshot,
    };
  }

  /**
   * Extract a positive integer product id from the §4.2 success
   * envelope returned by `POST /clb/v1/products`. Mirrors the
   * helper in `clb/product-card`'s editor.
   */
  function extractSavedId(response) {
    if (response && response.data) {
      var data = response.data;
      if (typeof data.id === "number" && data.id > 0) {
        return data.id;
      }
      if (
        data.product &&
        typeof data.product.id === "number" &&
        data.product.id > 0
      ) {
        return data.product.id;
      }
    }
    if (response && typeof response.id === "number" && response.id > 0) {
      return response.id;
    }
    return null;
  }

  /**
   * Coerce a raw `productIds` attribute into a list of positive
   * integers, dropping malformed entries silently.
   */
  function normalizeIds(raw) {
    if (!Array.isArray(raw)) {
      return [];
    }
    var out = [];
    for (var i = 0; i < raw.length; i++) {
      var v = raw[i];
      var n = parseInt(v, 10);
      if (!isNaN(n) && n > 0) {
        out.push(n);
      }
    }
    return out;
  }

  /**
   * Render a single chip representing a selected product id with
   * up / down / remove controls.
   */
  function renderChip(id, index, total, summary, attributes, onMove, onRemove) {
    return createElement(ChipPreview, {
      key: "chip-" + id + "-" + index,
      id: id,
      index: index,
      total: total,
      summary: summary,
      attributes: attributes,
      onMove: onMove,
      onRemove: onRemove,
    });
  }

  /**
   * Single chip body in the 選択中 list. Fetches the live render
   * HTML via POST /clb/v1/editor/preview using the block's current
   * itemTemplate + show_* attributes so the chip looks identical
   * to the front-end card. Falls back to the label-only display
   * when wp.apiFetch is unavailable or the request fails.
   */
  function ChipPreview(props) {
    var id = props.id;
    var index = props.index;
    var total = props.total;
    var summary = props.summary;
    var attributes = props.attributes || {};
    var onMove = props.onMove;
    var onRemove = props.onRemove;

    var fallbackLabel =
      summary && summary.title ? summary.title + " (#" + id + ")" : "#" + id;

    var template =
      typeof attributes.itemTemplate === "string" &&
      attributes.itemTemplate !== ""
        ? attributes.itemTemplate
        : "single-compact-card";
    var showPrice = attributes.showPrice !== false;
    var showBrand = attributes.showBrand !== false;
    var showDescription = attributes.showDescription === true;
    var showProviderBadge = attributes.showProviderBadge === true;

    var previewState = useState({
      status: "idle",
      html: null,
      error: null,
    });
    var preview = previewState[0];
    var setPreview = previewState[1];

    useEffect(
      function () {
        if (typeof apiFetch !== "function" || !id) {
          return;
        }
        var cancelled = false;
        setPreview({ status: "loading", html: null, error: null });
        apiFetch({
          path: "/clb/v1/editor/preview",
          method: "POST",
          data: {
            kind: "single",
            productIds: [String(id)],
            template: template,
            attrs: {
              show_price: showPrice,
              show_brand: showBrand,
              show_description: showDescription,
              show_provider_badge: showProviderBadge,
            },
          },
        })
          .then(function (response) {
            if (cancelled) {
              return;
            }
            var html =
              response &&
              response.data &&
              typeof response.data.html === "string"
                ? response.data.html
                : null;
            if (html === null || html === "") {
              setPreview({
                status: "error",
                html: null,
                error: __("プレビューHTMLを取得できませんでした。", DOMAIN),
              });
              return;
            }
            setPreview({ status: "ready", html: html, error: null });
          })
          .catch(function (err) {
            if (cancelled) {
              return;
            }
            var msg =
              err && err.message
                ? err.message
                : __("プレビューの取得に失敗しました。", DOMAIN);
            setPreview({ status: "error", html: null, error: msg });
          });
        return function () {
          cancelled = true;
        };
      },
      [id, template, showPrice, showBrand, showDescription, showProviderBadge],
    );

    var body;
    if (preview.status === "ready" && preview.html !== null) {
      body = createElement("div", {
        className: "clb-product-grid-chip-preview",
        style: { flex: "1 1 auto", minWidth: 0 },
        dangerouslySetInnerHTML: { __html: preview.html },
      });
    } else if (preview.status === "loading") {
      body = createElement(
        "div",
        {
          className: "description",
          style: { flex: "1 1 auto", minWidth: 0 },
        },
        __("プレビューを読み込み中…", DOMAIN) + " #" + id,
      );
    } else if (preview.status === "error") {
      body = createElement(
        "div",
        { style: { flex: "1 1 auto", minWidth: 0 } },
        createElement("div", { style: { fontWeight: "600" } }, fallbackLabel),
        createElement(
          "div",
          { className: "description", style: { color: "#b32d2e" } },
          preview.error,
        ),
      );
    } else {
      body = createElement(
        "span",
        {
          style: {
            flex: "1 1 auto",
            overflow: "hidden",
            textOverflow: "ellipsis",
          },
          title: fallbackLabel,
        },
        fallbackLabel,
      );
    }

    return createElement(
      "li",
      {
        className: "clb-product-grid-chip",
        style: {
          display: "flex",
          alignItems: "flex-start",
          gap: "8px",
          padding: "8px",
          marginBottom: "8px",
          border: "1px solid #ddd",
          borderRadius: "3px",
          background: "#fff",
        },
      },
      body,
      createElement(
        "div",
        {
          style: {
            display: "flex",
            flexDirection: "column",
            gap: "4px",
            flex: "0 0 auto",
          },
        },
        createElement(
          "button",
          {
            type: "button",
            className: "button button-small",
            disabled: index === 0,
            onClick: function () {
              onMove(index, index - 1);
            },
            "aria-label": __("上へ移動", DOMAIN),
            title: __("上へ移動", DOMAIN),
          },
          "\u2191",
        ),
        createElement(
          "button",
          {
            type: "button",
            className: "button button-small",
            disabled: index >= total - 1,
            onClick: function () {
              onMove(index, index + 1);
            },
            "aria-label": __("下へ移動", DOMAIN),
            title: __("下へ移動", DOMAIN),
          },
          "\u2193",
        ),
        createElement(
          "button",
          {
            type: "button",
            className: "button button-small button-link-delete",
            onClick: function () {
              onRemove(index);
            },
            "aria-label": __("削除", DOMAIN),
            title: __("削除", DOMAIN),
          },
          "\u2715",
        ),
      ),
    );
  }

  /**
   * Render a single search-result row with an "Add" affordance.
   */
  function renderResult(result, alreadySelected, onAdd) {
    var label =
      (result.title || "#" + result.id) +
      " — " +
      (result.provider || __("不明", DOMAIN));
    var imageUrl =
      typeof result.image_primary_url === "string" &&
      result.image_primary_url !== ""
        ? result.image_primary_url
        : null;

    return createElement(
      "li",
      {
        key: "result-" + result.id,
        className: "clb-product-grid-search-result",
        style: {
          display: "flex",
          alignItems: "flex-start",
          gap: "8px",
          padding: "6px",
          borderBottom: "1px solid #eee",
        },
      },
      imageUrl !== null
        ? createElement("img", {
            src: imageUrl,
            alt: "",
            style: {
              width: "40px",
              height: "40px",
              objectFit: "cover",
              flex: "0 0 auto",
              borderRadius: "3px",
            },
          })
        : createElement("div", {
            style: {
              width: "40px",
              height: "40px",
              background: "#f0f0f0",
              flex: "0 0 auto",
              borderRadius: "3px",
            },
          }),
      createElement(
        "span",
        {
          style: {
            flex: "1 1 auto",
            overflow: "hidden",
            textOverflow: "ellipsis",
          },
          title: label,
        },
        label,
      ),
      createElement(
        "button",
        {
          type: "button",
          className: "button button-small",
          disabled: alreadySelected,
          onClick: function () {
            onAdd(result);
          },
          style: { flex: "0 0 auto" },
        },
        alreadySelected ? __("追加済み", DOMAIN) : __("追加", DOMAIN),
      ),
    );
  }

  /**
   * Hook-driven debounced REST search returning `{ items, status }`.
   *
   *   - status: 'idle' | 'loading' | 'ok' | 'error' | 'unavailable'
   *   - items:  list of `{ id: number, title: string, provider: string }`
   *
   * Falls back to status='unavailable' when wp.apiFetch is missing
   * (typical pure-iframe preview hosts) so the UI can hide the
   * results panel rather than spin forever.
   */
  function useProductSearch(query) {
    var initial = { items: [], status: "idle" };
    var s = useState(initial);
    var state = s[0];
    var setState = s[1];

    useEffect(
      function () {
        var trimmed = (query || "").trim();
        if (trimmed === "") {
          setState({ items: [], status: "idle" });
          return undefined;
        }

        if (!apiFetch || typeof apiFetch !== "function") {
          setState({ items: [], status: "unavailable" });
          return undefined;
        }

        setState({ items: [], status: "loading" });
        var cancelled = false;

        var timer = window.setTimeout(function () {
          var path =
            "/clb/v1/products?search=" +
            encodeURIComponent(trimmed) +
            "&per_page=" +
            SEARCH_PAGE_SIZE;

          apiFetch({ path: path })
            .then(function (response) {
              if (cancelled) {
                return;
              }
              // §4.2 success envelope: { success, data: { items, pagination }, meta }.
              // Fall back to a few alternative shapes so the editor stays
              // resilient if a future REST surface returns a flatter payload.
              var rawItems = [];
              if (
                response &&
                response.data &&
                Array.isArray(response.data.items)
              ) {
                rawItems = response.data.items;
              } else if (response && Array.isArray(response.items)) {
                rawItems = response.items;
              } else if (response && Array.isArray(response.data)) {
                rawItems = response.data;
              } else if (Array.isArray(response)) {
                rawItems = response;
              }

              var items = [];
              for (var i = 0; i < rawItems.length; i++) {
                var row = rawItems[i];
                if (!row) {
                  continue;
                }
                var id = parseInt(row.id, 10);
                if (isNaN(id) || id <= 0) {
                  continue;
                }
                items.push({
                  id: id,
                  title: typeof row.title === "string" ? row.title : "",
                  provider:
                    typeof row.provider === "string" ? row.provider : "",
                  image_primary_url:
                    typeof row.image_primary_url === "string"
                      ? row.image_primary_url
                      : "",
                });
              }

              setState({ items: items, status: "ok" });
            })
            .catch(function () {
              if (cancelled) {
                return;
              }
              setState({ items: [], status: "error" });
            });
        }, SEARCH_DEBOUNCE_MS);

        return function () {
          cancelled = true;
          window.clearTimeout(timer);
        };
      },
      [query],
    );

    return state;
  }

  /**
   * Render the provider-driven search panel (KI-013 Slice A).
   *
   * Owns local search state (provider / query / loading /
   * results / per-row save status) and calls
   * `onAddSavedId(id, summary)` once a row's save round-trip
   * succeeds. Already-selected ids are surfaced through
   * `selectedIds` so the affordance can render `Already added`
   * rather than re-saving the same row.
   */
  function ProviderSearchPanel(props) {
    var onAddSavedId = props.onAddSavedId;
    var selectedIds = Array.isArray(props.selectedIds) ? props.selectedIds : [];

    var providerState = useState(PROVIDERS[0].key);
    var providerKey = providerState[0];
    var setProviderKey = providerState[1];

    var queryState = useState("");
    var query = queryState[0];
    var setQuery = queryState[1];
    var queryInputRef = typeof useRef === "function" ? useRef(null) : null;

    var resultsState = useState({ status: "idle", items: [], error: null });
    var results = resultsState[0];
    var setResults = resultsState[1];

    var rowStatusState = useState({});
    var rowStatus = rowStatusState[0];
    var setRowStatus = rowStatusState[1];

    var apiAvailable = typeof apiFetch === "function";

    useEffect(function () {
      if (
        queryInputRef &&
        queryInputRef.current &&
        typeof queryInputRef.current.focus === "function"
      ) {
        queryInputRef.current.focus();
      }
    }, []);

    var runSearch = useCallback(
      function () {
        if (!apiAvailable) {
          setResults({
            status: "error",
            items: [],
            error: __(
              "REST API クライアント (wp.apiFetch) がこのエディターホストで読み込まれていません。",
              DOMAIN,
            ),
          });
          return;
        }
        var trimmed = (query || "").trim();
        if (trimmed === "") {
          setResults({
            status: "error",
            items: [],
            error: __("キーワードを入力してください。", DOMAIN),
          });
          return;
        }

        setResults({ status: "loading", items: [], error: null });

        var path =
          "/clb/v1/products/search?provider=" +
          encodeURIComponent(providerKey) +
          "&keywords=" +
          encodeURIComponent(trimmed) +
          "&per_page=10";

        apiFetch({ path: path })
          .then(function (response) {
            setResults({
              status: "ok",
              items: extractSearchItems(response),
              error: null,
            });
          })
          .catch(function (err) {
            var message = __(
              "検索に失敗しました。プロバイダーの認証情報が設定されているか、edit_posts 権限があるかを確認してください。",
              DOMAIN,
            );
            if (err && typeof err.message === "string" && err.message !== "") {
              message = err.message;
            } else if (
              err &&
              err.error &&
              typeof err.error.message === "string"
            ) {
              message = err.error.message;
            }
            setResults({ status: "error", items: [], error: message });
          });
      },
      [apiAvailable, providerKey, query],
    );

    var handleSave = useCallback(
      function (row) {
        var rowKey =
          (typeof row.provider === "string" ? row.provider : "?") +
          ":" +
          (typeof row.provider_item_id === "string"
            ? row.provider_item_id
            : "?");

        // Already-saved row: skip the POST, just adopt the id.
        if (row.is_saved && typeof row.id === "number" && row.id > 0) {
          onAddSavedId(row.id, {
            title: typeof row.title === "string" ? row.title : "",
            provider: typeof row.provider === "string" ? row.provider : "",
          });
          return;
        }

        if (!apiAvailable) {
          setRowStatus(function (prev) {
            var next = {};
            for (var k in prev) {
              if (Object.prototype.hasOwnProperty.call(prev, k)) {
                next[k] = prev[k];
              }
            }
            next[rowKey] = {
              status: "error",
              error: __(
                "wp.apiFetch が読み込まれていないため保存できません。",
                DOMAIN,
              ),
            };
            return next;
          });
          return;
        }

        var payload = buildProviderSavePayload(row);
        if (payload === null) {
          setRowStatus(function (prev) {
            var next = {};
            for (var k in prev) {
              if (Object.prototype.hasOwnProperty.call(prev, k)) {
                next[k] = prev[k];
              }
            }
            next[rowKey] = {
              status: "error",
              error: __(
                "この検索結果は必須フィールド (タイトル / 詳細 URL) が欠けているため保存できません。",
                DOMAIN,
              ),
            };
            return next;
          });
          return;
        }

        setRowStatus(function (prev) {
          var next = {};
          for (var k in prev) {
            if (Object.prototype.hasOwnProperty.call(prev, k)) {
              next[k] = prev[k];
            }
          }
          next[rowKey] = { status: "saving", error: null };
          return next;
        });

        apiFetch({ path: "/clb/v1/products", method: "POST", data: payload })
          .then(function (response) {
            var savedId = extractSavedId(response);
            if (savedId === null) {
              setRowStatus(function (prev) {
                var next = {};
                for (var k in prev) {
                  if (Object.prototype.hasOwnProperty.call(prev, k)) {
                    next[k] = prev[k];
                  }
                }
                next[rowKey] = {
                  status: "error",
                  error: __(
                    "保存は成功しましたが、レスポンスに商品 ID が含まれていません。",
                    DOMAIN,
                  ),
                };
                return next;
              });
              return;
            }
            setRowStatus(function (prev) {
              var next = {};
              for (var k in prev) {
                if (Object.prototype.hasOwnProperty.call(prev, k)) {
                  next[k] = prev[k];
                }
              }
              next[rowKey] = { status: "saved", error: null };
              return next;
            });
            onAddSavedId(savedId, {
              title: typeof row.title === "string" ? row.title : "",
              provider: typeof row.provider === "string" ? row.provider : "",
            });
          })
          .catch(function (err) {
            var message = __("保存に失敗しました。", DOMAIN);
            if (err && typeof err.message === "string" && err.message !== "") {
              message = err.message;
            } else if (
              err &&
              err.error &&
              typeof err.error.message === "string"
            ) {
              message = err.error.message;
            }
            setRowStatus(function (prev) {
              var next = {};
              for (var k in prev) {
                if (Object.prototype.hasOwnProperty.call(prev, k)) {
                  next[k] = prev[k];
                }
              }
              next[rowKey] = { status: "error", error: message };
              return next;
            });
          });
      },
      [apiAvailable, onAddSavedId],
    );

    var providerTabs = createElement(
      "div",
      {
        className: "clb-product-grid-providers",
        role: "tablist",
        style: {
          display: "flex",
          gap: "6px",
          marginBottom: "10px",
          flexWrap: "wrap",
          alignItems: "center",
        },
      },
      PROVIDERS.map(function (p) {
        var isActive = p.key === providerKey;
        // Inline style overrides so the active provider is
        // unmistakable even when the WP admin .button-primary /
        // .button-secondary base styles render at low contrast in
        // the editor iframe.
        var activeStyle = {
          background: "#2271b1",
          color: "#fff",
          borderColor: "#2271b1",
          fontWeight: "700",
          boxShadow: "0 0 0 1px #2271b1",
        };
        var inactiveStyle = {
          background: "#f6f7f7",
          color: "#2c3338",
          borderColor: "#dcdcde",
          fontWeight: "500",
        };
        return createElement(
          "button",
          {
            key: "provider-" + p.key,
            type: "button",
            role: "tab",
            "aria-selected": isActive ? "true" : "false",
            className:
              "button " + (isActive ? "button-primary" : "button-secondary"),
            onClick: function () {
              setProviderKey(p.key);
            },
            style: isActive ? activeStyle : inactiveStyle,
            title: isActive
              ? __("現在の検索対象プロバイダー", DOMAIN)
              : __("このプロバイダーに切り替え", DOMAIN),
          },
          (isActive ? "✓ " : "") + p.label,
        );
      }),
    );

    var statusLine = null;
    if (results.status === "loading") {
      statusLine = createElement(
        "p",
        { className: "description" },
        __("検索中…", DOMAIN),
      );
    } else if (results.status === "error" && results.error !== null) {
      statusLine = createElement(
        "p",
        { className: "description", style: { color: "#b32d2e" } },
        results.error,
      );
    } else if (results.status === "ok" && results.items.length === 0) {
      statusLine = createElement(
        "p",
        { className: "description" },
        __("一致する商品が見つかりませんでした。", DOMAIN),
      );
    }

    var resultElements = results.items
      .filter(function (row) {
        // Hide rows that are already in the grid; the chip below
        // already shows them, so re-listing them as '追加済み' is
        // just visual noise.
        return !(
          row.is_saved &&
          typeof row.id === "number" &&
          row.id > 0 &&
          selectedIds.indexOf(row.id) !== -1
        );
      })
      .map(function (row, idx) {
        var rowKey =
          (typeof row.provider === "string" ? row.provider : "?") +
          ":" +
          (typeof row.provider_item_id === "string"
            ? row.provider_item_id
            : "?");
        var status = rowStatus[rowKey] || { status: "idle", error: null };
        var alreadySelected = false;

        var imageUrl =
          typeof row.image_primary_url === "string" &&
          row.image_primary_url !== ""
            ? row.image_primary_url
            : null;
        var title = typeof row.title === "string" ? row.title : "(no title)";
        var brand =
          typeof row.brand === "string" && row.brand !== "" ? row.brand : null;
        var priceLabel =
          row.price &&
          typeof row.price.label === "string" &&
          row.price.label !== ""
            ? row.price.label
            : null;
        var detailUrl =
          row.links && typeof row.links.detail_url === "string"
            ? row.links.detail_url
            : null;

        var saveLabel = __("追加", DOMAIN);
        if (alreadySelected) {
          saveLabel = __("追加済み", DOMAIN);
        } else if (row.is_saved && typeof row.id === "number" && row.id > 0) {
          saveLabel = __("追加 (#" + row.id + ")", DOMAIN);
        } else if (status.status === "saving") {
          saveLabel = __("保存中…", DOMAIN);
        } else if (status.status === "saved") {
          saveLabel = __("追加済み", DOMAIN);
        }

        return createElement(
          "li",
          {
            key: "provider-result-" + idx + "-" + rowKey,
            className: "clb-product-grid-search-result",
            style: {
              display: "flex",
              alignItems: "flex-start",
              gap: "8px",
              padding: "8px",
              borderBottom: "1px solid #eee",
            },
          },
          imageUrl !== null
            ? createElement("img", {
                src: imageUrl,
                alt: "",
                style: {
                  width: "40px",
                  height: "40px",
                  objectFit: "cover",
                  flex: "0 0 auto",
                  borderRadius: "3px",
                },
              })
            : createElement("div", {
                style: {
                  width: "40px",
                  height: "40px",
                  background: "#f0f0f0",
                  flex: "0 0 auto",
                  borderRadius: "3px",
                },
              }),
          createElement(
            "div",
            { style: { flex: "1 1 auto", minWidth: 0 } },
            createElement(
              "div",
              {
                style: {
                  fontWeight: "600",
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                  whiteSpace: "nowrap",
                },
                title: title,
              },
              title,
            ),
            brand !== null
              ? createElement(
                  "div",
                  { className: "description", style: { fontSize: "12px" } },
                  brand,
                )
              : null,
            priceLabel !== null
              ? createElement(
                  "div",
                  { style: { fontSize: "12px", marginTop: "2px" } },
                  priceLabel,
                )
              : null,
            detailUrl !== null
              ? createElement(
                  "div",
                  { style: { fontSize: "11px", marginTop: "2px" } },
                  createElement(
                    "a",
                    {
                      href: detailUrl,
                      target: "_blank",
                      rel: "noopener noreferrer",
                    },
                    __("プロバイダーで表示", DOMAIN),
                  ),
                )
              : null,
            status.status === "error" && status.error !== null
              ? createElement(
                  "div",
                  {
                    className: "description",
                    style: { color: "#b32d2e", marginTop: "4px" },
                  },
                  status.error,
                )
              : null,
          ),
          createElement(
            "button",
            {
              type: "button",
              className: "button button-primary button-small",
              disabled:
                alreadySelected ||
                status.status === "saving" ||
                status.status === "saved",
              onClick: function () {
                handleSave(row);
              },
              style: { flex: "0 0 auto", whiteSpace: "nowrap" },
            },
            saveLabel,
          ),
        );
      });

    return createElement(
      "div",
      {
        className: "clb-product-grid-provider-search",
        style: {
          padding: "12px",
          border: "1px solid #ccd0d4",
          borderRadius: "3px",
          background: "#fff",
          marginBottom: "12px",
        },
      },
      createElement(
        "h5",
        { style: { marginTop: 0 } },
        __("プロバイダー検索", DOMAIN),
      ),
      createElement(
        "p",
        { className: "description" },
        __(
          "ボタンでプロバイダーを選んで検索し、「追加」をクリックしてグリッドに追加してください。",
          DOMAIN,
        ),
      ),
      providerTabs,
      createElement(
        "div",
        {
          style: { display: "flex", gap: "6px", marginBottom: "8px" },
        },
        createElement("input", {
          ref: queryInputRef,
          type: "search",
          className: "regular-text",
          placeholder: __("キーワード…", DOMAIN),
          autoFocus: true,
          spellCheck: false,
          autoCorrect: "off",
          autoCapitalize: "off",
          autoComplete: "off",
          value: query,
          onChange: function (e) {
            setQuery(e.target.value);
          },
          onKeyDown: function (e) {
            if (e.key === "Enter") {
              e.preventDefault();
              runSearch();
            }
          },
          style: { flex: "1 1 auto" },
        }),
        createElement(
          "button",
          {
            type: "button",
            className: "button button-secondary",
            disabled: results.status === "loading",
            onClick: runSearch,
          },
          __("検索", DOMAIN),
        ),
      ),
      statusLine,
      resultElements.length > 0
        ? createElement(
            "ul",
            {
              className: "clb-product-grid-provider-results",
              style: {
                listStyle: "none",
                margin: "0 0 8px 0",
                padding: 0,
                maxHeight: "320px",
                overflowY: "auto",
                border: "1px solid #eee",
                borderRadius: "3px",
              },
            },
            resultElements,
          )
        : null,
      !apiAvailable
        ? createElement(
            "p",
            { className: "description", style: { fontStyle: "italic" } },
            __("このエディターホストでは REST 検索を利用できません。", DOMAIN),
          )
        : null,
    );
  }

  /**
   * Render the Inspector sidebar panel for `clb/product-grid`
   * (KI-015): grid template + per-item template + columns + gap
   * + show-flag toggles.
   */
  function GridInspectorPanel(props) {
    if (!blockEditor || !components) {
      return null;
    }
    var InspectorControls = blockEditor.InspectorControls;
    var PanelBody = components.PanelBody;
    var SelectControl = components.SelectControl;
    var ToggleControl = components.ToggleControl;
    var RangeControl = components.RangeControl;
    if (
      typeof InspectorControls !== "function" ||
      typeof PanelBody !== "function" ||
      typeof SelectControl !== "function" ||
      typeof ToggleControl !== "function"
    ) {
      return null;
    }

    var attributes = props.attributes || {};
    var setAttributes = props.setAttributes || function () {};

    function toggle(attrKey, label, help, defaultOn) {
      return createElement(ToggleControl, {
        key: "toggle-" + attrKey,
        label: label,
        help: help,
        checked:
          typeof attributes[attrKey] === "boolean"
            ? attributes[attrKey]
            : !!defaultOn,
        onChange: function (next) {
          var update = {};
          update[attrKey] = !!next;
          setAttributes(update);
        },
      });
    }

    var columns =
      typeof attributes.columns === "number" && attributes.columns > 0
        ? attributes.columns
        : 3;

    return createElement(
      InspectorControls,
      {},
      createElement(
        PanelBody,
        { title: __("レイアウト", DOMAIN), initialOpen: true },
        createElement(SelectControl, {
          label: __("グリッドテンプレート", DOMAIN),
          value:
            typeof attributes.template === "string" &&
            attributes.template !== ""
              ? attributes.template
              : "grid-default",
          options: GRID_TEMPLATE_OPTIONS,
          onChange: function (next) {
            setAttributes({ template: String(next) });
          },
        }),
        createElement(SelectControl, {
          label: __("1 商品ごとのカードテンプレート", DOMAIN),
          help: __(
            "各グリッドセルは、このカードバリアントで描画されます。",
            DOMAIN,
          ),
          value:
            typeof attributes.itemTemplate === "string" &&
            attributes.itemTemplate !== ""
              ? attributes.itemTemplate
              : "single-compact-card",
          options: ITEM_TEMPLATE_OPTIONS,
          onChange: function (next) {
            setAttributes({ itemTemplate: String(next) });
          },
        }),
        typeof RangeControl === "function"
          ? createElement(RangeControl, {
              label: __("列数", DOMAIN),
              value: columns,
              min: 1,
              max: 6,
              onChange: function (next) {
                var n = parseInt(next, 10);
                if (!isNaN(n) && n >= 1 && n <= 6) {
                  setAttributes({ columns: n });
                }
              },
            })
          : null,
        createElement(SelectControl, {
          label: __("余白", DOMAIN),
          value:
            typeof attributes.gap === "string" && attributes.gap !== ""
              ? attributes.gap
              : "medium",
          options: [
            { value: "none", label: __("なし", DOMAIN) },
            { value: "small", label: __("小", DOMAIN) },
            { value: "medium", label: __("中", DOMAIN) },
            { value: "large", label: __("大", DOMAIN) },
          ],
          onChange: function (next) {
            setAttributes({ gap: String(next) });
          },
        }),
        createElement(SelectControl, {
          label: __("並び順", DOMAIN),
          value:
            typeof attributes.sortMode === "string" &&
            attributes.sortMode !== ""
              ? attributes.sortMode
              : "manual",
          options: [
            { value: "manual", label: __("手動並び替え", DOMAIN) },
            { value: "price_asc", label: __("価格 (安い順)", DOMAIN) },
            { value: "price_desc", label: __("価格 (高い順)", DOMAIN) },
            { value: "recent", label: __("最近更新順", DOMAIN) },
          ],
          onChange: function (next) {
            setAttributes({ sortMode: String(next) });
          },
        }),
      ),
      createElement(
        PanelBody,
        { title: __("表示オプション", DOMAIN), initialOpen: false },
        toggle(
          "showPrice",
          __("価格を表示", DOMAIN),
          __("タイトル下にスナップショットの価格ラベルを表示します。", DOMAIN),
          true,
        ),
        toggle(
          "showBrand",
          __("ブランドを表示", DOMAIN),
          __("タイトル下にブランド名を表示します。", DOMAIN),
          true,
        ),
        toggle(
          "showDescription",
          __("説明を表示", DOMAIN),
          __(
            "スナップショットの説明ブロックを表示します (デフォルトではオフ、カードをコンパクトに保つため)。",
            DOMAIN,
          ),
          false,
        ),
        toggle(
          "showProviderBadge",
          __("プロバイダーバッジを表示", DOMAIN),
          null,
          false,
        ),
        toggle(
          "showDisclaimer",
          __("ディスクレーマーを表示", DOMAIN),
          null,
          true,
        ),
      ),
      createElement(
        PanelBody,
        { title: __("リンク設定", DOMAIN), initialOpen: false },
        toggle(
          "openInNewTab",
          __("新しいタブで開く", DOMAIN),
          __('アフィリエイトリンクに target="_blank" を付与します。', DOMAIN),
          true,
        ),
        toggle("nofollow", __('rel="nofollow" を追加', DOMAIN), null, true),
        toggle("sponsored", __('rel="sponsored" を追加', DOMAIN), null, true),
        toggle(
          "trackingEnabled",
          __("トラッキングを有効化", DOMAIN),
          __(
            "アフィリエイト URL に設定済みのトラッキングパラメータを付与します。",
            DOMAIN,
          ),
          true,
        ),
      ),
    );
  }

  function ProductGridEditor(props) {
    var attributes = props.attributes || {};
    var setAttributes = props.setAttributes || function () {};

    // Gutenberg's `useBlockProps()` returns the props the
    // block-editor needs to attach to the outermost edit() element
    // so the block is selectable / movable / deletable like any
    // core block. Without it the block renders correctly but the
    // toolbar / mover / Delete-key affordances do not engage.
    // Falls back to a plain props bag when wp.blockEditor is
    // unavailable so the surface still renders.
    var useBlockProps =
      blockEditor && typeof blockEditor.useBlockProps === "function"
        ? blockEditor.useBlockProps
        : null;
    var rootProps = useBlockProps
      ? useBlockProps({
          className: "clb-product-grid-editor",
          style: {
            padding: "12px",
            border: "1px solid #ccd0d4",
            borderRadius: "3px",
            background: "#fff",
          },
        })
      : {
          className: "clb-product-grid-editor",
          style: {
            padding: "12px",
            border: "1px solid #ccd0d4",
            borderRadius: "3px",
            background: "#fff",
          },
        };

    var ids = normalizeIds(attributes.productIds);

    var qs = useState("");
    var query = qs[0];
    var setQuery = qs[1];

    // Cache id -> summary so chips can show the title even
    // after the search panel has moved on.
    var ss = useState({});
    var summaries = ss[0];
    var setSummaries = ss[1];

    var search = useProductSearch(query);

    var commitIds = useCallback(
      function (next) {
        var asStrings = [];
        for (var i = 0; i < next.length; i++) {
          asStrings.push(String(next[i]));
        }
        setAttributes({ productIds: asStrings });
      },
      [setAttributes],
    );

    var handleAdd = useCallback(
      function (result) {
        if (!result || !result.id) {
          return;
        }
        var existing = normalizeIds(attributes.productIds);
        for (var i = 0; i < existing.length; i++) {
          if (existing[i] === result.id) {
            return; // already selected
          }
        }
        var next = existing.concat([result.id]);
        commitIds(next);
        setSummaries(function (prev) {
          var copy = {};
          for (var k in prev) {
            if (Object.prototype.hasOwnProperty.call(prev, k)) {
              copy[k] = prev[k];
            }
          }
          copy[String(result.id)] = {
            title: result.title,
            provider: result.provider,
          };
          return copy;
        });
      },
      [attributes.productIds, commitIds],
    );

    // KI-013 Slice A: provider-search panel hands us
    // (id, summary) tuples after a successful save. Reuse the
    // saved-product `handleAdd` semantics so duplicate ids are
    // dropped silently and the chip cache picks up the title /
    // provider hint.
    var handleProviderAdded = useCallback(
      function (id, summaryFromRow) {
        handleAdd({
          id: id,
          title:
            summaryFromRow && summaryFromRow.title ? summaryFromRow.title : "",
          provider:
            summaryFromRow && summaryFromRow.provider
              ? summaryFromRow.provider
              : "",
        });
      },
      [handleAdd],
    );

    var handleRemove = useCallback(
      function (index) {
        var existing = normalizeIds(attributes.productIds);
        if (index < 0 || index >= existing.length) {
          return;
        }
        var next = existing.slice(0, index).concat(existing.slice(index + 1));
        commitIds(next);
      },
      [attributes.productIds, commitIds],
    );

    var handleMove = useCallback(
      function (from, to) {
        var existing = normalizeIds(attributes.productIds);
        if (
          from < 0 ||
          from >= existing.length ||
          to < 0 ||
          to >= existing.length ||
          from === to
        ) {
          return;
        }
        var next = existing.slice();
        var moved = next.splice(from, 1)[0];
        next.splice(to, 0, moved);
        commitIds(next);
      },
      [attributes.productIds, commitIds],
    );

    var statusLine = null;
    if (search.status === "loading") {
      statusLine = createElement(
        "p",
        { className: "description" },
        __("検索中…", DOMAIN),
      );
    } else if (search.status === "error") {
      statusLine = createElement(
        "p",
        { className: "description", style: { color: "#b32d2e" } },
        __(
          "検索に失敗しました。商品 REST エンドポイントに到達可能か、edit_posts 権限があるかを確認してください。",
          DOMAIN,
        ),
      );
    } else if (search.status === "unavailable") {
      statusLine = createElement(
        "p",
        { className: "description" },
        __(
          "REST API クライアント (wp.apiFetch) がこのエディターホストで読み込まれていません。productIds は手入力では引き続き保存されます。",
          DOMAIN,
        ),
      );
    } else if (
      search.status === "ok" &&
      search.items.length === 0 &&
      query.trim() !== ""
    ) {
      statusLine = createElement(
        "p",
        { className: "description" },
        __("一致する商品が見つかりませんでした。", DOMAIN),
      );
    }

    var resultElements = [];
    for (var i = 0; i < search.items.length; i++) {
      var item = search.items[i];
      var alreadySelected = false;
      for (var j = 0; j < ids.length; j++) {
        if (ids[j] === item.id) {
          alreadySelected = true;
          break;
        }
      }
      // Hide already-selected entries entirely — the chip below
      // already shows them, so listing them again as 'added'
      // rows is just visual noise.
      if (alreadySelected) {
        continue;
      }
      resultElements.push(renderResult(item, false, handleAdd));
    }

    var chipElements = [];
    for (var k = 0; k < ids.length; k++) {
      var id = ids[k];
      var summary = summaries[String(id)] || null;
      chipElements.push(
        renderChip(
          id,
          k,
          ids.length,
          summary,
          attributes,
          handleMove,
          handleRemove,
        ),
      );
    }

    return createElement(
      Fragment,
      {},
      createElement(GridInspectorPanel, {
        attributes: attributes,
        setAttributes: setAttributes,
      }),
      createElement(
        "div",
        rootProps,
        createElement(
          "h4",
          { style: { marginTop: 0 } },
          __("Commerce Link Builder / 商品グリッド", DOMAIN),
        ),
        createElement(
          "p",
          { className: "description" },
          __("商品を検索するか、登録済み商品から追加します。", DOMAIN),
        ),
        createElement(ProviderSearchPanel, {
          onAddSavedId: handleProviderAdded,
          selectedIds: ids,
        }),
        createElement(
          "h5",
          { style: { marginTop: "4px", marginBottom: "6px" } },
          __("登録済み商品", DOMAIN),
        ),
        createElement("input", {
          type: "search",
          className: "regular-text",
          placeholder: __("タイトルで登録済み商品を検索…", DOMAIN),
          spellCheck: false,
          autoCorrect: "off",
          autoCapitalize: "off",
          autoComplete: "off",
          value: query,
          onChange: function (e) {
            setQuery(e.target.value);
          },
          style: { width: "100%", marginBottom: "8px" },
        }),
        statusLine,
        resultElements.length > 0
          ? createElement(
              "ul",
              {
                className: "clb-product-grid-search-results",
                style: {
                  listStyle: "none",
                  margin: "0 0 12px 0",
                  padding: 0,
                  maxHeight: "200px",
                  overflowY: "auto",
                  border: "1px solid #eee",
                  borderRadius: "3px",
                },
              },
              resultElements,
            )
          : null,
        createElement(
          "h5",
          { style: { marginBottom: "6px" } },
          __("選択中の商品", DOMAIN) + " (" + ids.length + ")",
        ),
        ids.length === 0
          ? createElement(
              "p",
              { className: "description", style: { fontStyle: "italic" } },
              __(
                "まだ商品が選択されていません。上の検索ボックスから追加してください。",
                DOMAIN,
              ),
            )
          : createElement(
              "ul",
              {
                className: "clb-product-grid-chips",
                style: { listStyle: "none", margin: 0, padding: 0 },
              },
              chipElements,
            ),
      ),
    );
  }

  blocks.registerBlockType("clb/product-grid", {
    edit: ProductGridEditor,
    save: function () {
      // Server-side rendered: no static save markup.
      return null;
    },
  });
})();
