/**
 * NeccoSpeak ウィジェットローダー — フレーム埋め込みブートストラップ
 *
 * WordPress プラグイン (neccospeak) が同一オリジンで配信するチャットウィジェット
 * フレーム (?neccospeak_frame=1) を、ランチャーボタン + iframe として埋め込む。
 *
 * 移植元は pocxr の standalone widget-loader.js (任意サイトへの埋め込み用、
 * data-agent/data-tenant/data-site 属性でクロスオリジン API を呼ぶ設計) だが、
 * D2 (同一オリジン iframe 配信) に伴い、data-* 属性読取・クロスオリジン
 * AGENT_URL 解決・外部 API 依存を全て取り除き、同一オリジン専用に絞り込んだ
 * もの (docs/transplant-plan.md §2.6 の10点改変 + 左配置対応)。
 *
 * 設定源:
 *   wp_enqueue_script() でこのファイルを読み込んだ直後に wp_localize_script()
 *   が window.NECCOSPEAK_LAUNCHER を注入する (docs/transplant-plan.md §4.3)。
 *   data-* 属性は一切読まない。
 *
 * API:
 *   window.AIAgentWidget.open()   — ウィジェットを開く
 *   window.AIAgentWidget.close()  — ウィジェットを閉じる
 *   window.AIAgentWidget.toggle() — 開閉切替
 *   window.AIAgentWidget.on(event, callback) — イベントリスナー
 *     events: "open", "close", "ready", "navigate"
 */
(function () {
  "use strict";

  // ============================================
  // スクリプトタグの参照 (vendor アセットの相対解決にのみ使用。§2.6 L7)
  // ============================================
  var currentScript = document.currentScript || (function () {
    var scripts = document.getElementsByTagName("script");
    return scripts[scripts.length - 1];
  })();

  // ============================================
  // ランチャー設定の読取 (§2.6 改変点 L1)
  //
  // pocxr 原本は <script data-agent-url data-agent data-tenant data-site
  // data-widget-path data-iframe-allow data-debug> を読んでいたが、NS は
  // 常に同一オリジンの WP サイト内で動くため:
  //   - AGENT_URL はクロスオリジン解決不要 → location.origin 固定
  //   - tenant/site/agent の概念自体が無い (D4: WP プラグインは単一テナント)
  //   - 残る設定は全て window.NECCOSPEAK_LAUNCHER (§4.3) から読む
  // ============================================
  var LAUNCHER_CONFIG = window.NECCOSPEAK_LAUNCHER || {};
  var AGENT_URL = location.origin;
  // framePath は PHP 側で組み立て済みの完全なクエリ付きパス
  // (?neccospeak_frame=1&v=NECCOSPEAK_VERSION)。
  var WIDGET_PATH = LAUNCHER_CONFIG.framePath || "";
  // Issue #288 由来: cross-origin iframe で navigator.clipboard.writeText を
  // 許可するため既定に clipboard-write を含む。geolocation は取得ツールを
  // 削除したため既定から外す (§2.3 Permissions-Policy と整合)。
  var IFRAME_ALLOW = LAUNCHER_CONFIG.iframeAllow || "microphone; clipboard-write";
  var DEBUG = !!LAUNCHER_CONFIG.debug;

  function warn() { if (DEBUG) console.warn.apply(console, ["[widget-loader]"].concat(Array.prototype.slice.call(arguments))); }

  // ============================================
  // 親ページ側 i18n (INV-12)
  //
  // このファイルは classic script として親ページで動くため、フレーム内の
  // window.__NS_I18N__ (§4.4) には触れない。同じ形の辞書を
  // NECCOSPEAK_LAUNCHER.i18n (§4.3、neccospeak_frame_launcher_config() が
  // __() 済みの文字列を注入) から引き、ソース上のリテラルは英語
  // フォールバックのみに保つ。ns/ns-rest.js の t() と同じ契約
  // (未登録キー / 空値なら fallback)。
  // ============================================
  function li18n(key, fallback) {
    var dict = LAUNCHER_CONFIG.i18n;
    if (dict && typeof dict === "object" && typeof dict[key] === "string" && dict[key] !== "") {
      return dict[key];
    }
    return fallback;
  }

  // ============================================
  // ブラウザ Capability Check (Issue #446)
  //
  // 古いブラウザ・低スペック環境で AI ウィジェットが起動失敗する代わりに、
  // 軽量な「お問い合わせ CTA」を表示して導線を確保する。
  //
  // 必須機能 (1つでも欠けたら legacy CTA に振替):
  //   - fetch / Promise / addEventListener / postMessage / JSON
  // 任意機能 (3つ以上欠けたら legacy CTA に振替、それ以外は警告のみ):
  //   - WebRTC / AudioContext / mediaDevices / crypto.randomUUID
  //   - color-mix() / safe-area-inset
  // ============================================
  function checkBrowserCapabilities() {
    var required = {
      fetch: typeof fetch === "function",
      promise: typeof Promise === "function" && typeof Promise.allSettled === "function",
      addEventListener: typeof window.addEventListener === "function",
      postMessage: typeof window.postMessage === "function",
      json: typeof JSON !== "undefined" && typeof JSON.parse === "function",
    };
    var optional = {
      webrtc: typeof window.RTCPeerConnection !== "undefined",
      audioContext: "AudioContext" in window || "webkitAudioContext" in window,
      mediaDevices: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
      randomUUID: !!(window.crypto && typeof window.crypto.randomUUID === "function"),
      colorMix: !!(window.CSS && CSS.supports && CSS.supports("background", "color-mix(in srgb, red 50%, blue)")),
      safeArea: !!(window.CSS && CSS.supports && CSS.supports("padding-bottom", "env(safe-area-inset-bottom)")),
    };
    var missingRequired = Object.keys(required).filter(function (k) { return !required[k]; });
    var missingOptional = Object.keys(optional).filter(function (k) { return !optional[k]; });
    return {
      ok: missingRequired.length === 0 && missingOptional.length < 3,
      missingRequired: missingRequired,
      missingOptional: missingOptional,
    };
  }

  // ============================================
  // Legacy CTA 表示 (capability 不足時の最低限導線)
  // ============================================
  function showLegacyCta(opts) {
    if (document.getElementById("ai-agent-legacy-cta")) return;
    var contactUrl = (opts && opts.contactUrl) || "";
    var label = (opts && opts.label) || li18n("cta_label", "Contact");
    var container = document.createElement("div");
    container.id = "ai-agent-legacy-cta";
    // 最小限の inline スタイル (CSS変数や最新セレクタ非依存、Safari 6+ で動く設計)
    container.style.cssText = [
      "position:fixed",
      "bottom:20px",
      "right:20px",
      "z-index:99999",
      "font-family:-apple-system,Segoe UI,sans-serif"
    ].join(";");
    if (contactUrl) {
      // contactUrl は NECCOSPEAK_LAUNCHER.cta.contact_url 由来だがサーバ側侵害・
      // 改ざんに備え、http(s) 以外 (javascript:, data:, file: 等) は受け入れない
      var safeContactUrl = "";
      try {
        var parsed = new URL(contactUrl, location.href);
        if (parsed.protocol === "https:" || parsed.protocol === "http:" || parsed.protocol === "mailto:") {
          safeContactUrl = parsed.href;
        }
      } catch (e) { /* invalid URL → safeContactUrl は空のまま */ }
      if (!safeContactUrl) {
        warn("legacy CTA: contactUrl rejected (unsafe protocol or invalid URL)");
        return;
      }
      var link = document.createElement("a");
      link.href = safeContactUrl;
      link.textContent = label;
      link.setAttribute("rel", "noopener");
      link.style.cssText = [
        "display:inline-block",
        "padding:12px 20px",
        "background:#0D9488",
        "color:#ffffff",
        "border-radius:24px",
        "text-decoration:none",
        "font-size:14px",
        "font-weight:600",
        "box-shadow:0 4px 16px rgba(0,0,0,0.15)"
      ].join(";");
      container.appendChild(link);
    } else {
      // contactUrl 未取得の場合は表示しない (空のCTA は出さない)
      return;
    }
    document.body.appendChild(container);
  }

  // capability 不足の記録 (§2.6 改変点 L5)
  //
  // pocxr 原本は client-exceptions エンドポイントへ beacon 送信していたが、
  // NS に同等エンドポイントは無い (docs/transplant-plan.md §3.2 telemetry:
  // 全削除)。ネットワーク送信をやめ、console.warn のみのローカル記録にする。
  function reportCapabilityFallback(caps) {
    warn("browser capability fallback triggered", {
      missing_required: caps.missingRequired,
      missing_optional: caps.missingOptional,
    });
  }

  // contactUrl を取得して legacy CTA 表示 (フル widget は起動しない)
  // (§2.6 改変点 L6)
  //
  // pocxr 原本は widget-config エンドポイントを fetch して contactUrl を
  // 取得していたが、NS では window.NECCOSPEAK_LAUNCHER が既に同期的に注入
  // 済み (§4.3) なのでネットワーク往復は不要。
  function fallbackToLegacyCta(caps) {
    reportCapabilityFallback(caps);
    var contactUrl = (LAUNCHER_CONFIG.cta && LAUNCHER_CONFIG.cta.contact_url) || "";
    if (contactUrl) {
      showLegacyCta({ contactUrl: contactUrl, label: li18n("cta_label", "Contact") });
    } else {
      warn("capability fallback: contactUrl not configured");
    }
  }

  // ============================================
  // イベントシステム
  // ============================================
  var eventListeners = {};
  // デフォルトの navigate ハンドラ（overrideNavigate で上書き可能）
  // init() より先に定義し、DOMContentLoaded 競合で上書きされないようにする
  eventListeners["navigate"] = [function (url) { location.href = url; }];
  function emit(event, data) {
    (eventListeners[event] || []).forEach(function (fn) { try { fn(data); } catch (e) { /* ignore */ } });
  }

  // ============================================
  // ユーザーID管理
  // ============================================
  // 0.3.0-dev.40: キーを neccospeak_ 名前空間へ改名（旧 "neccos_widget_user_id"
  // は本番 pocxr ウィジェットと共有される社名入りキーだった。WP.org 配布物と
  // しては自前の名前空間を持つべき、というユーザー判断）。既存訪問者の ID は
  // getOrCreateUserId() の移行チェーンで引き継ぐ。
  var USER_KEY = "neccospeak_widget_user_id";
  var USER_KEY_LEGACY_NECCOS = "neccos_widget_user_id";
  function safeGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
  function safeSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* ignore */ } }
  function safeSessionGet(k) { try { return sessionStorage.getItem(k); } catch (e) { return null; } }
  function safeSessionSet(k, v) { try { sessionStorage.setItem(k, v); } catch (e) { /* ignore */ } }

  function getOrCreateUserId() {
    var id = safeGet(USER_KEY);
    if (id && id.indexOf("user_") === 0) return id;
    // 旧キーからの移行（優先順: 旧NSキー → さらに古い名前空間なしキー）。
    // 移行によって既存訪問者のID連続性（アナリティクスの再訪判定）を保つ。
    var legacy = safeGet(USER_KEY_LEGACY_NECCOS) || safeGet("user_id");
    if (legacy && legacy.indexOf("user_") === 0) {
      safeSet(USER_KEY, legacy);
      return legacy;
    }
    var newId = "user_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9);
    safeSet(USER_KEY, newId);
    return newId;
  }
  var userId = getOrCreateUserId();

  // ============================================
  // CSS 注入
  // ============================================
  function injectStyles() {
    if (document.getElementById("ai-agent-widget-styles")) return;
    var style = document.createElement("style");
    style.id = "ai-agent-widget-styles";
    style.textContent = [
      "#ai-agent-widget{",
      "  --ai-agent-primary:#0D9488;",
      "  --ai-agent-primary-hover:#0F766E;",
      "  --ai-agent-primary-shadow:rgba(13,148,136,0.35);",
      "  --ai-agent-primary-shadow-hover:rgba(13,148,136,0.45);",
      "  --ai-agent-launcher-size:48px;",
      "  --ai-agent-launcher-width:auto;",
      "  --ai-agent-launcher-icon-size:24px;",
      "  --ai-agent-launcher-hover-scale:1.04;",
      "  --ai-agent-launcher-bottom:24px;",
      "  --ai-agent-launcher-right:24px;",
      "  --ai-agent-radius:16px;",
      "  --ai-agent-radius-full:9999px;",
      "  --ai-agent-transition:0.25s cubic-bezier(0.4,0,0.2,1);",
      "  --ai-agent-z:99999;",
      "  --ai-agent-font:system-ui,-apple-system,'Segoe UI',sans-serif;",
      // 骨格プロパティは !important で敵対 WP テーマ (例: Elementor の無差別 !important パターン)
      // に勝つ。widget-config の CSS 変数は変数読取のみなので重ね掛けの影響なし (Issue #196)
      "  position:fixed!important;",
      // iPhone X系のホームインジケータと重ならないよう safe-area-inset を加算する。Issue #444
      // env() は viewport-fit=cover が必要 (顧客サイト側の viewport meta は制御できないため、無くても max() で必ず launcher 位置を維持できるよう設計)
      "  right:max(var(--ai-agent-launcher-right),calc(var(--ai-agent-launcher-right) - 24px + env(safe-area-inset-right)));",
      "  bottom:max(var(--ai-agent-launcher-bottom),calc(var(--ai-agent-launcher-bottom) - 24px + env(safe-area-inset-bottom)));",
      // 左配置対応 (docs/transplant-plan.md §2.6 追加改変): NS の
      // launcher_position='bottom-left' 用。既定は auto (右配置時は無効)。
      // bottom-left 選択時は applyLauncherConfig() がこの変数に px 値を設定し、
      // かつ inline style で right:auto を上書きして左寄せを有効化する。
      "  left:var(--ai-agent-launcher-left,auto);",
      "  z-index:var(--ai-agent-z)!important;",
      // align-items も左右配置に連動させる (既定は右配置なので flex-end)。
      "  display:flex;flex-direction:column;align-items:var(--ai-agent-launcher-align,flex-end);pointer-events:none;",
      "}",
      "#ai-agent-widget>*{pointer-events:auto;}",
      ".ai-agent-toggle{",
      "  position:relative;appearance:none;border:none;",
      // height / border-radius / background は骨格 (Issue #196 — !important で敵対テーマに勝つ)
      "  height:var(--ai-agent-launcher-size)!important;",
      "  padding:0 16px 0 12px;border-radius:var(--ai-agent-radius-full)!important;",
      "  background:var(--ai-agent-primary)!important;color:#fff;cursor:pointer;",
      "  box-shadow:0 4px 16px var(--ai-agent-primary-shadow);",
      "  display:flex;align-items:center;justify-content:center;gap:8px;",
      "  transition:transform var(--ai-agent-transition),box-shadow var(--ai-agent-transition),background-color var(--ai-agent-transition);",
      "  font-family:var(--ai-agent-font);font-size:14px;line-height:1;",
      "}",
      // hover の background も !important (base の background !important に勝つため)
      ".ai-agent-toggle:hover{transform:scale(var(--ai-agent-launcher-hover-scale));background:var(--ai-agent-primary-hover)!important;box-shadow:0 6px 24px var(--ai-agent-primary-shadow-hover);}",
      ".ai-agent-toggle:active{transform:scale(0.98);}",
      // ランチャーのフォーカス輪郭は常時非表示にする（担当R）。
      // 意図的な変更: closeWidget() がパネルを閉じるたびに toggle.focus()
      // でランチャーへフォーカスを戻すため、キーボード/支援技術以外の経路
      // （パネル内の最小化ボタンで閉じた場合や、ページ復元で自動的に開いた
      // 場合など）でもブラウザが :focus-visible と判定し、意図せず輪郭が
      // 出ることがあった。これはキーボード利用者がフォーカス位置を目視で
      // 追えなくなるアクセシビリティ上のトレードオフだが、見た目を優先する
      // という人間のレビュー担当の明示判断により、常時 outline:none とする。
      // 注意: ここを削除するだけではブラウザ既定（UAスタイルシート）の
      // フォーカスリングが代わりに出てしまうため、outline:none を明示する。
      ".ai-agent-toggle:focus,.ai-agent-toggle:focus-visible{outline:none;}",
      ".ai-agent-toggle--no-outline:focus,.ai-agent-toggle--no-outline:focus-visible{outline:none;}",
      ".ai-agent-toggle[aria-expanded='true']{display:none;}",
      ".ai-agent-toggle--loading .ai-agent-toggle__spinner{display:block;}",
      ".ai-agent-toggle--loading .ai-agent-toggle__icon-wrap,.ai-agent-toggle--loading .ai-agent-toggle__label{opacity:0;pointer-events:none;}",
      ".ai-agent-toggle__spinner{display:none;width:18px;height:18px;flex-shrink:0;border:2px solid rgba(255,255,255,0.35);border-top-color:#fff;border-radius:50%;animation:ai-agent-spin 0.7s linear infinite;}",
      "@keyframes ai-agent-spin{to{transform:rotate(360deg);}}",
      ".ai-agent-toggle__icon-wrap,.ai-agent-toggle__label{transition:opacity var(--ai-agent-transition);}",
      ".ai-agent-toggle__icon{display:block;flex-shrink:0;width:var(--ai-agent-launcher-icon-size);height:var(--ai-agent-launcher-icon-size);}",
      ".ai-agent-toggle__label{font-size:14px;font-weight:600;white-space:nowrap;line-height:1;}",
      ".ai-agent-toggle__badge{position:absolute;top:2px;right:2px;width:10px;height:10px;border-radius:50%;background:#10B981;border:2px solid #fff;pointer-events:none;}",
      ".ai-agent-toggle__badge--text{width:auto;height:auto;border-radius:10px;padding:2px 6px;font-size:10px;font-weight:600;color:#fff;top:-4px;right:-4px;white-space:nowrap;line-height:1.2;}",
      // unavailable badge (Issue #194 G-009) — FAB に ! マークで状態を視覚化
      ".ai-agent-toggle__badge--unavailable{width:16px;height:16px;border-radius:50%;background:#DC2626;color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;line-height:1;top:-3px;right:-3px;border:2px solid #fff;}",
      ".ai-agent-unavailable{opacity:0.85;}",
      // tooltip 内 retry ボタン (Issue #194 G-009)
      ".ai-agent-tooltip__retry-btn{display:block;margin-top:8px;padding:6px 14px;background:var(--ai-agent-primary);color:#fff;border:none;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;font-family:var(--ai-agent-font);}",
      ".ai-agent-tooltip__retry-btn:hover{background:var(--ai-agent-primary-hover);}",
      "@keyframes ai-agent-attention{0%,100%{transform:scale(1);}15%{transform:scale(1.12) rotate(-3deg);}30%{transform:scale(1.08) rotate(3deg);}45%{transform:scale(1.1) rotate(-2deg);}60%{transform:scale(1.06) rotate(1deg);}75%{transform:scale(1.04);}}",
      ".ai-agent-toggle--attention{animation:ai-agent-attention 0.8s ease-in-out;}",
      // shape: circle (border-radius は base の !important に勝つため !important 必要)
      ".ai-agent-toggle--circle{border-radius:50%!important;padding:0;width:var(--ai-agent-launcher-size);justify-content:center;}",
      ".ai-agent-toggle--circle .ai-agent-toggle__label{display:none;}",
      // shape: square (同上)
      ".ai-agent-toggle--square{border-radius:12px!important;}",
      // transparent
      ".ai-agent-toggle--transparent{background:transparent!important;box-shadow:none!important;}",
      ".ai-agent-toggle--transparent:hover{background:transparent!important;}",
      // hide label
      ".ai-agent-toggle--no-label .ai-agent-toggle__label{display:none;}",
      ".ai-agent-toggle--no-label{padding:0;width:var(--ai-agent-launcher-size);}",
      // border
      ".ai-agent-toggle--border{border-style:solid;}",
      // explicit width
      ".ai-agent-toggle--custom-width{width:var(--ai-agent-launcher-width);}",
      // image icon
      ".ai-agent-toggle__icon--img{display:block;flex-shrink:0;width:var(--ai-agent-launcher-icon-size);height:var(--ai-agent-launcher-icon-size);object-fit:contain;border-radius:3px;}",
      // rich tooltip
      ".ai-agent-tooltip--rich{white-space:normal;min-width:180px;max-width:260px;}",
      ".ai-agent-tooltip__title{font-weight:700;font-size:13px;margin-bottom:6px;display:block;}",
      ".ai-agent-tooltip__items{list-style:none;margin:0;padding:0;}",
      ".ai-agent-tooltip__items li{display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 0;}",
      ".ai-agent-tooltip__item-icon{flex-shrink:0;font-size:14px;}",
      ".ai-agent-tooltip{position:absolute;bottom:calc(100% + 12px);right:0;background:#fff;color:#1F2937;font-family:var(--ai-agent-font);font-size:13px;line-height:1.5;padding:10px 14px;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,0.12);white-space:nowrap;cursor:pointer;opacity:0;transform:translateY(4px);transition:opacity var(--ai-agent-transition),transform var(--ai-agent-transition);pointer-events:none;display:none;}",
      ".ai-agent-tooltip--visible{opacity:1;transform:translateY(0);pointer-events:auto;}",
      ".ai-agent-tooltip__arrow{position:absolute;bottom:-6px;right:24px;width:12px;height:12px;background:#fff;transform:rotate(45deg);box-shadow:2px 2px 4px rgba(0,0,0,0.06);border-radius:0 0 2px 0;}",
      ".ai-agent-frame-wrap{width:400px;max-width:calc(100vw - 32px);height:min(680px,calc(100dvh - 100px));border-radius:var(--ai-agent-radius);overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,0.18);background:#fff;margin-bottom:12px;opacity:0;transform:translateY(12px) scale(0.98);transition:opacity var(--ai-agent-transition),transform var(--ai-agent-transition);}",
      ".ai-agent-frame-wrap--open{opacity:1;transform:translateY(0) scale(1);}",
      ".ai-agent-frame{width:100%;height:100%;border:0;display:block;}",
      "@media(max-width:480px){",
      // モバイルでも safe-area-inset を加算 (iPhone X系対応)。Issue #444
      "  #ai-agent-widget{right:max(var(--ai-agent-mobile-right,min(var(--ai-agent-launcher-right),8px)),env(safe-area-inset-right,0px));bottom:max(var(--ai-agent-mobile-bottom,min(var(--ai-agent-launcher-bottom),8px)),env(safe-area-inset-bottom,0px));}",
      // モバイル時の height override も base の !important に勝つため !important 必要
      "  .ai-agent-toggle{height:var(--ai-agent-mobile-size,44px)!important;padding:0 14px 0 10px;}",
      "  .ai-agent-toggle__icon{width:var(--ai-agent-mobile-icon-size,20px);height:var(--ai-agent-mobile-icon-size,20px);}",
      "  .ai-agent-toggle__icon--img{width:var(--ai-agent-mobile-icon-size,20px);height:var(--ai-agent-mobile-icon-size,20px);}",
      "  .ai-agent-toggle__label{font-size:13px;}",
      "  .ai-agent-toggle--circle,.ai-agent-toggle--no-label{width:var(--ai-agent-mobile-size,44px);}",
      "  .ai-agent-toggle--custom-width{width:var(--ai-agent-mobile-width,var(--ai-agent-launcher-width));}",
      "  .ai-agent-frame-wrap{width:calc(100vw - 16px);height:calc(100dvh - 72px);border-radius:12px;transform:translateY(100%);opacity:1;}",
      "  .ai-agent-frame-wrap--open{transform:translateY(0);}",
      "  .ai-agent-tooltip{right:0;white-space:normal;max-width:calc(100vw - 32px);}",
      "}",
      "@media(prefers-reduced-motion:reduce){",
      "  .ai-agent-toggle,.ai-agent-frame-wrap,.ai-agent-tooltip{transition:none;}",
      "  .ai-agent-toggle--attention{animation:none;}",
      "  .ai-agent-toggle__spinner{animation:none;border-top-color:rgba(255,255,255,0.6);}",
      "  .ai-agent-toggle__icon-wrap,.ai-agent-toggle__label{transition:none;}",
      "  .ai-agent-toggle:hover,.ai-agent-toggle:active{transform:none;}",
      "  .ai-agent-frame-wrap{transform:none;opacity:1;}",
      "}",
    ].join("\n");
    document.head.appendChild(style);
  }

  // ============================================
  // DOM 生成
  // ============================================
  function buildIframeSrc() {
    // WIDGET_PATH (= NECCOSPEAK_LAUNCHER.framePath, PHP が組み立て済み) は
    // 既に自身のクエリ文字列 (?neccospeak_frame=1&v=NECCOSPEAK_VERSION,
    // docs/transplant-plan.md §4.3) を含んでいる。班A の
    // neccospeak_frame_launcher_config() は framePath を home_url() 由来の
    // **絶対URL** で組み立てている (サブディレクトリ設置でも正しく動く
    // ように)。AGENT_URL との単純連結は絶対URL同士の二重オリジンに
    // なって壊れるため、URL コンストラクタで location.href を基準に
    // 解決する。framePath が絶対URL・ルート相対のいずれであっても同一
    // オリジンの正しいURLになり、既存クエリも壊さない (§2.6 改変点 L2)。
    if (!WIDGET_PATH) return AGENT_URL;
    return new URL(WIDGET_PATH, location.href).href;
  }

  var widget, toggle, frameWrap, iframe, tooltip;
  // Issue #334: iframe lazy load 制御
  var iframeSrcSet = false;
  var iframePreloadMode = "click";

  function createDOM() {
    widget = document.createElement("div");
    widget.id = "ai-agent-widget";

    toggle = document.createElement("button");
    toggle.id = "ai-agent-toggle";
    toggle.className = "ai-agent-toggle ai-agent-toggle--loading";
    toggle.setAttribute("aria-label", li18n("aria_open", "Open AI agent"));
    toggle.setAttribute("aria-expanded", "false");
    toggle.innerHTML =
      '<span class="ai-agent-toggle__spinner" aria-hidden="true"></span>' +
      '<span class="ai-agent-toggle__icon-wrap" aria-hidden="true">' +
        '<svg class="ai-agent-toggle__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z"/></svg>' +
      '</span>' +
      '<span class="ai-agent-toggle__label">' + escapeHtml(li18n("toggle_label", "Ask AI")) + '</span>';

    tooltip = document.createElement("div");
    tooltip.id = "ai-agent-tooltip";
    tooltip.className = "ai-agent-tooltip";
    tooltip.setAttribute("role", "tooltip");
    tooltip.style.display = "none";
    tooltip.innerHTML =
      '<span class="ai-agent-tooltip__title" style="display:none;"></span>' +
      '<span class="ai-agent-tooltip__text"></span>' +
      '<ul class="ai-agent-tooltip__items" style="display:none;"></ul>' +
      '<span class="ai-agent-tooltip__arrow"></span>';

    frameWrap = document.createElement("div");
    frameWrap.id = "ai-agent-frame-wrap";
    frameWrap.className = "ai-agent-frame-wrap";
    frameWrap.style.display = "none";
    frameWrap.setAttribute("aria-label", li18n("aria_frame", "AI agent"));

    iframe = document.createElement("iframe");
    iframe.id = "ai-agent-iframe";
    iframe.className = "ai-agent-frame";
    iframe.title = "AI Navigation Agent";
    iframe.allow = IFRAME_ALLOW;
    // Issue #334: 初回 openWidget() まで src を設定しない (LCP/INP/通信量を改善)
    // NECCOSPEAK_LAUNCHER.preload === "eager" のときだけ即時ロード、"idle" は
    // requestIdleCallback で遅延ロード (既定 "click")。
    var preloadMode = LAUNCHER_CONFIG.preload || "click";
    iframe.loading = preloadMode === "eager" ? "eager" : "lazy";
    frameWrap.appendChild(iframe);
    // idle preload は init() 後に schedule (createDOM 中は document が完全でない可能性)
    iframePreloadMode = preloadMode;
    // eager mode は createDOM 時点で src を設定する。ensureIframeSrc 経由で
    // startWidgetReadyTimer も連動起動させ、idle / click 経路と挙動を揃える。
    if (preloadMode === "eager") {
      ensureIframeSrc();
    }

    widget.appendChild(toggle);
    widget.appendChild(tooltip);
    widget.appendChild(frameWrap);
    document.body.appendChild(widget);
  }

  // ============================================
  // Issue #450: ホストサイト固有の z-index / レイアウト調整
  // ============================================
  /**
   * #450-B WordPress 管理バー検知
   * 管理バーは fixed top:0 / height:32px (admin) or 46px (mobile)、z-index:100200 を持つ。
   * launcher.position.top が未設定で widget が画面上部に来る配置のときに被る可能性は
   * 低いが、将来 launcher.position.top をサポートした場合に重なる。現状は安全側で
   * カスタムプロパティだけ流し、CSS 側で activate するかは tenant 設定次第とする。
   *
   * #450-C z-index 競合検知
   * 親サイト側で同等以上の z-index を持つ要素が存在する場合、widget が隠れる可能性が
   * あるため console.warn で開発者に知らせる。本番ユーザーには影響を与えない (UI 変化なし)。
   */
  function adjustForHostSite() {
    var widget = document.getElementById("ai-agent-widget");
    if (!widget) return;

    // (B) WordPress 管理バー検知
    var adminBar = document.querySelector("#wpadminbar");
    if (adminBar) {
      // 32px は WP 標準 (デスクトップ)、モバイル時は 46px だが viewport が狭く widget
      // 位置と衝突しにくいので 32px 固定で十分な近似値
      widget.style.setProperty("--ai-agent-top-offset", "32px");
    }

    // (C) z-index 競合検知 — 検査範囲は body 直下の position:fixed 要素のみ。
    //     全要素を walk すると重いので、典型的な競合要素 (overlay / modal) に絞る。
    try {
      var ourZ = parseInt(window.getComputedStyle(widget).zIndex, 10) || 0;
      if (!ourZ) return;
      var fixedEls = Array.prototype.slice.call(document.body.children).filter(function (el) {
        if (el === widget) return false;
        var style = window.getComputedStyle(el);
        return style.position === "fixed" || style.position === "sticky";
      });
      var conflicts = fixedEls.filter(function (el) {
        var z = parseInt(window.getComputedStyle(el).zIndex, 10);
        return !isNaN(z) && z >= ourZ;
      });
      if (conflicts.length > 0) {
        warn("z-index conflict detected: " + conflicts.length +
             " host-page element(s) at z-index >= " + ourZ +
             " may overlap the widget. Adjust launcher.z_index in tenant settings if needed.");
      }
    } catch (e) {
      // 親サイト側の CSSOM 取得失敗は致命ではないので静かに無視
    }
  }

  // ============================================
  // カラーユーティリティ
  // ============================================
  function hexToRgb(hex) {
    try {
      var c = hex.replace(/^#/, "");
      if (c.length === 3) c = c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
      var n = parseInt(c, 16);
      if (isNaN(n)) return null;
      return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
    } catch (e) { return null; }
  }
  function darkenHex(hex, amount) {
    var c = hexToRgb(hex);
    if (!c) return "";
    return "#" + [Math.max(0, c.r - amount), Math.max(0, c.g - amount), Math.max(0, c.b - amount)]
      .map(function (v) { return ("0" + v.toString(16)).slice(-2); }).join("");
  }
  function hexToRgba(hex, alpha) {
    var c = hexToRgb(hex);
    if (!c) return "";
    return "rgba(" + c.r + "," + c.g + "," + c.b + "," + alpha + ")";
  }

  // ============================================
  // SVG サニタイズ
  // ============================================
  var SVG_TAGS = ["svg","path","circle","rect","line","polyline","polygon","g","defs","clippath","lineargradient","radialgradient","stop","text","tspan"];
  var SVG_ATTRS = ["viewbox","width","height","fill","stroke","stroke-width","stroke-linecap","stroke-linejoin","d","cx","cy","r","x","y","x1","y1","x2","y2","rx","ry","points","transform","opacity","class","id","offset","stop-color","stop-opacity","gradientunits","gradienttransform","xmlns","clip-path"];

  function sanitizeSvg(str) {
    try {
      // image/svg+xml は厳格な XML パース: xmlns 宣言の無い <svg> は null
      // 名前空間の要素になり、DOM に入れても何も描画されない（2026-08-07 の
      // 空ランチャー障害）。宣言が無ければ補ってからパースする。
      if (!/xmlns\s*=/.test(str)) {
        str = str.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
      }
      var doc = new DOMParser().parseFromString(str, "image/svg+xml");
      var el = doc.querySelector("svg");
      if (!el || doc.querySelector("parsererror")) return null;
      if (el.namespaceURI !== "http://www.w3.org/2000/svg") return null;
      stripUnsafe(el);
      return document.importNode(el, true);
    } catch (e) { return null; }
  }
  function stripUnsafe(el) {
    // ルート要素自身の属性もサニタイズ（<svg onload="..."> 対策）
    for (var k = el.attributes.length - 1; k >= 0; k--) {
      var aname = el.attributes[k].name.toLowerCase();
      if (aname.indexOf("on") === 0 || SVG_ATTRS.indexOf(aname) === -1) el.removeAttribute(el.attributes[k].name);
    }
    for (var i = el.children.length - 1; i >= 0; i--) {
      var child = el.children[i];
      if (SVG_TAGS.indexOf(child.tagName.toLowerCase()) === -1) { child.remove(); }
      else {
        stripUnsafe(child);
      }
    }
  }

  // ============================================
  // HTML エスケープ
  // ============================================
  function escapeHtml(str) {
    return String(str).replace(/[&<>"']/g, function (c) {
      return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c];
    });
  }

  // ============================================
  // 画像アイコン適用
  // ============================================
  function applyImageIcon(iconWrap, url, size, onReady) {
    if (!/^https?:\/\//i.test(url)) { if (onReady) onReady(); return; }
    // SVGを即座に非表示（読み込み中に吹き出しアイコンが見えるのを防止）
    var svgEl = iconWrap.querySelector("svg");
    if (svgEl) svgEl.style.display = "none";
    var existing = iconWrap.querySelector(".ai-agent-toggle__icon--img");
    var img = existing || document.createElement("img");
    if (!existing) {
      img.className = "ai-agent-toggle__icon--img";
      img.alt = "";
      img.setAttribute("aria-hidden", "true");
    }
    img.style.width = size + "px";
    img.style.height = size + "px";
    img.style.display = "none";
    img.onerror = function () {
      img.style.display = "none";
      // 画像読み込み失敗時のみSVGを復元
      if (svgEl) svgEl.style.display = "";
      warn("icon image load failed, falling back to SVG");
      if (onReady) onReady();
    };
    img.onload = function () {
      img.style.display = "block";
      if (onReady) onReady();
    };
    img.src = url;
    if (!iconWrap.contains(img)) iconWrap.appendChild(img);
  }

  // ============================================
  // vendor アセットの相対解決 (§2.6 改変点 L7 が使用)
  //
  // widget-loader.js 自身は assets/widget-frame/js/widget-loader.js に配置
  // され、vendor/ はその親 (assets/widget-frame/) 直下の兄弟ディレクトリ
  // (docs/transplant-plan.md §1.1)。currentScript の src からスクリプト自身
  // の URL を得て、そこからの相対パスで解決する (data-* 属性は使わない)。
  // ============================================
  function resolveVendorAssetUrl(relativePath) {
    try {
      var src = currentScript.getAttribute("src") || "";
      return new URL("../" + relativePath, new URL(src, location.href)).href;
    } catch (e) {
      return "";
    }
  }

  // ============================================
  // Bootstrap Icons CSS 遅延読み込み (§2.6 改変点 L7)
  //
  // pocxr 原本は data-icons-css-url 属性 → CDN (jsdelivr) フォールバックの
  // 2段構えだったが、D5 (wordpress.org 配布は外部CDN禁止) によりローカル
  // 同梱の assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css
  // 固定に一本化する。
  // ============================================
  var biCssLoaded = false;
  function ensureBootstrapIconsCss() {
    if (biCssLoaded) return;
    if (document.querySelector('link[href*="bootstrap-icons"]')) { biCssLoaded = true; return; }
    biCssLoaded = true;
    var href = resolveVendorAssetUrl("vendor/bootstrap-icons/bootstrap-icons.css");
    if (!href) { warn("bootstrap-icons css: could not resolve local vendor URL"); return; }
    var link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = href;
    document.head.appendChild(link);
  }

  // ============================================
  // リッチツールチップ適用
  // ============================================
  function applyRichTooltip(ttConfig) {
    if (!tooltip) return;
    var titleEl = tooltip.querySelector(".ai-agent-tooltip__title");
    var itemsEl = tooltip.querySelector(".ai-agent-tooltip__items");
    if (ttConfig.title && titleEl) {
      titleEl.textContent = ttConfig.title;
      titleEl.style.display = "block";
    }
    if (Array.isArray(ttConfig.items) && ttConfig.items.length && itemsEl) {
      // アイコン名を持つ項目があればBootstrap Icons CSSを読み込む
      var hasIcons = ttConfig.items.some(function (item) { return item.icon && /^[a-z0-9-]+$/i.test(item.icon); });
      if (hasIcons) ensureBootstrapIconsCss();

      tooltip.classList.add("ai-agent-tooltip--rich");
      itemsEl.innerHTML = "";
      ttConfig.items.forEach(function (item) {
        var li = document.createElement("li");
        var iconHtml = "";
        if (item.icon && /^[a-z0-9-]+$/i.test(item.icon)) {
          // Bootstrap Icons 名（例: "volume-up-fill"）→ <i class="bi bi-xxx">
          iconHtml = '<i class="bi bi-' + escapeHtml(item.icon) + '" style="font-size:14px;line-height:1;flex-shrink:0;"></i>';
        }
        li.innerHTML = iconHtml + '<span>' + escapeHtml(String(item.text || "")) + '</span>';
        itemsEl.appendChild(li);
      });
      itemsEl.style.display = "block";
    }
    if (ttConfig.bg_color) {
      tooltip.style.background = ttConfig.bg_color;
      var arrow = tooltip.querySelector(".ai-agent-tooltip__arrow");
      if (arrow) arrow.style.background = ttConfig.bg_color;
    }
    if (ttConfig.text_color) tooltip.style.color = ttConfig.text_color;
  }

  // ============================================
  // ユーザー状態別アイコン (§2.6 改変点 L8)
  //
  // pocxr 原本は localStorage キャッシュで即時描画した後、chat-log の
  // hasInteracted エンドポイントをバックグラウンド fetch して答え合わせして
  // いたが、NS に同等エンドポイントは無い (docs/transplant-plan.md §3.2
  // session: 全削除)。localStorage の ai_agent_has_interacted フラグ
  // (実際の会話ターン発生時にフロント JS が書き込む) のみを判定に使う。
  // ============================================
  function applyUserStateIcon(usiConfig, size) {
    var iconSize = size || 24;
    var CACHE_KEY = "ai_agent_has_interacted";

    var hasInteracted = safeGet(CACHE_KEY) === "true";
    var iconUrl = hasInteracted ? usiConfig.returning_url : usiConfig.first_time_url;

    if (toggle) {
      var iconWrap = toggle.querySelector(".ai-agent-toggle__icon-wrap");
      if (iconWrap && iconUrl) applyImageIcon(iconWrap, iconUrl, iconSize);
    }
  }

  // ============================================
  // ウィジェット開閉
  // ============================================
  var isOpen = false;
  var iframeReady = false;
  var closeTimerId = null;
  var loadTimerId = null;
  var widgetConfigData = null;
  var launcherConfigApplied = false;
  var spinnerRevealTimerId = null;
  var pendingAutoConnect = null;

  function prefersReducedMotion() {
    return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  }

  function ensureIframeSrc() {
    // Issue #334: 初回 open / idle preload 時に src を設定する
    if (iframeSrcSet || !iframe) return;
    iframe.src = buildIframeSrc();
    iframeSrcSet = true;
    // widget-ready タイムアウトは src を設定した時点から計測開始する。
    // data-preload="click" 既定では openWidget() で初めて呼ばれるため、ここで
    // タイマーを起動しないと iframe をロード前に "ご利用できません" 誤表示が起きる。
    startWidgetReadyTimer();
  }

  function startWidgetReadyTimer() {
    // Issue #194 (unavailable UI badge / retry / beacon) のタイムアウトを起動。
    // 二重起動と iframeReady 後の起動を抑止。
    if (loadTimerId || iframeReady) return;
    loadTimerId = setTimeout(function () {
      loadTimerId = null;
      if (!iframeReady && toggle) {
        sendUnavailableBeacon("iframe_not_ready");
        showUnavailableUI();
      }
    }, 15000);
  }

  function openWidget() {
    if (!frameWrap || !toggle) return;
    if (closeTimerId) { clearTimeout(closeTimerId); closeTimerId = null; }
    isOpen = true;
    safeSessionSet("ai_agent_widget_open", "1");
    dismissTooltip();
    ensureIframeSrc();
    frameWrap.style.display = "block";
    frameWrap.setAttribute("role", "dialog");
    frameWrap.setAttribute("aria-modal", "true");
    requestAnimationFrame(function () {
      requestAnimationFrame(function () {
        frameWrap.classList.add("ai-agent-frame-wrap--open");
      });
    });
    toggle.setAttribute("aria-expanded", "true");
    if (iframe) iframe.focus(); else frameWrap.focus();
    if (iframeReady) { sendUserIdToIframe(); sendPageNavigation(); }
    emit("open");
  }

  function closeWidget() {
    if (!frameWrap || !toggle) return;
    isOpen = false;
    safeSessionSet("ai_agent_widget_open", "0");
    if (closeTimerId) { clearTimeout(closeTimerId); closeTimerId = null; }
    frameWrap.classList.remove("ai-agent-frame-wrap--open");
    frameWrap.removeAttribute("role");
    frameWrap.removeAttribute("aria-modal");
    if (prefersReducedMotion()) {
      frameWrap.style.display = "none";
    } else {
      var onEnd = function () {
        frameWrap.removeEventListener("transitionend", onEnd);
        closeTimerId = null;
        if (!isOpen) frameWrap.style.display = "none";
      };
      frameWrap.addEventListener("transitionend", onEnd);
      closeTimerId = setTimeout(function () {
        closeTimerId = null;
        frameWrap.removeEventListener("transitionend", onEnd);
        if (!isOpen) frameWrap.style.display = "none";
      }, 350);
    }
    toggle.setAttribute("aria-expanded", "false");
    if (toggle) toggle.focus();
    emit("close");
  }

  function toggleWidget() { isOpen ? closeWidget() : openWidget(); }

  // ============================================
  // ツールチップ
  // ============================================
  function showTooltip() {
    if (!tooltip) return;
    tooltip.style.display = "block";
    requestAnimationFrame(function () {
      requestAnimationFrame(function () { tooltip.classList.add("ai-agent-tooltip--visible"); });
    });
  }

  // ============================================
  // Unavailable 状態 (Issue #194 / G-009 + G-010)
  // ============================================
  // (§2.6 改変点 L9)
  //
  // pocxr 原本は navigator.sendBeacon で widget-analytics の
  // unavailable-beacon エンドポイントに送信していたが、NS に同等
  // エンドポイントは無い (docs/transplant-plan.md §3.2 telemetry: 全削除)。呼び出し元
  // (startWidgetReadyTimer / retryInit) はそのままなので、シグネチャは
  // 維持しつつ no-op (console.warn のみ) にする。
  function sendUnavailableBeacon(reason) {
    warn("widget unavailable", reason || "unknown");
  }

  function showUnavailableUI() {
    if (!toggle) return;
    toggle.classList.add("ai-agent-unavailable");
    toggle.setAttribute("title", li18n("unavailable_title", "Chat is temporarily unavailable"));

    // badge 追加 (idempotent)
    if (!toggle.querySelector(".ai-agent-toggle__badge--unavailable")) {
      var badge = document.createElement("span");
      badge.className = "ai-agent-toggle__badge ai-agent-toggle__badge--unavailable";
      badge.setAttribute("aria-hidden", "true");
      badge.textContent = "!";
      toggle.appendChild(badge);
    }

    // tooltip に retry CTA を表示
    if (tooltip) {
      var textEl = tooltip.querySelector(".ai-agent-tooltip__text");
      if (textEl) {
        textEl.textContent = li18n("unavailable_tooltip", "Chat is temporarily unavailable.");
      }
      if (!tooltip.querySelector(".ai-agent-tooltip__retry-btn")) {
        var btn = document.createElement("button");
        btn.type = "button";
        btn.className = "ai-agent-tooltip__retry-btn";
        btn.textContent = li18n("retry_label", "Retry");
        btn.addEventListener("click", function (e) {
          e.stopPropagation();
          retryInit();
        });
        var arrow = tooltip.querySelector(".ai-agent-tooltip__arrow");
        if (arrow) tooltip.insertBefore(btn, arrow);
        else tooltip.appendChild(btn);
      }
      tooltip.classList.add("ai-agent-tooltip--rich");
      showTooltip();
    }
  }

  function retryInit() {
    if (!toggle) return;
    toggle.classList.remove("ai-agent-unavailable");
    toggle.classList.add("ai-agent-toggle--loading");
    toggle.removeAttribute("title");

    var badge = toggle.querySelector(".ai-agent-toggle__badge--unavailable");
    if (badge) badge.remove();

    var retryBtn = tooltip && tooltip.querySelector(".ai-agent-tooltip__retry-btn");
    if (retryBtn) retryBtn.remove();

    // tooltip を隠す (dismissTooltip は session flag を立てるので使わない)
    if (tooltip) {
      tooltip.classList.remove("ai-agent-tooltip--visible");
      setTimeout(function () { if (tooltip) tooltip.style.display = "none"; }, 350);
    }

    // iframe src を再設定して再読み込み
    iframeReady = false;
    if (iframe) iframe.src = buildIframeSrc();

    if (loadTimerId) { clearTimeout(loadTimerId); loadTimerId = null; }
    loadTimerId = setTimeout(function () {
      loadTimerId = null;
      if (!iframeReady && toggle) {
        sendUnavailableBeacon("iframe_not_ready_retry");
        showUnavailableUI();
      }
    }, 15000);

    fetchAndApplyConfig();
  }
  function dismissTooltip() {
    if (!tooltip) return;
    // ユーザー dismiss 時、まだ走っている auto-hide タイマーを明示的に解除して
    // リソース残存を避ける (AI Review #551 round7 low #1)。
    // `tooltipAutoHideTimerId` は `var` で IIFE スコープ全体にホイスト済のため
    // ここから安全に参照可能。値が undefined / null / 0 のいずれでも条件式は false。
    if (tooltipAutoHideTimerId) {
      clearTimeout(tooltipAutoHideTimerId);
      tooltipAutoHideTimerId = null;
    }
    tooltip.classList.remove("ai-agent-tooltip--visible");
    safeSessionSet("ai_agent_tooltip_dismissed", "1");
    var onEnd = function () { tooltip.removeEventListener("transitionend", onEnd); tooltip.style.display = "none"; };
    tooltip.addEventListener("transitionend", onEnd);
    setTimeout(function () { tooltip.removeEventListener("transitionend", onEnd); tooltip.style.display = "none"; }, 350);
  }

  // ============================================
  // Issue #291: ページ別 launcher 文言の解決
  //
  // launcher.contextual.rules は server サイドで sanitize 済の配列
  // ([{ path_prefix, label, tooltip_text }, ...]) を想定。location.pathname に対し
  // 最長 prefix マッチでヒットしたルールの上書き値を返す。マッチしなければ
  // 空オブジェクトを返し、呼び出し側でフォールバック。
  //
  // パスマッチは startsWith で十分 (regex は server 側 sanitize の対象外で
  // ReDoS / 設定ミスのリスクが大きいため許容しない)。
  // ============================================
  function resolveContextualOverride(contextual, pathname) {
    if (!contextual || contextual.enabled === false) return {};
    var rules = (contextual && Array.isArray(contextual.rules)) ? contextual.rules : null;
    if (!rules || rules.length === 0) return {};
    var pn = String(pathname || "/");
    var best = null;
    for (var i = 0; i < rules.length; i++) {
      var r = rules[i];
      if (!r || typeof r.path_prefix !== "string" || !r.path_prefix) continue;
      if (pn.indexOf(r.path_prefix) !== 0) continue;
      // パスセグメント境界チェック (AI Review #551 round1 medium):
      //  - `/shop` が `/shopping/cart` に誤マッチしないよう、prefix の直後が
      //    末尾 / "/" / "?" / "#" のいずれかであることを要求する。
      //  - `/service/` のように `/` で終わる prefix は indexOf 後に追加検査
      //    不要 (prefix 内に `/` 境界を含むため自動で安全)。
      var endsWithSlash = r.path_prefix.charAt(r.path_prefix.length - 1) === "/";
      if (!endsWithSlash) {
        var charAfter = pn.charAt(r.path_prefix.length); // 範囲外は "" を返す
        if (charAfter !== "" && charAfter !== "/" && charAfter !== "?" && charAfter !== "#") continue;
      }
      if (!best || r.path_prefix.length > best.path_prefix.length) best = r;
    }
    if (!best) return {};
    return {
      label: typeof best.label === "string" ? best.label : "",
      tooltip_text: typeof best.tooltip_text === "string" ? best.tooltip_text : "",
    };
  }

  // ============================================
  // Issue #291: 訪問回数の追跡
  //
  // 同一ブラウザでの再訪を localStorage でカウント。1セッション中の SPA 内
  // 遷移で重複カウントされないよう sessionStorage の guard flag で 1セッション
  // 1回に絞る。3 回 (= 3 セッション) を超えたら吹き出しチラ見せは表示しない。
  // ============================================
  var VISIT_COUNT_KEY = "ai_agent_visit_count";
  var VISIT_COUNTED_FLAG = "ai_agent_visit_counted";
  var TOOLTIP_PEEK_MAX_VISITS = 3;
  function getVisitCount() {
    var v = 0;
    try { v = parseInt(window.localStorage.getItem(VISIT_COUNT_KEY) || "0", 10); } catch (e) {}
    return isFinite(v) ? v : 0;
  }
  function bumpVisitCountOncePerSession() {
    // private browsing 等で sessionStorage が使えない場合、`getItem` で
    // throw すると訪問カウントが永久に進まず TOOLTIP_PEEK_MAX_VISITS が
    // 機能しなくなる。dedup を諦めて localStorage カウントは進める方が
    // UX 上マシなので、getItem 失敗時は alreadyCounted=false 扱いにする
    // (AI Review #551 round3 low #1)
    var alreadyCounted = false;
    try {
      alreadyCounted = window.sessionStorage.getItem(VISIT_COUNTED_FLAG) === "1";
    } catch (e) { /* sessionStorage 不可 — dedup なし */ }
    if (alreadyCounted) return;
    var next = getVisitCount() + 1;
    try { window.localStorage.setItem(VISIT_COUNT_KEY, String(next)); } catch (e) {}
    try { window.sessionStorage.setItem(VISIT_COUNTED_FLAG, "1"); } catch (e) {}
  }

  // ============================================
  // Issue #291: 吹き出しチラ見せ — 5 秒で自動 fade-out
  //
  // ユーザー明示の dismiss と区別したいが、SPA 内遷移で再度 3 秒後に蘇るのは
  // スパムなので session flag は同じく立てる (dismissTooltip と同様)。
  // ============================================
  var TOOLTIP_AUTOHIDE_MS = 5000;
  var tooltipAutoHideTimerId = null;
  var tooltipShowTimerId = null;
  function autoHideTooltip() {
    tooltipAutoHideTimerId = null;
    if (!tooltip) return;
    if (!tooltip.classList.contains("ai-agent-tooltip--visible")) return; // 既に閉じた
    dismissTooltip();
  }

  function initTooltip() {
    if (!tooltip) return;
    var textEl = tooltip.querySelector(".ai-agent-tooltip__text");
    var titleEl = tooltip.querySelector(".ai-agent-tooltip__title");
    var itemsEl = tooltip.querySelector(".ai-agent-tooltip__items");
    var hasContent = (textEl && textEl.textContent.trim()) ||
                     (titleEl && titleEl.textContent.trim()) ||
                     (itemsEl && itemsEl.children.length > 0);
    if (!hasContent) return;
    if (safeSessionGet("ai_agent_tooltip_dismissed") === "1") return;
    // Issue #291: 訪問回数で表示制限。これは visit count 増分後に判定する。
    // 注 (AI Review #551 round5 low #1): visit count > 3 で早期 return しても
    // `tooltipShowTimerId` が stale で残るシナリオは発生しない。
    // bumpVisitCountOncePerSession は sessionStorage guard でセッション内
    // 1 回しかカウントを上げないため、initTooltip が同一セッション内で複数回
    // 呼ばれても `getVisitCount()` は不変。本 PR 内で「初回は通過 → 後続は
    // count > 3 で return」と切り替わることはない。
    if (getVisitCount() > TOOLTIP_PEEK_MAX_VISITS) return;
    // SPA ルーティング等で initTooltip が短時間に複数回呼ばれた場合、
    // 3 秒後 setTimeout コールバックが重なって showTooltip / click listener が
    // 多重登録されないよう、既存スケジュールをクリアして再シングル化する
    // (AI Review #551 round2 low #1)
    if (tooltipShowTimerId) clearTimeout(tooltipShowTimerId);
    tooltipShowTimerId = setTimeout(function () {
      tooltipShowTimerId = null;
      if (isOpen) return;
      // SPA 内遷移中に別呼び出しの autoHide が dismissed flag を先に立てた
      // 可能性があるので、3 秒タイマー callback 冒頭で再チェック
      // (AI Review #551 round3 low #2)
      if (safeSessionGet("ai_agent_tooltip_dismissed") === "1") return;
      showTooltip();
      // 5 秒後に自動 fade out (Issue #291)
      if (tooltipAutoHideTimerId) clearTimeout(tooltipAutoHideTimerId);
      tooltipAutoHideTimerId = setTimeout(autoHideTooltip, TOOLTIP_AUTOHIDE_MS);
    }, 3000);
    tooltip.removeEventListener("click", dismissTooltip);
    tooltip.addEventListener("click", dismissTooltip);
  }

  // ============================================
  // postMessage (§2.6 改変点 L10)
  //
  // AGENT_URL は location.origin 固定 (L1) になっただけで postToIframe /
  // handleIframeMessage 自体の論理は無変更 — 元々 targetOrigin / origin 照合
  // に AGENT_URL を使っており、常に同一オリジンであることを暗黙の前提と
  // していたコードがそのまま正しく動く。
  // ============================================
  function postToIframe(data) {
    if (!AGENT_URL || !iframeReady) return;
    try { if (iframe && iframe.contentWindow) iframe.contentWindow.postMessage(data, AGENT_URL); } catch (e) { /* cross-origin */ }
  }
  function sendUserIdToIframe() { postToIframe({ type: "set-user-id", data: { userId: userId }, timestamp: Date.now() }); }
  function sendPageNavigation() {
    postToIframe({ type: "page-navigation", data: { url: location.pathname, title: document.title, path: location.pathname } });
  }
  function sendThemeConfig() {
    if (widgetConfigData) {
      var ui = widgetConfigData.ui || {};
      var payload = { brandColor: ui.primary_color || "#0D9488", widgetTitle: ui.header_title || "", welcomeMessage: "", quickActions: [] };
      if (ui.theme_overrides && typeof ui.theme_overrides === "object") payload.themeOverrides = ui.theme_overrides;
      postToIframe({ type: "theme-config", data: payload, timestamp: Date.now() });
    }
  }

  function handleIframeMessage(event) {
    if (!AGENT_URL || event.origin !== AGENT_URL) return;
    var msg = event.data;
    if (!msg || !msg.type) return;
    switch (msg.type) {
      case "widget-ready":
        iframeReady = true;
        if (loadTimerId) { clearTimeout(loadTimerId); loadTimerId = null; }
        sendUserIdToIframe();
        sendPageNavigation();
        sendThemeConfig();
        if (pendingAutoConnect) { pendingAutoConnect(); pendingAutoConnect = null; }
        // ページ遷移前にウィジェットが開いていた場合、チャットビューを復元
        if (isOpen && safeSessionGet("ai_agent_widget_open") === "1") {
          postToIframe({ type: "switch-view", data: { view: "chat" } });
        }
        emit("ready");
        break;
      case "navigate":
        if (msg.url) {
          var navDest;
          try {
            navDest = new URL(msg.url, location.href);
            if (navDest.protocol !== "https:" && navDest.protocol !== "http:") break;
          } catch (e) { break; }
          // Issue #208 由来の same-origin 限定を維持。pocxr 原本にあった
          // data-allow-cross-origin-nav="true" の抜け穴 (§2.6 改変点 L10) は
          // 削除し、常にブロックする (D2: iframe は常に同一オリジン)。
          if (navDest.origin !== location.origin) {
            warn("blocked cross-origin navigate", { dest: navDest.origin, current: location.origin });
            break;
          }
          // emit のみ実行 — WPラッパー等の外部ハンドラがpjax遷移を行う。
          // ハンドラ未登録時はデフォルトハンドラ（下記 init() で登録）が location.href で遷移。
          emit("navigate", navDest.href);
        }
        break;
      case "minimize-widget":
        closeWidget();
        break;
    }
  }

  // ============================================
  // ランチャー設定適用
  // ============================================
  function revealLauncher() {
    if (launcherConfigApplied) return;
    launcherConfigApplied = true;
    if (spinnerRevealTimerId) { clearTimeout(spinnerRevealTimerId); spinnerRevealTimerId = null; }
    if (toggle) toggle.classList.remove("ai-agent-toggle--loading");
  }

  function applyLauncherConfig(data) {
    if (!toggle || !widget || !data) { revealLauncher(); return; }
    var launcher = data.launcher || {};
    var ui = data.ui || {};

    var primary = ui.primary_color || "";
    if (primary) {
      widget.style.setProperty("--ai-agent-primary", primary);
      if (!launcher.hover_color) { var d = darkenHex(primary, 13); if (d) widget.style.setProperty("--ai-agent-primary-hover", d); }
      if (!launcher.shadow_color) {
        var s = hexToRgba(primary, 0.35); var sh = hexToRgba(primary, 0.45);
        if (s) widget.style.setProperty("--ai-agent-primary-shadow", s);
        if (sh) widget.style.setProperty("--ai-agent-primary-shadow-hover", sh);
      }
    }
    if (launcher.hover_color) widget.style.setProperty("--ai-agent-primary-hover", launcher.hover_color);
    if (launcher.shadow_color) { widget.style.setProperty("--ai-agent-primary-shadow", launcher.shadow_color); widget.style.setProperty("--ai-agent-primary-shadow-hover", launcher.shadow_color); }
    if (launcher.position) {
      if (launcher.position.bottom != null) widget.style.setProperty("--ai-agent-launcher-bottom", launcher.position.bottom + "px");
      // 左配置対応 (docs/transplant-plan.md §2.6 追加改変): NS の
      // launcher_position='bottom-left' のとき、PHP は position.right の
      // 代わりに position.left を渡す (§4.3)。pocxr 原本は right アンカーしか
      // 持たないため、left が来たら --ai-agent-launcher-left を設定し、
      // align-items を flex-start に切り替え、CSS の right:max(...) 規則を
      // inline style で right:auto 上書きして無効化する。
      if (launcher.position.left != null) {
        widget.style.setProperty("--ai-agent-launcher-left", launcher.position.left + "px");
        widget.style.setProperty("--ai-agent-launcher-align", "flex-start");
        widget.style.setProperty("right", "auto");
      } else if (launcher.position.right != null) {
        widget.style.setProperty("--ai-agent-launcher-right", launcher.position.right + "px");
      }
    }
    if (launcher.size) widget.style.setProperty("--ai-agent-launcher-size", launcher.size + "px");

    // ── z-index ──
    if (launcher.z_index) widget.style.setProperty("--ai-agent-z", String(launcher.z_index));

    // ── hover_scale ──
    if (launcher.hover_scale) widget.style.setProperty("--ai-agent-launcher-hover-scale", String(launcher.hover_scale));

    // ── icon_size ──
    var iconSize = launcher.icon_size || 24;
    widget.style.setProperty("--ai-agent-launcher-icon-size", iconSize + "px");

    // ── shape クラス ──
    var shape = launcher.shape || "pill";
    toggle.classList.remove("ai-agent-toggle--circle", "ai-agent-toggle--square");
    if (shape === "circle") toggle.classList.add("ai-agent-toggle--circle");
    else if (shape === "square") toggle.classList.add("ai-agent-toggle--square");

    // ── transparent ──
    toggle.classList.toggle("ai-agent-toggle--transparent", !!launcher.transparent);

    // ── hide_label ──
    toggle.classList.toggle("ai-agent-toggle--no-label", !!launcher.hide_label);

    // ── width ──
    if (launcher.width != null && shape !== "circle") {
      widget.style.setProperty("--ai-agent-launcher-width", launcher.width + "px");
      toggle.classList.add("ai-agent-toggle--custom-width");
    }

    // ── border / focus outline ──
    // 「枠線」の統一制御: border.enabled が明示的に false のときは
    // focus-visible 時の outline も抑止する（border 未設定時は下位互換のため現状維持）
    if (launcher.border && launcher.border.enabled) {
      toggle.classList.add("ai-agent-toggle--border");
      toggle.style.borderColor = launcher.border.color || "#ffffff";
      toggle.style.borderWidth = (launcher.border.width || 2) + "px";
    } else if (launcher.border && launcher.border.enabled === false) {
      toggle.classList.add("ai-agent-toggle--no-outline");
    }

    // ── アイコン（SVG / 画像URL の分岐） ──
    // 画像読み込みが必要な場合は revealLauncher を画像onload/onerrorまで遅延
    var iconWrap = toggle.querySelector(".ai-agent-toggle__icon-wrap");
    var useImage = launcher.icon_type === "image" && launcher.icon_image_url;
    var needsImageLoad = false;
    if (iconWrap) {
      if (useImage) {
        needsImageLoad = true;
        applyImageIcon(iconWrap, launcher.icon_image_url, iconSize, revealLauncher);
      } else if (launcher.icon_svg) {
        if (/^https?:\/\//i.test(launcher.icon_svg.trim())) {
          needsImageLoad = true;
          applyImageIcon(iconWrap, launcher.icon_svg.trim(), iconSize, revealLauncher);
        } else {
          var safeSvg = sanitizeSvg(launcher.icon_svg.trim());
          if (safeSvg) {
            safeSvg.classList.add("ai-agent-toggle__icon");
            safeSvg.setAttribute("width", String(iconSize)); safeSvg.setAttribute("height", String(iconSize)); safeSvg.setAttribute("aria-hidden", "true");
            iconWrap.innerHTML = "";
            iconWrap.appendChild(safeSvg);
          }
        }
      }
    }

    // Label
    var label = launcher.label || ui.launcher_label || "";

    // Issue #291: ページ別の文脈化。location.pathname の prefix マッチで
    // launcher.contextual.rules[].path_prefix にヒットすれば label / tooltip_text
    // を上書きする。複数 prefix がヒットした場合は最長一致を優先。
    var contextOverride = resolveContextualOverride(launcher.contextual, location.pathname);
    if (contextOverride.label) label = contextOverride.label;

    if (label) { var el = toggle.querySelector(".ai-agent-toggle__label"); if (el) el.textContent = label; }

    // Tooltip（基本テキスト）
    if (launcher.tooltip && launcher.tooltip.enabled) {
      // contextual.tooltip_text が指定されていれば優先 (Issue #291)。
      // 仕様: 空文字 ("") は "上書きしない" と解釈する。「このページでは tooltip を
      // 出さない」を表現したい場合は launcher.tooltip.enabled=false を別途設定する
      // か、tooltip_text を未設定にする (AI Review #551 round8 low #2 の挙動明文化)
      var tooltipText = contextOverride.tooltip_text || launcher.tooltip.text;
      if (tooltipText) {
        var tt = tooltip.querySelector(".ai-agent-tooltip__text");
        if (tt) tt.textContent = tooltipText;
      }
      // リッチツールチップ（title / items / colors）
      applyRichTooltip(launcher.tooltip);
    }
    if (launcher.tooltip && !launcher.tooltip.enabled && tooltip) tooltip.style.display = "none";

    // Badge
    if (launcher.badge && launcher.badge.enabled) {
      if (!toggle.querySelector(".ai-agent-toggle__badge")) {
        var badge = document.createElement("span");
        badge.className = "ai-agent-toggle__badge";
        if (launcher.badge.color) badge.style.backgroundColor = launcher.badge.color;
        if (launcher.badge.text) { badge.textContent = launcher.badge.text; badge.classList.add("ai-agent-toggle__badge--text"); }
        toggle.appendChild(badge);
      }
    }

    // Attention animation
    if (launcher.attention_animation && launcher.attention_animation.enabled) {
      var delay = (launcher.attention_animation.delay_seconds || 5) * 1000;
      var shown = false;
      try { shown = sessionStorage.getItem("ai_agent_attention_shown") === "1"; } catch (e) {}
      if (!shown) {
        setTimeout(function () {
          if (!isOpen && toggle) {
            toggle.classList.add("ai-agent-toggle--attention");
            toggle.addEventListener("animationend", function () { toggle.classList.remove("ai-agent-toggle--attention"); }, { once: true });
          }
          try { sessionStorage.setItem("ai_agent_attention_shown", "1"); } catch (e) {}
        }, delay);
      }
    }

    // ── mobile CSS変数 ──
    var mobile = launcher.mobile || {};
    if (mobile.enabled) {
      if (mobile.size) widget.style.setProperty("--ai-agent-mobile-size", mobile.size + "px");
      if (mobile.icon_size) widget.style.setProperty("--ai-agent-mobile-icon-size", mobile.icon_size + "px");
      if (mobile.width != null) widget.style.setProperty("--ai-agent-mobile-width", mobile.width + "px");
      if (mobile.position) {
        if (mobile.position.right != null) widget.style.setProperty("--ai-agent-mobile-right", mobile.position.right + "px");
        if (mobile.position.bottom != null) widget.style.setProperty("--ai-agent-mobile-bottom", mobile.position.bottom + "px");
      }
    }

    // ── user_state_icons（非同期、最後に実行） ──
    if (launcher.user_state_icons && launcher.user_state_icons.enabled &&
        (launcher.user_state_icons.first_time_url || launcher.user_state_icons.returning_url)) {
      applyUserStateIcon(launcher.user_state_icons, iconSize);
    }

    // ── auto_connect ──
    if (launcher.auto_connect) {
      var autoConnectHandler = function () {
        postToIframe({ type: "auto-connect", timestamp: Date.now() });
      };
      if (iframeReady) { autoConnectHandler(); }
      else { pendingAutoConnect = autoConnectHandler; }
    }

    // ツールチップテキスト設定後に初期化（fetch完了後でないとテキストが空）
    initTooltip();

    // 画像読み込みが必要な場合は applyImageIcon の onload/onerror コールバックで revealLauncher が呼ばれる
    // それ以外（SVGインライン or アイコンなし）は即座に reveal
    if (needsImageLoad) {
      // 画像読み込みタイムアウト: 5秒経っても読み込み完了しなければ強制 reveal
      setTimeout(function () { revealLauncher(); }, 5000);
    } else {
      revealLauncher();
    }
  }

  // (§2.6 改変点 L4)
  //
  // pocxr 原本は widget-config エンドポイントを fetch し、4秒のスピナー
  // タイムアウトを併走させていたが、NS では window.NECCOSPEAK_LAUNCHER が
  // wp_localize_script によって既に同期的に注入済み (§4.3) なので、
  // ネットワーク往復無しで即座に適用できる。
  function fetchAndApplyConfig() {
    widgetConfigData = LAUNCHER_CONFIG;
    applyLauncherConfig(LAUNCHER_CONFIG);
    if (iframeReady) sendThemeConfig();
  }

  // ============================================
  // Bot UA 判定 (Issue #439)
  //
  // GPTBot / ClaudeBot / Googlebot 等のクローラがウィジェットを初期化すると
  // widget-config / hasInteracted を叩いて Function 実行 + DB クエリが発火し、
  // 多数サイト埋め込み時に大きなコスト要因になる。client 側でも early-out。
  //
  // SEO 影響なし: HTML 本文は通常通りクロールされ、widget 起動だけ抑制。
  //
  // 注: サーバ側 utils/bot-detector.js BOT_PATTERNS と意図的に分離している。
  //  - クライアントはブラウザ配信のためサイズ最小化を優先し、主要クローラ
  //    (FinOps 対象) + 一般用語のみに絞る。
  //  - サーバ側はマイナークローラまで含めた多層防御の最終ライン。
  // 新規 AI クローラ追加時はサーバ側にも反映すること。
  // ============================================
  var BOT_UA_PATTERNS = [
    // 実 UA に単独 "crawl" を含むクローラはほぼ無いため /\bcrawl\b/ は除外。
    // (utils/bot-detector.js のサーバ側 BOT_PATTERNS と整合)
    /\bbot\b/i, /\bcrawler\b/i, /\bspider\b/i,
    /GPTBot/i, /ClaudeBot/i, /CCBot/i, /Claude-Web/i, /Anthropic-AI/i,
    /PerplexityBot/i, /YouBot/i, /OAI-SearchBot/i, /Bytespider/i,
    /Googlebot/i, /Bingbot/i, /DuckDuckBot/i, /YandexBot/i, /Baiduspider/i,
    /HeadlessChrome/i, /PhantomJS/i, /AhrefsBot/i, /SemrushBot/i,
  ];
  function isBotUA(ua) {
    if (!ua || typeof ua !== "string") return false;
    for (var i = 0; i < BOT_UA_PATTERNS.length; i += 1) {
      if (BOT_UA_PATTERNS[i].test(ua)) return true;
    }
    return false;
  }

  // ============================================
  // 初期化
  // ============================================
  function init() {
    if (!AGENT_URL) { warn("Could not determine agent URL"); return; }
    // Issue #439: Bot/crawler はウィジェット初期化をスキップ (FinOps)。
    // navigator.userAgent は spoof 容易だが、正規利用 UA を spoof する経済合理性は無い。
    // サーバ側 (middleware/bot-filter.js) との多層防御。
    if (isBotUA(navigator.userAgent || "")) {
      warn("Bot UA detected, skipping widget initialization:", navigator.userAgent);
      return;
    }
    // Issue #446: 古いブラウザ・低スペック環境ではフル widget を起動せず軽量CTA に振り替える
    var caps = checkBrowserCapabilities();
    if (!caps.ok) {
      warn("Browser capabilities insufficient, showing legacy CTA",
           "missing_required:", caps.missingRequired, "missing_optional:", caps.missingOptional);
      fallbackToLegacyCta(caps);
      return;
    }
    // Issue #291: 訪問回数を最初に bump (sessionStorage guard で 1 session 1 回のみ)。
    // initTooltip 側で参照されるため、style/DOM 構築より前に確定させる。
    bumpVisitCountOncePerSession();

    injectStyles();
    createDOM();
    adjustForHostSite();

    // ページ遷移前にウィジェットが開いていた場合、即座に開いた状態で表示（フリッカー防止）
    if (safeSessionGet("ai_agent_widget_open") === "1") {
      openWidget();
    }

    // Issue #334: idle preload (低負荷で iframe を裏読込)
    // SaveData / 低速回線では skip して通信量を節約
    if (iframePreloadMode === "idle" && !iframeSrcSet) {
      var conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
      var isSlowOrSaveData = conn && (conn.saveData === true || conn.effectiveType === "slow-2g" || conn.effectiveType === "2g");
      if (!isSlowOrSaveData) {
        var schedule = window.requestIdleCallback || function (fn) { return setTimeout(fn, 2000); };
        schedule(function () { ensureIframeSrc(); }, { timeout: 5000 });
      }
    }

    toggle.addEventListener("click", function () { dismissTooltip(); toggleWidget(); });

    // widget-ready タイムアウト (Issue #194: badge + tooltip retry + analytics beacon)
    // タイマーは ensureIframeSrc() 経由で iframe.src 設定時に起動される。
    //   - eager: createDOM 内で ensureIframeSrc() が即時呼ばれる
    //   - idle: 下の requestIdleCallback で ensureIframeSrc()
    //   - click: openWidget() の ensureIframeSrc()
    // 旧実装は init 直後に無条件起動しており、data-preload="click" だと iframe を
    // ロード前にタイマーが満了し "ご利用できません" 誤表示が発生していた。

    window.addEventListener("message", handleIframeMessage);

    document.addEventListener("keydown", function (e) { if (e.key === "Escape" && isOpen) closeWidget(); });

    // デフォルト navigate ハンドラは IIFE 冒頭で設定済み（DOMContentLoaded 競合防止）

    fetchAndApplyConfig();
  }

  // ============================================
  // 公開 API
  // ============================================
  window.AIAgentWidget = {
    open: function () { openWidget(); },
    close: function () { closeWidget(); },
    toggle: function () { toggleWidget(); },
    on: function (event, fn) {
      if (!eventListeners[event]) eventListeners[event] = [];
      eventListeners[event].push(fn);
    },
    /** navigate ハンドラを完全に置き換える（pjax等の外部ナビゲーション用） */
    overrideNavigate: function (fn) {
      eventListeners["navigate"] = [fn];
    },
    sendPageContext: function (data) {
      postToIframe({ type: "page-navigation", data: data });
    },
  };

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
