(() => {
  if (window.__geminiAsaContentLoaded) {
    return;
  }
  window.__geminiAsaContentLoaded = true;

  // PL-134: 画面 HTML → 発言形の変換は conversation_parse.js に切り出し、取込アドオン
  // (ローカルページ) と共有する。manifest で本 file より先に読む。
  const ASA_PARSE = globalThis.ASA_GEMINI_PARSE;
  const deepText = ASA_PARSE.deepText;

  const STATUS_INTERVAL_MS = 1000;
  const HEARTBEAT_INTERVAL_MS = 1000;
  const WIDGET_HOST_ID = "asa-widget-host";
  const WIDGET_LEGACY_POS_KEY = "asa_widget_pos";
  const WIDGET_SIDE_KEY = "asa_widget_side";
  const WIDGET_TOPMOST_KEY = "asa_widget_dialog_topmost";
  // PL-013 CP3: Gemini composer anchor。`input-area-v2` 1 次 (custom element、
  // 1 instance、composer 直 wrapper)、`fieldset.input-area-container` 2 次 fallback。
  // probe survey (2026-05-05) 結果。`chat-window` / `xap-uploader-dropzone.chat-container`
  // は chat 全体 (h=802) で widget 配置に大き過ぎ採用せず。
  const WIDGET_ANCHOR_SELECTORS = [
    'input-area-v2',
    'fieldset.input-area-container',
  ];
  const WIDGET_ANCHOR_RETRY_MS = 1000;
  const PAGE_HOOK_EVENT = 'gemini-asa-network';
  const PAGE_HOOK_REPLAY_EVENT = 'gemini-asa-replay-history';
  const PAGE_HOOK_MESSAGE_SOURCE = 'gemini-asa-page-hook';
  const PAGE_HOOK_MESSAGE_TYPE_NETWORK = 'network';
  const PAGE_HOOK_MESSAGE_TYPE_REPLAY = 'replay-history';
  const PAGE_HOOK_MESSAGE_TYPE_TEMPLATE_STATUS = 'template-status';
  const PAGE_HOOK_MESSAGE_TYPE_HOOK_STATUS = 'hook-status';
  const PAGE_HOOK_MESSAGE_TYPE_FULL_HISTORY_READY = 'full-history-ready';
  const PAGINATE_BATCH_SIZE = 10;

  function findResponseEntry(value) {
    try {
      if (!Array.isArray(value)) {
        return null;
      }
      for (const item of value) {
        if (Array.isArray(item) && item[1] === 'hNvQHb' && typeof item[2] === 'string') {
          return item;
        }
      }
      for (const item of value) {
        const found = findResponseEntry(item);
        if (found) {
          return found;
        }
      }
    } catch (_e) {}
    return null;
  }

  const state = {
    lastUrl: location.href,
    lastStatusKey: '',
    networkMessagesByConversationId: {},
    historyTemplateByConversationId: {},
    fullHistoryReadyByConversationId: {},
    historyReadyWaitersByConversationId: {},
    lastCaptureDebug: {},
    pageHookMessageCount: 0,
    hookStatus: {},
    lastNetworkParseStatus: 'not_started',
    lastNetworkParseSample: '',
    widgetRoot: null,
    widgetHost: null,
    widgetAnchor: null,
    widgetObserver: null,
    widgetSentinelInterval: null,
    widgetLang: "ja",
  };

  try { localStorage.removeItem(WIDGET_LEGACY_POS_KEY); } catch (_e) { void 0; }

  // PL-013 CP3: tooltip 文言は旧 OK prompt 文言を流用 (translations.py の
  // settings.tooltip / realtime.tooltip / 旧 ok.save)。
  // PL-058: scroll_snap tooltip を撤去、 toggle_topmost tooltip を追加。
  const WIDGET_I18N = {
    ja: { save: "会話を保存", settings: "環境設定", realtime: "キャプチャモード", toggle_topmost: "ダイアログを最前面に固定" },
    en: { save: "Save conversation", settings: "Preferences", realtime: "Capture mode", toggle_topmost: "Pin dialogs to front" },
  };

  function injectPageHook() {
    try {
      if (window.__geminiAsaPageHookInstalled) {
        return;
      }
      if (document.documentElement && document.documentElement.dataset && document.documentElement.dataset.geminiAsaPageHookInjected === '1') {
        return;
      }
      const script = document.createElement('script');
      script.src = chrome.runtime.getURL('page_hook.js');
      script.async = false;
      script.onload = () => {
        try {
          script.remove();
        } catch (_e) {}
      };
      if (document.documentElement && document.documentElement.dataset) {
        document.documentElement.dataset.geminiAsaPageHookInjected = '1';
      }
      (document.documentElement || document.head || document.body).appendChild(script);
    } catch (_e) {}
  }

  function getConversationIdFromUrl(url) {
    try {
      const value = String(url || '');
      const match = value.match(/\/app\/([^/?#]+)/);
      return match ? match[1] : '';
    } catch (_e) {
      return '';
    }
  }

  // PL-120: 共有ページ (/share/<id>) 判定。 共有は通常会話と DOM 構造が異なり、
  // assistant turn が <model-response> でなく <response-container> (share-turn-viewer 配下) に
  // 入るため、 DOM capture の assistant 容器を共有時のみ差し替える。 共有 URL は cid 不在で
  // batchexecute replay が走らず DOM fallback (buildDomMessages) に落ちるので、 DOM walk 側で吸収する。
  function isGeminiSharePage() {
    try { return /^\/share\//.test(location.pathname); } catch (_e) { return false; }
  }

  function normalizeConversationId(value) {
    const raw = String(value || '').trim();
    if (!raw) {
      return '';
    }
    const fromUrl = getConversationIdFromUrl(raw);
    if (fromUrl) {
      return fromUrl;
    }
    return raw.replace(/^c_/, '');
  }

  function getConversationIdVariants(value) {
    const raw = String(value || '').trim();
    const normalized = normalizeConversationId(raw);
    const variants = [];
    if (normalized) {
      variants.push(normalized, `c_${normalized}`);
    }
    if (raw && !variants.includes(raw)) {
      variants.push(raw);
    }
    return variants;
  }

  function setConversationStateBucket(map, conversationId, payload) {
    for (const key of getConversationIdVariants(conversationId)) {
      map[key] = payload;
    }
  }

  function getConversationStateBucket(map, conversationId) {
    for (const key of getConversationIdVariants(conversationId)) {
      if (Object.prototype.hasOwnProperty.call(map, key)) {
        return map[key];
      }
    }
    return null;
  }

  function registerHistoryReadyWaiter(conversationId, resolve) {
    const variants = getConversationIdVariants(conversationId);
    if (!variants.length) {
      return () => {};
    }
    const entry = { resolve };
    for (const key of variants) {
      let bucket = state.historyReadyWaitersByConversationId[key];
      if (!Array.isArray(bucket)) {
        bucket = [];
        state.historyReadyWaitersByConversationId[key] = bucket;
      }
      bucket.push(entry);
    }
    return () => {
      for (const key of variants) {
        const bucket = state.historyReadyWaitersByConversationId[key];
        if (!Array.isArray(bucket)) continue;
        const idx = bucket.indexOf(entry);
        if (idx >= 0) bucket.splice(idx, 1);
      }
    };
  }

  function notifyHistoryReadyWaiters(conversationId) {
    const variants = getConversationIdVariants(conversationId);
    const fired = new Set();
    for (const key of variants) {
      const bucket = state.historyReadyWaitersByConversationId[key];
      if (!Array.isArray(bucket)) continue;
      for (const entry of bucket) {
        if (entry && typeof entry.resolve === 'function' && !fired.has(entry)) {
          fired.add(entry);
          try { entry.resolve(); } catch (_e) {}
        }
      }
      state.historyReadyWaitersByConversationId[key] = [];
    }
  }

  function setFullHistoryReady(conversationId) {
    setConversationStateBucket(state.fullHistoryReadyByConversationId, conversationId, true);
    notifyHistoryReadyWaiters(conversationId);
  }

  function getNestedString(value, path) {
    let current = value;
    for (const key of path) {
      if (!Array.isArray(current) || current.length <= key) {
        return '';
      }
      current = current[key];
    }
    return typeof current === 'string' ? current : '';
  }

  function normalizeNetworkText(text) {
    return String(text || '')
      .replace(/\[cite_start\]/g, '')
      .replace(/\[cite:\s*[0-9,\s]+\]/g, '')
      .replace(/\r/g, '')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
  }

  // PL-068: Gemini network 経路の YouTube 引用 turn は raw text 内に
  // `http://googleusercontent.com/youtube_content/<N>` placeholder URL のみ持ち、
  // 真の card data (= title / video_id / watch_url / channel / view_count / thumbnail URL)
  // は turn[3] subtree の structured array に positional encoding で格納される
  // (= 末尾 thumbnail URL `ytimg.com/vi/<id>/maxresdefault.jpg` + index 2 が
  // `youtube.com/watch?v=<id>` の 13+ element shape)。
  // capture mode (= DOM 経路) は site renderer 出力 markdown を取得するため
  // 通常保存 (= network text path) との render parity 欠落、 PL-056 root family 再来。
  // 本 helper で turn[3] subtree を再帰 walk + card data を document order で抽出、
  // text 内 placeholder を `applyYoutubeCardReplacements` で site renderer 等価
  // markdown に置換する。
  function extractYoutubeCards(node) {
    const cards = [];
    function walk(value) {
      if (Array.isArray(value)) {
        if (
          value.length >= 13 &&
          typeof value[0] === 'string' &&
          typeof value[1] === 'string' &&
          typeof value[2] === 'string' &&
          /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=/i.test(value[2]) &&
          typeof value[3] === 'string'
        ) {
          const tail = value[value.length - 1];
          if (typeof tail === 'string' && /^https?:\/\/i\.ytimg\.com\/vi\//i.test(tail)) {
            cards.push({
              title: value[0],
              video_id: value[1],
              watch_url: value[2],
              channel: value[3],
              views: typeof value[7] === 'number' ? value[7] : null,
              thumbnail_url: tail,
            });
            return;
          }
        }
        for (let i = 0; i < value.length; i++) walk(value[i]);
      } else if (value && typeof value === 'object') {
        for (const k of Object.keys(value)) walk(value[k]);
      }
    }
    walk(node);
    return cards;
  }

  const YOUTUBE_LOGO_URL = 'https://www.gstatic.com/images/branding/productlogos/youtube/v9/192px.svg';

  function applyYoutubeCardReplacements(text, cards) {
    if (!text || !Array.isArray(cards) || !cards.length) return text || '';
    let cardIdx = 0;
    return String(text).replace(
      /https?:\/\/(?:[a-z0-9.-]+\.)?googleusercontent\.com\/youtube_content\/\d+/gi,
      function (match) {
        if (cardIdx >= cards.length) return match;
        const card = cards[cardIdx++];
        const viewsStr = (card.views != null && Number.isFinite(card.views))
          ? card.views.toLocaleString('ja-JP') + ' 回の視聴'
          : '';
        const channelLine = viewsStr ? card.channel + ' · ' + viewsStr : card.channel;
        return '[![画像](' + YOUTUBE_LOGO_URL + ')\n' + channelLine + '](' + card.watch_url + ')\n\n![画像](' + card.thumbnail_url + ')';
      }
    );
  }

  // PL-056: AI 生 markdown source を site DOM render 等価形に正規化する。
  // reload 後 (= network text path) でのみ raw 残置していた markup を
  // reload 前 (= site rendered DOM 経由) と一致させる目的。
  // 対処 3 件:
  //   1) <Image alt="X" src="Y"/> tag →
  //        公開 URL (http/https): "X"\n"Y" 2 行 quoted text (user 明示指定形)
  //        内部 ID (image_agent_tag_*): [AI生成: X] (PL-051 / 段 C DOM path 等価)
  //   2) 内部画像 URL (= googleusercontent /image_generation_content/<id> = AI 生成、
  //      /image_collection/image_retrieval/<id> = web 拾い物の引用画像) → [AI生成]
  //      ※ PL-081/083: web 拾い物 (image_retrieval / image_agent_tag 形 anchor) は本 helper の
  //        手前で pl081ResolveWebImages が各 turn の network turn[3] から ID 結合で gstatic を
  //        解決し中間マーカー化する。 ここに残るのは解決不能な orphan anchor のみで [AI生成]
  //        text へ degrade (= 表示されない参照、 画像は出さない)。
  //   3) LaTeX 数式 (PL-084): display ($$...$$ / \[...\]) と inline ($...$ / \(...\)) を
  //      Unicode 化 + \frac / \left \right 平坦化で可読化 (例: $-2^\circ\text{C}$ → −2°C、
  //      $$\nabla\rho=\left(\frac{\partial\rho}{\partial x}\right)$$ → ∇ρ=(∂ρ/∂x))
  // 残課題: 行頭 `* ` list は別 root 候補 (= asa_core renderer / 改行 spec) で本 helper scope 外。
  function normalizeNetworkMarkdown(text) {
    if (!text) return '';
    let result = String(text);
    result = result.replace(
      /<Image\b([^>]*?)\/?>(?:<\/Image>)?/gi,
      function (match, attrs) {
        const altMatch = attrs.match(/\balt="([^"]*)"/i);
        const srcMatch = attrs.match(/\bsrc="([^"]*)"/i);
        const alt = altMatch ? altMatch[1] : '';
        const src = srcMatch ? srcMatch[1] : '';
        const isInternalPlaceholder = /^image_agent_tag_/i.test(src);
        const isPublicUrl = /^https?:\/\//i.test(src);
        if (isInternalPlaceholder) {
          return alt ? '[AI生成: ' + alt + ']' : '[AI生成]';
        }
        if (isPublicUrl) {
          return alt ? '"' + alt + '"\n"' + src + '"' : '"' + src + '"';
        }
        if (alt && src) return '"' + alt + '"\n"' + src + '"';
        if (src) return '"' + src + '"';
        if (alt) return '"' + alt + '"';
        return '';
      }
    );
    result = result.replace(
      /https?:\/\/(?:[a-z0-9.-]+\.)?googleusercontent\.com\/(?:image_generation_content|image_collection\/image_retrieval)\/\d+/gi,
      '[AI生成]'
    );
    // PL-086: 手前の pl086ResolveShopping が解決し切らなかった shopping_content 素 URL は
    // 露出させず除去 (保険。 通常は resolver で消費済)。
    result = result.replace(
      /https?:\/\/(?:[a-z0-9.-]+\.)?googleusercontent\.com\/shopping_content\/\d+_(?:card|link)/gi,
      ''
    );
    // PL-084: 数式区切りを display ($$ / \[ \]、 改行を含みうる) → inline ($ / \( \)) の順で処理。
    // inline regex は [^$\n] で改行を跨げず block display を取り逃すのが raw 露出の root だったため、
    // 改行を含む display 形を先に [\s\S] で捕まえ、 前後空行で独立段落 (中央寄せ相当) にする。
    result = result.replace(/\$\$([\s\S]+?)\$\$/g, function (match, body) {
      return '\n\n' + latexInlineToUnicode(body) + '\n\n';
    });
    result = result.replace(/\\\[([\s\S]+?)\\\]/g, function (match, body) {
      return '\n\n' + latexInlineToUnicode(body) + '\n\n';
    });
    result = result.replace(/\\\(([\s\S]+?)\\\)/g, function (match, body) {
      return latexInlineToUnicode(body);
    });
    result = result.replace(/\$([^$\n]+)\$/g, function (match, body) {
      return latexInlineToUnicode(body);
    });
    return result;
  }

  // PL-084: LaTeX コマンド → Unicode 記号の置換表。 適用順に意味があるため配列で保持する
  // (= superset を先に置く。 例 \int を \in より先に処理しないと \int が壊れる)。 backslash で
  // 始まるキーは部分一致でも単語境界の役を果たす (例 \rho は \varrho 内に現れない)。 本表は
  // \left/\right 除去の後に適用するため (\le が \left を壊さないよう)、 矢印 (\rightarrow 等) は
  // 除去側の lookahead で温存され本表で変換される。
  const PL084_LATEX_MAP = [
    // 矢印 (\leftrightarrow → \leftarrow → \rightarrow の順)
    ['\\leftrightarrow', '↔'], ['\\Leftrightarrow', '⇔'],
    ['\\rightarrow', '→'], ['\\leftarrow', '←'], ['\\Rightarrow', '⇒'], ['\\Leftarrow', '⇐'],
    ['\\to', '→'], ['\\gets', '←'], ['\\mapsto', '↦'],
    // 大文字ギリシャ
    ['\\Gamma', 'Γ'], ['\\Delta', 'Δ'], ['\\Theta', 'Θ'], ['\\Lambda', 'Λ'], ['\\Xi', 'Ξ'],
    ['\\Pi', 'Π'], ['\\Sigma', 'Σ'], ['\\Upsilon', 'Υ'], ['\\Phi', 'Φ'], ['\\Psi', 'Ψ'], ['\\Omega', 'Ω'],
    // 小文字ギリシャ (var 形を通常形より先に)
    ['\\varepsilon', 'ε'], ['\\vartheta', 'ϑ'], ['\\varphi', 'φ'], ['\\varrho', 'ϱ'], ['\\varsigma', 'ς'], ['\\varpi', 'ϖ'],
    ['\\alpha', 'α'], ['\\beta', 'β'], ['\\gamma', 'γ'], ['\\delta', 'δ'], ['\\epsilon', 'ε'],
    ['\\zeta', 'ζ'], ['\\eta', 'η'], ['\\theta', 'θ'], ['\\iota', 'ι'], ['\\kappa', 'κ'],
    ['\\lambda', 'λ'], ['\\mu', 'μ'], ['\\nu', 'ν'], ['\\xi', 'ξ'], ['\\pi', 'π'],
    ['\\rho', 'ρ'], ['\\sigma', 'σ'], ['\\tau', 'τ'], ['\\upsilon', 'υ'], ['\\phi', 'φ'],
    ['\\chi', 'χ'], ['\\psi', 'ψ'], ['\\omega', 'ω'],
    // 大型演算子
    ['\\partial', '∂'], ['\\nabla', '∇'], ['\\sum', '∑'], ['\\prod', '∏'], ['\\oint', '∮'], ['\\int', '∫'],
    // 二項演算子・関係子 (superset 先: \cdots>\cdot, \leq>\le, \geq>\ge, \neq>\ne, \infty>\in)
    ['\\cdots', '⋯'], ['\\cdot', '·'], ['\\ldots', '…'], ['\\dots', '…'],
    ['\\times', '×'], ['\\div', '÷'], ['\\pm', '±'], ['\\mp', '∓'], ['\\ast', '∗'], ['\\star', '⋆'],
    ['\\circ', '∘'], ['\\bullet', '•'],
    ['\\leq', '≤'], ['\\le', '≤'], ['\\geq', '≥'], ['\\ge', '≥'], ['\\neq', '≠'], ['\\ne', '≠'],
    ['\\approx', '≈'], ['\\equiv', '≡'], ['\\cong', '≅'], ['\\simeq', '≃'], ['\\sim', '∼'], ['\\propto', '∝'],
    ['\\infty', '∞'], ['\\notin', '∉'], ['\\in', '∈'], ['\\ni', '∋'],
    ['\\subseteq', '⊆'], ['\\subset', '⊂'], ['\\supseteq', '⊇'], ['\\supset', '⊃'],
    ['\\cup', '∪'], ['\\cap', '∩'], ['\\emptyset', '∅'], ['\\forall', '∀'], ['\\exists', '∃'],
    ['\\angle', '∠'], ['\\perp', '⊥'], ['\\parallel', '∥'], ['\\langle', '⟨'], ['\\rangle', '⟩'],
    // エスケープ記号
    ['\\%', '%'], ['\\&', '&'], ['\\#', '#'], ['\\{', '{'], ['\\}', '}'], ['\\$', '$'],
  ];

  // PL-084: LaTeX 断片を可読 Unicode へ変換する (本家 MathJax の描画再現ではなく、 記号化 +
  // レイアウト平坦化で「読める」状態を担保。 完全描画は dep/保守判断で別途)。 inline/block 共用。
  function latexInlineToUnicode(latex) {
    let s = String(latex);
    // 1) 書体コマンドは中身のみ残す
    s = s.replace(/\\(?:text|mathrm|mathbf|mathit|mathcal|operatorname)\{([^{}]*)\}/g, '$1');
    // 2) \frac{a}{b} → a/b (非ネスト。 上下分数レイアウトは平坦化)
    s = s.replace(/\\[dt]?frac\{([^{}]*)\}\{([^{}]*)\}/g, '$1/$2');
    // 3) \sqrt{x} → √(x)
    s = s.replace(/\\sqrt\{([^{}]*)\}/g, '√($1)');
    // 4) \left( \right) 等の可変サイズ括弧綴りを除去し括弧文字のみ残す (\left. \right. は不可視)。
    // MAP より前に行う (\le→≤ が \left 内の \le を壊すため)。 直後が英字の \rightarrow / \leftarrow は
    // 矢印なので除去せず後段 MAP で変換する (lookahead で英字続きを除外)。
    s = s.replace(/\\left\s*\./g, '').replace(/\\right\s*\./g, '');
    s = s.replace(/\\(?:left|right)(?![a-zA-Z])\s*/g, '');
    // 5) 記号置換 (矢印・ギリシャ・演算子)
    for (let i = 0; i < PL084_LATEX_MAP.length; i++) {
      s = s.split(PL084_LATEX_MAP[i][0]).join(PL084_LATEX_MAP[i][1]);
    }
    // 6) 上付き/下付きの波括弧を外す (x^{2} → x^2、 a_{i} → a_i)
    s = s.replace(/\^\{([^{}]*)\}/g, '^$1').replace(/_\{([^{}]*)\}/g, '_$1');
    // 7) 度: ^∘ → ° (4 で \circ → ∘ 済。 単独 ∘ は環演算子として温存)
    s = s.replace(/\^∘/g, '°');
    // 8) 細スペース等の制御綴りを除去 / 改行コマンドは空白へ (\\ を先に、 次に \ + 空白)
    s = s.replace(/\\\\/g, ' ').replace(/\\[,;:!]/g, '').replace(/\\ /g, '').replace(/\\quad|\\qquad/g, ' ');
    // 9) ハイフン → 数学マイナス、 余分な空白除去
    s = s.replace(/-/g, '−').replace(/\s+/g, '');
    return s;
  }

  // PL-048 Phase 2: AI 応答 raw markdown 内の fenced code block (= ``` 囲み)
  // を抽出して別配列 (code_blocks) に格納、 本文中は marker [[ASA_CODE_BLOCK_<idx>]]
  // で置換する。 python-markdown の fenced_code 拡張は 4-space (以上) indent
  // された fence opener を認識せず (= 例: list item 内 indent fence)、 中身が
  // markdown text として誤解釈される base spec 挙動を bypass する目的。
  // (= 例: list item 内 indented fence で `__pycache__` が太字化 / `*.pyc` が
  // italic化 / `# 環境変数ファイル` が見出し化 / 末尾 ``` が raw 残置)
  //
  // 取込 layer = service 固有 raw markdown 経路なので Gemini 拡張側で実装。
  // DOM path (PL-016) と同じ marker chain を network path にも適用、 既存
  // asa_core _render_with_code_blocks_html 経路で marker → <pre><code> 直接生成。
  //
  // fence は 0〜N space indent で出現、 body line は同 indent prefix を持つ場合
  // 多 (= 例: list item 内 4-space indent fence)、 抽出時に prefix を strip して
  // 真の body のみ code_blocks[idx].body に格納する。
  function extractFencedCodeBlocks(text) {
    if (!text) return { text: text || '', code_blocks: [] };
    const codeBlocks = [];
    const result = String(text).replace(
      /^([ \t]*)```([^\n]*)\n([\s\S]*?)\n\1```[ \t]*$/gm,
      function (match, indent, lang, body) {
        const indentStr = indent || '';
        let cleanBody = body;
        if (indentStr) {
          const escIndent = indentStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
          const stripRe = new RegExp('^' + escIndent, 'gm');
          cleanBody = body.replace(stripRe, '');
        }
        const idx = codeBlocks.length;
        codeBlocks.push({ body: cleanBody, lang: (lang || '').trim() });
        return indentStr + '[[ASA_CODE_BLOCK_' + idx + ']]';
      }
    );
    return { text: result, code_blocks: codeBlocks };
  }

  // PL-079: Gemini は blockquote (`>` 記法) を引用バー無し (= 内容の太字/通常テキスト) で
  // 描画するため、 ASA の標準 markdown 描画 (左縦線バー + md/txt の `>` 残置) と乖離する。
  // network markdown の `>` 行頭 prefix を剥がし site 等価 (普通の段落 + 箇条書き) に正規化する。
  // 必ず extractFencedCodeBlocks 後に適用する (= code block は `[[ASA_CODE_BLOCK_N]]` marker 化済の
  // ため、 code 内 `>` を誤剥がししない順序保証)。
  // blockquote 内の tight list (段落直後・空行無しの `*   ` 列) は asa_core の sane_lists が
  // list 開始と認めず `*` を生露出するため、 de-quote 時に list 直前へ空行を 1 行注入して
  // <ul> 描画を回復する。
  function pl079DeBlockquote(text) {
    if (!text || text.indexOf('>') === -1) return text || '';
    const isList = (l) => /^\s*([*+-]|\d+\.)\s/.test(l);
    const lines = String(text).split('\n');
    const out = [];
    let i = 0;
    while (i < lines.length) {
      if (/^>\s?/.test(lines[i])) {
        const block = [];
        while (i < lines.length && /^>\s?/.test(lines[i])) {
          block.push(lines[i].replace(/^>\s?/, ''));
          i++;
        }
        for (let j = 0; j < block.length; j++) {
          const prev = out.length ? out[out.length - 1] : '';
          if (isList(block[j]) && prev.trim() !== '' && !isList(prev)) {
            out.push('');
          }
          out.push(block[j]);
        }
      } else {
        out.push(lines[i]);
        i++;
      }
    }
    return out.join('\n');
  }

  // PL-017: network response の turn 構造から添付 file 名を抽出する。
  // 各 file は `turn[2][0][4][0][3][N]` の配列として格納され、index `[2]` に
  // 元 file 名 (拡張子込み、UTF-8 完全形) が入る。type code (`[1]`) は
  // 1 = 画像 / 11 = PDF などサービス内部分類で本実装では参照しない (file 名のみ採取)。
  // [4][0][4] は会話累積 (cumulative) なので使わず、必ず [4][0][3] (current turn) を読む。
  function extractFileNamesFromTurn(turn) {
    try {
      const fileArr = (
        Array.isArray(turn) &&
        Array.isArray(turn[2]) &&
        Array.isArray(turn[2][0]) &&
        Array.isArray(turn[2][0][4]) &&
        Array.isArray(turn[2][0][4][0])
      ) ? turn[2][0][4][0][3] : null;
      if (!Array.isArray(fileArr)) return [];
      const names = [];
      for (const f of fileArr) {
        if (Array.isArray(f) && typeof f[2] === 'string' && f[2]) {
          names.push(f[2]);
        }
      }
      return names;
    } catch (_e) {
      return [];
    }
  }

  function findFirstJsonValue(text) {
    const source = String(text || '');
    const start = source.search(/[\[{]/);
    if (start < 0) return '';
    let depth = 0;
    let inString = false;
    let escaped = false;
    for (let i = start; i < source.length; i++) {
      const ch = source[i];
      if (inString) {
        if (escaped) {
          escaped = false;
        } else if (ch === '\\') {
          escaped = true;
        } else if (ch === '"') {
          inString = false;
        }
        continue;
      }
      if (ch === '"') {
        inString = true;
        continue;
      }
      if (ch === '[' || ch === '{') {
        depth++;
        continue;
      }
      if (ch === ']' || ch === '}') {
        depth--;
        if (depth === 0) {
          return source.slice(start, i + 1);
        }
      }
    }
    return '';
  }

  function extractOuterJsonFromBatchexecute(text) {
    try {
      const source = String(text || '').replace(/^\)\]\}'\s*/, '');
      return findFirstJsonValue(source);
    } catch (_e) {}
    return '';
  }

  function orderNetworkTurns(turns) {
    try {
      const records = [];
      const byCurrentId = new Map();
      const referencedIds = new Set();
      for (const turn of turns) {
        if (!Array.isArray(turn)) {
          continue;
        }
        const currentId = getNestedString(turn, [0, 1]);
        const nextId = getNestedString(turn, [1, 1]);
        const record = { turn, currentId, nextId };
        records.push(record);
        if (currentId && !byCurrentId.has(currentId)) {
          byCurrentId.set(currentId, record);
        }
        if (nextId) {
          referencedIds.add(nextId);
        }
      }
      if (!records.length) {
        return [];
      }

      const ordered = [];
      const seen = new Set();
      const heads = records.filter(record => record.currentId && !referencedIds.has(record.currentId));

      function walk(startRecord) {
        let current = startRecord;
        while (current && !seen.has(current.turn)) {
          ordered.push(current.turn);
          seen.add(current.turn);
          current = current.nextId ? byCurrentId.get(current.nextId) || null : null;
        }
      }

      for (const head of heads) {
        walk(head);
      }
      for (const record of records) {
        if (!seen.has(record.turn)) {
          walk(record);
        }
      }
      return ordered.length ? ordered : turns;
    } catch (_e) {}
    return turns;
  }

  function extractNetworkMessages(rawBody) {
    try {
      const responseText = String(rawBody || '');
      state.lastNetworkParseSample = responseText.slice(0, 200);
      if (!responseText.includes('hNvQHb')) {
        state.lastNetworkParseStatus = 'missing_hNvQHb_marker';
        return null;
      }
      const outerJson = extractOuterJsonFromBatchexecute(responseText);
      if (!outerJson) {
        state.lastNetworkParseStatus = 'outer_json_not_found';
        return null;
      }
      const outer = JSON.parse(outerJson);
      const entry = findResponseEntry(outer);
      if (!Array.isArray(entry) || typeof entry[2] !== 'string') {
        state.lastNetworkParseStatus = 'hNvQHb_entry_not_found';
        return null;
      }
      const inner = JSON.parse(entry[2]);
      const turns = Array.isArray(inner) && Array.isArray(inner[0]) ? inner[0] : null;
      if (!Array.isArray(turns)) {
        state.lastNetworkParseStatus = 'turns_not_array';
        return null;
      }
      const cursorToken = (Array.isArray(inner) && typeof inner[1] === 'string' && inner[1]) ? inner[1] : null;
      const endOfHistory = !cursorToken || turns.length < PAGINATE_BATCH_SIZE;

      const orderedTurns = orderNetworkTurns(turns).reverse();

      const messages = [];
      const droppedTally = {};   // PL-133 横展開: 中身を持つ turn を捨てた件数
      let conversationId = '';
      for (const turn of orderedTurns) {
        if (!Array.isArray(turn)) continue;
        if (!conversationId) {
          conversationId = getNestedString(turn, [0, 0]) || getNestedString(turn, [1, 0]);
        }
        // PL-051 Phase 1.1: Gemini は画像生成 turn の text path に
        // `http(s)://(...)googleusercontent.com/image_generation_content/<id>` 形式の
        // internal URL placeholder を返す。 そのまま msg.text 化すると log で URL のみ表示で識別性ゼロ。
        // 全 URL を [AI生成] 文字列に substitute して 3 版 contract と整合 (= ChatGPT [画像] / Claude [AI生成] 相当)。
        // title 取得は別 cycle (現段階は枠保持の仮 placeholder)。
        // PL-056: 上記 URL substitute + <Image> tag + LaTeX inline 等の site render 等価
        // 正規化を `normalizeNetworkMarkdown` に集約、 user/assistant 双方 text に対称適用。
        const userTextRaw = normalizeNetworkMarkdown(normalizeNetworkText(getNestedString(turn, [2, 0, 0])));
        // PL-068: turn[3] subtree から YouTube card structured data 抽出、
        // assistant raw text 内の youtube_content placeholder を capture mode 等価
        // markdown (= logo + channel + views + thumbnail) に置換。
        const youtubeCards = extractYoutubeCards(turn[3]);
        const assistantTextRaw0 = normalizeNetworkText(getNestedString(turn, [3, 0, 0, 1, 0]));
        // PL-078: 本文中の画像 placeholder (image_generation_content URL) を同 turn の gg URL と
        // 出現順で対応づけ中間マーカー化する。 normalizeNetworkMarkdown の placeholder→[AI生成]
        // 置換より前に実施 (順序依存)。
        // PL-125: コード実行生成チャートは本文に `[image-tag: code-generated-image-<n>-<id>]` の
        // 引換券だけ残り、 実 gg URL は turn[3] 構造側にのみ在る (DOM の <img src> と同一)。
        // image_generation_content と同じ gg URL プールで解決する (両形を 1 pass・共有 index)。
        const ggUrlsInline = (/image_generation_content/.test(assistantTextRaw0)
          || /\[image-tag:\s*code-generated-image-\d+/.test(assistantTextRaw0))
          ? pl078gGgUrlsFromTurn(turn[3]) : null;
        const assistantTextMarked = ggUrlsInline
          ? pl078gMarkInlineImages(assistantTextRaw0, ggUrlsInline) : assistantTextRaw0;
        // PL-081: web 引用画像 (検索 grounding) は本文に [Image of <説明>] placeholder だけ残し、
        // 実 URL (gstatic licensed-image) は turn[3] の構造ノードに在る。 説明文一致で対応づけ
        // 中間マーカー化する (生成画像 gg と対称の network 完結、 DOM 非依存)。
        const assistantTextWeb = pl081ResolveWebImages(assistantTextMarked, turn[3]);
        // PL-086: ショッピング商品カルーセル。 本文の shopping_content/<N>_card / _link placeholder を
        // turn[3] の商品ブロック (block[N]) で解決 — _card は画像+商品名+価格+店名+評価+販売リンクの
        // カードに、 _link は本文中の販売リンク (テキスト) に。 web 引用と対称の network 完結。
        const assistantTextShop = pl086ResolveShopping(assistantTextWeb, turn[3]);
        const assistantTextWithCards = applyYoutubeCardReplacements(assistantTextShop, youtubeCards);
        const assistantTextRaw = normalizeNetworkMarkdown(assistantTextWithCards);
        // PL-048 Phase 2: fenced code block を marker 化 + 別配列に抽出 (= python-markdown indented fence trap bypass)
        const { text: userTextDq, code_blocks: userCodeBlocks } = extractFencedCodeBlocks(userTextRaw);
        const { text: assistantTextDq, code_blocks: assistantCodeBlocks } = extractFencedCodeBlocks(assistantTextRaw);
        // PL-079: blockquote de-quote (引用バー乖離 + blockquote 内 tight list の `*` 露出 解消)。
        // code block marker 化後に適用 (code 内 `>` 誤剥がし回避)。
        const userText = pl079DeBlockquote(userTextDq);
        const assistantText = pl079DeBlockquote(assistantTextDq);
        // PL-017: 添付 file 名は network response の `turn[2][0][4][0][3]` に
        // 全種別 (画像 / PDF / その他) 共通で格納されている (各要素 [2] = 元 file 名)。
        // DOM 経路は画像で button[aria-label] が UI 操作ラベルを返すため信用できず、
        // network 経路を一次情報源として優先する。
        const fileNames = extractFileNamesFromTurn(turn);
        const placeholders = fileNames.map(n => '[添付ファイル: ' + n + ']').join('\n\n');
        if (userText) {
          const text = placeholders ? placeholders + '\n\n---\n\n' + userText : userText;
          const userMsg = { role: 'user', text };
          if (userCodeBlocks && userCodeBlocks.length) userMsg.code_blocks = userCodeBlocks;
          messages.push(userMsg);
        } else if (placeholders) {
          // file-only upload: userText が空、添付のみ
          messages.push({ role: 'user', text: placeholders });
        } else if (assistantText) {
          // userText 空 + 添付情報も取れない場合 (parser 失敗 / DOM 経路 fallback 候補):
          // assistant 存在 turn は placeholder user を push して turn 順序を維持
          messages.push({ role: 'user', text: '[添付ファイル]', _attachmentPlaceholder: true });
        }
        if (assistantText) {
          const assistantMsg = { role: 'assistant', text: assistantText };
          if (assistantCodeBlocks && assistantCodeBlocks.length) assistantMsg.code_blocks = assistantCodeBlocks;
          messages.push(assistantMsg);
        } else {
          // PL-051/078: AI 生成画像 turn は text path (turn[3][0][0][1][0]) 空、 画像 gg URL が
          // turn[3] 配下に埋め込まれる。 gg URL を中間マーカー化して image_blocks 経路に載せる
          // (background が取得・埋込)。 gg URL が無く lh3 のみ在る turn は従来どおり [AI生成] 印。
          const ggUrlsImageTurn = pl078gGgUrlsFromTurn(turn[3]);
          if (ggUrlsImageTurn.length) {
            messages.push({
              role: 'assistant',
              text: ggUrlsImageTurn.map(u => '[[ASA_GEMIMG:' + u + ']]').join('\n\n'),
            });
          } else {
            try {
              const slot3Json = JSON.stringify(turn[3] !== undefined ? turn[3] : null);
              if (slot3Json && slot3Json.indexOf('lh3.googleusercontent.com') !== -1) {
                messages.push({ role: 'assistant', text: '[AI生成]' });
              } else if (slot3Json && slot3Json.length > 2) {
                // PL-133 横展開: 本文も既知の画像 URL 形式も取れなかった assistant turn。
                // 中身は在るのに発言枠ごと消えるので、 件数だけ残して合図にする
                // (従来は無言 drop、 ChatGPT の PL-133 と同じ盲点)。
                droppedTally.assistant_no_text_no_image =
                  (droppedTally.assistant_no_text_no_image || 0) + 1;
                droppedTally.slot3_chars =
                  (droppedTally.slot3_chars || 0) + slot3Json.length;
              }
            } catch (_e) {}
          }
        }
      }

      if (!conversationId || !messages.length) {
        state.lastNetworkParseStatus = 'conversation_or_messages_missing';
        return null;
      }
      state.lastNetworkParseStatus = 'parsed_ok';
      if (Object.keys(droppedTally).length > 0) {
        // 普段は無出力。 出た事自体が「Gemini 側の構造が変わったかもしれない」の合図。
        try {
          console.log('[PL-133-drop]', JSON.stringify({
            t: Date.now(), kept: messages.length, dropped: droppedTally,
          }));
        } catch (_e) {}
      }
      return { conversationId, messages, endOfHistory };
    } catch (_e) {
      state.lastNetworkParseStatus = 'parse_exception';
      return null;
    }
  }

  // PL-053 Step 1: site lazy unmount で上方向 scroll 部分 fetch が全件 cache を
  // 上書き縮小する root を防ぐ shrink ガード。 trade-off (= 新 turn partial
  // response 1-2 件 block) 対策で「prev 0 = 初期 / 既存 < CACHE_SHRINK_THRESHOLD
  // = 新 turn 中の小規模 case」 では縮小許可、 既存 ≥ threshold で新 < 既存
  // のときのみ skip。
  const CACHE_SHRINK_THRESHOLD = 30;
  function updateNetworkMessagesCacheGuarded(conversationId, newMessages) {
    if (!conversationId || !Array.isArray(newMessages)) return;
    const existing = getConversationStateBucket(state.networkMessagesByConversationId, conversationId);
    const existingLen = Array.isArray(existing) ? existing.length : 0;
    if (existingLen >= CACHE_SHRINK_THRESHOLD && newMessages.length < existingLen) return;
    setConversationStateBucket(state.networkMessagesByConversationId, conversationId, newMessages);
  }

  function handlePageHookEvent(event) {
    try {
      const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : null;
      if (!detail) return;
      const parsed = extractNetworkMessages(detail.body || '');
      if (!parsed) return;
      updateNetworkMessagesCacheGuarded(parsed.conversationId, parsed.messages);
      // PL-036 Phase 4: passive sniff 経路の fullHistoryReady set 削除。
      // endOfHistory = !cursorToken || turns.length < PAGINATE_BATCH_SIZE は
      // 末尾 end と上方 end を構造的に区別不能で、UI の上方向 scroll fetch
      // (cursor null + turns < 10) も全件 signal 化する false positive trap。
      // bucket は部分 truncate のまま fullHistoryReady=true → warm short-circuit
      // hit → 下切れ NEGATIVE (274 msg conv 上 scroll save で diagnostic 確定)。
      // active replay (replayHistoryRequest) 完遂後の synth emit 経由 set のみ
      // 真の全件 signal として残す (page_hook.js MESSAGE_TYPE_FULL_HISTORY_READY)。
    } catch (_e) {}
  }

  function handlePageHookMessage(event) {
    try {
      if (event.source !== window) return;
      const data = event && event.data && typeof event.data === 'object' ? event.data : null;
      if (!data || data.source !== PAGE_HOOK_MESSAGE_SOURCE) return;
      if (data.type === PAGE_HOOK_MESSAGE_TYPE_HOOK_STATUS) {
        state.hookStatus = {
          installed: !!data.installed,
          candidateRequestCount: Number(data.candidateRequestCount || 0),
          candidateResponseCount: Number(data.candidateResponseCount || 0),
          templateCount: Number(data.templateCount || 0),
          lastCandidateUrl: String(data.lastCandidateUrl || ''),
          lastTemplateUrl: String(data.lastTemplateUrl || ''),
          lastTemplateReason: String(data.lastTemplateReason || ''),
        };
        return;
      }
      if (data.type === PAGE_HOOK_MESSAGE_TYPE_TEMPLATE_STATUS) {
        const conversationId = normalizeConversationId(data.conversationId || '');
        if (conversationId) {
          setConversationStateBucket(state.historyTemplateByConversationId, conversationId, !!data.hasTemplate);
        }
        return;
      }
      if (data.type === PAGE_HOOK_MESSAGE_TYPE_FULL_HISTORY_READY) {
        const conversationId = normalizeConversationId(data.conversationId || '');
        if (conversationId) {
          setFullHistoryReady(conversationId);
        }
        return;
      }
      if (data.type !== PAGE_HOOK_MESSAGE_TYPE_NETWORK) return;
      state.pageHookMessageCount += 1;
      const parsed = extractNetworkMessages(data.body || '');
      if (!parsed) return;
      updateNetworkMessagesCacheGuarded(parsed.conversationId, parsed.messages);
      // PL-036 Phase 4: handlePageHookEvent と同根 (passive sniff endOfHistory
      // 由来 set 削除)、上記 handler のコメント参照。
    } catch (_e) {}
  }

  function hasHistoryTemplate(conversationId) {
    try {
      return !!getConversationStateBucket(state.historyTemplateByConversationId, conversationId);
    } catch (_e) {
      return false;
    }
  }

  async function requestExpandedHistory(conversationId, timeoutMs, captureDebug, domMessages, options = {}) {
    // PL-053 段 A: 厳格モード option 追加。 options 不在 / 旧呼び元は backward compatible。
    //   forceReplay = warm cache でも skip せず active replay 強制
    //   requireFullHistory = 全件揃った印 (fullHistoryReady) が立った時のみ accept
    //   allowPartialWithoutTemplate = false で 「履歴 template 不在 + 部分 cache のみ」 を拒否 (= null 返却)
    const forceReplay = !!(options && options.forceReplay === true);
    const requireFullHistory = !!(options && options.requireFullHistory === true);
    const allowPartialWithoutTemplate = !(options && options.allowPartialWithoutTemplate === false);
    try {
      const existingMessages = getConversationStateBucket(state.networkMessagesByConversationId, conversationId);
      const before = Array.isArray(existingMessages)
        ? existingMessages.length
        : 0;
      const templateAvailable = hasHistoryTemplate(conversationId);
      const fullHistoryReady = !!getConversationStateBucket(state.fullHistoryReadyByConversationId, conversationId);
      const domCount = Array.isArray(domMessages) ? domMessages.length : 0;
      if (captureDebug && typeof captureDebug === 'object') {
        captureDebug.replayAttempted = templateAvailable;
        captureDebug.hasHistoryTemplate = templateAvailable;
        captureDebug.networkMessageCountBeforeReplay = before;
        captureDebug.domMessageCount = domCount;
        captureDebug.fullHistoryReady = fullHistoryReady;
        captureDebug.replaySkipReason = '';
        captureDebug.forceReplay = forceReplay;
        captureDebug.requireFullHistory = requireFullHistory;
        captureDebug.allowPartialWithoutTemplate = allowPartialWithoutTemplate;
      }
      if (before > 0 && !templateAvailable) {
        if (!allowPartialWithoutTemplate) {
          if (captureDebug && typeof captureDebug === 'object') {
            captureDebug.replayExpanded = false;
            captureDebug.replayWaitMs = 0;
            captureDebug.networkMessageCountAfterReplay = before;
            captureDebug.replaySkipReason = 'no_template_partial_cache_rejected';
            captureDebug.networkRejectedPartial = true;
          }
          return null;
        }
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.replayExpanded = false;
          captureDebug.replayWaitMs = 0;
          captureDebug.networkMessageCountAfterReplay = before;
          captureDebug.captureSource = 'network';
        }
        return existingMessages;
      }
      if (!templateAvailable) {
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.replayExpanded = false;
          captureDebug.replayWaitMs = 0;
          captureDebug.networkMessageCountAfterReplay = 0;
          captureDebug.replaySkipReason = 'no_history_template';
        }
        return null;
      }
      // PL-036 Phase 2: warm short-circuit を fullHistoryReady 単独 condition に
      // 集約。overlap fingerprint (fullOverlap / tailOverlapReady) と lengthMatch
      // は本質的に「passive sniff cache vs DOM 部分一致」の signal で、両方が
      // 部分 truncate のとき contents 一致しても全件 signal にならない false
      // positive (270 message conv で UI scroll 未完遂時 20 件確定する事故、
      // PL-036 v3 probe + diagnostic 観測済) のため棄却。passive sniff 経路で
      // response inner[1] cursor 不在 = paginate end → endOfHistory true →
      // fullHistoryReady set、active paginate (replayHistoryRequest) 完遂時も
      // synth emit + full-history-ready signal で flag set、両 path 共通の
      // 真の全件 signal として fullHistoryReady を採用。
      if (before > 0 && templateAvailable && domCount > 0 && fullHistoryReady && !forceReplay) {
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.replayAttempted = false;
          captureDebug.replayExpanded = false;
          captureDebug.replayWaitMs = 0;
          captureDebug.networkMessageCountAfterReplay = before;
          captureDebug.captureSource = 'network';
          captureDebug.replaySkipReason = 'warm_cache_full_history_ready';
        }
        return existingMessages;
      }
      window.postMessage({
        source: PAGE_HOOK_MESSAGE_SOURCE,
        type: PAGE_HOOK_MESSAGE_TYPE_REPLAY,
        conversationId,
      }, '*');
      window.dispatchEvent(new CustomEvent(PAGE_HOOK_REPLAY_EVENT, {
        detail: {
          conversationId,
        },
      }));
      const startedAt = Date.now();
      let unregisterWaiter = null;
      let timeoutHandle = null;
      let timedOut = false;
      try {
        await new Promise((resolve) => {
          unregisterWaiter = registerHistoryReadyWaiter(conversationId, resolve);
          if (getConversationStateBucket(state.fullHistoryReadyByConversationId, conversationId)) {
            resolve();
            return;
          }
          timeoutHandle = setTimeout(() => {
            timedOut = true;
            resolve();
          }, timeoutMs);
        });
      } finally {
        if (timeoutHandle !== null) clearTimeout(timeoutHandle);
        if (typeof unregisterWaiter === 'function') unregisterWaiter();
      }
      const finalCurrent = getConversationStateBucket(state.networkMessagesByConversationId, conversationId);
      const finalCount = Array.isArray(finalCurrent) ? finalCurrent.length : 0;
      const finalFullReady = !!getConversationStateBucket(state.fullHistoryReadyByConversationId, conversationId);
      if (captureDebug && typeof captureDebug === 'object') {
        captureDebug.replayExpanded = !timedOut && finalCount > before;
        captureDebug.replayWaitMs = Date.now() - startedAt;
        captureDebug.networkMessageCountAfterReplay = finalCount;
        captureDebug.fullHistoryReady = finalFullReady;
        if (timedOut) {
          captureDebug.replayTimeoutBefore = before;
          captureDebug.replayTimeoutDomCount = domCount;
        }
        if (finalCount > 0) {
          captureDebug.captureSource = 'network';
        }
      }
      if (requireFullHistory && !finalFullReady) {
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.replaySkipReason = captureDebug.replaySkipReason || 'full_history_not_ready_after_replay';
          captureDebug.networkRejectedPartial = true;
        }
        return null;
      }
    } catch (_e) {}
    const tailCurrent = getConversationStateBucket(state.networkMessagesByConversationId, conversationId);
    if (requireFullHistory) {
      const tailFullReady = !!getConversationStateBucket(state.fullHistoryReadyByConversationId, conversationId);
      if (!tailFullReady) {
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.replaySkipReason = captureDebug.replaySkipReason || 'full_history_not_ready_tail';
          captureDebug.networkRejectedPartial = true;
        }
        return null;
      }
    }
    return tailCurrent;
  }

  window.addEventListener(PAGE_HOOK_EVENT, handlePageHookEvent);
  window.addEventListener('message', handlePageHookMessage);
  injectPageHook();

  // ======== ページ状態検出 ========

  function challengePresent() {
    try {
      const iframes = Array.from(document.querySelectorAll('iframe'));
      for (const f of iframes) {
        const src = String(f.src || f.getAttribute('src') || '').toLowerCase();
        if (src.includes('challenge') || src.includes('turnstile') || src.includes('captcha')) {
          return true;
        }
        const id = String(f.id || '').toLowerCase();
        if (id.includes('challenge') || id.includes('turnstile')) {
          return true;
        }
      }
    } catch (_e) {}
    return false;
  }

  function loginRequired() {
    try {
      const url = String(location.href || '').toLowerCase();
      if (url.includes('accounts.google.com') || url.includes('/signin') || url.includes('/login')) {
        return true;
      }
      if (document.querySelector('input[type="email"], input[type="password"]')) {
        return true;
      }
    } catch (_e) {}
    return false;
  }

  function hasConversationContent() {
    try {
      return !!(
        document.querySelector('user-query') ||
        document.querySelector('model-response') ||
        document.querySelector('generative-ui-response') ||
        document.querySelector('dual-model-response')
      );
    } catch (_e) {
      return false;
    }
  }

  function countConversationElements() {
    try {
      const TARGETS = new Set(['user-query', 'model-response', 'generative-ui-response', 'dual-model-response']);
      let count = 0;
      function walk(root) {
        for (const c of (root.childNodes || [])) {
          if (c.nodeType !== 1) continue;
          if (TARGETS.has((c.localName || '').toLowerCase())) count++;
          if (c.shadowRoot) walk(c.shadowRoot);
          walk(c);
        }
      }
      walk(document.documentElement);
      return count;
    } catch (_e) {
      return 0;
    }
  }

  function isTemporaryChat() {
    try {
      if (document.querySelector('.temporary-chat-header')) {
        return true;
      }
      if (document.querySelector('.temp-chat-on')) {
        return true;
      }
      if (document.querySelector('[data-test-id="temp-chat-button"].temp-chat-on')) {
        return true;
      }
    } catch (_e) {}
    return false;
  }

  function currentPageState() {
    try {
      if (loginRequired()) return 'login_required';
      if (challengePresent()) return 'human_check';
      const url = String(location.href || '').toLowerCase();
      if (!url.includes('gemini.google.com')) return 'unsupported_page';
      if (hasConversationContent()) return 'ready';
      if (url.includes('/app') || url.includes('/chat')) return 'fetching';
      return 'unsupported_page';
    } catch (_e) {
      return 'unsupported_page';
    }
  }

  function collectStatusDetails() {
    return {
      countElements: countConversationElements(),
      isTemporaryChat: isTemporaryChat(),
      loginRequired: loginRequired(),
      challengePresent: challengePresent(),
      documentTitle: document.title || '',
    };
  }

  // ======== CDK スクロール ユーティリティ ========

  function getCdkViewport() {
    function findVP(root, depth) {
      if (depth > 20) return null;
      for (const c of (root.childNodes || [])) {
        if (c.nodeType !== 1) continue;
        if ((c.localName || '').toLowerCase() === 'cdk-virtual-scroll-viewport') return c;
        let r = null;
        if (c.shadowRoot) r = findVP(c.shadowRoot, depth + 1);
        if (!r) r = findVP(c, depth + 1);
        if (r) return r;
      }
      return null;
    }
    return findVP(document.documentElement, 0);
  }

  function getCdkScrollTop() {
    const vp = getCdkViewport();
    return vp !== null ? vp.scrollTop : null;
  }

  function scrollToTop() {
    // 1) CDK viewport 直接操作
    const vp = getCdkViewport();
    if (vp) {
      vp.scrollTop = 0;
      try { vp.dispatchEvent(new Event('scroll')); } catch (_e) {}
    }
    // 2) 既知セレクタ
    const knownSels = [
      'cdk-virtual-scroll-viewport',
      'infinite-scroller',
      'chat-history',
      'conversation-container',
      '[class*="virtual-scroll-viewport"]',
      '[class*="scroll-viewport"]',
    ];
    for (const sel of knownSels) {
      try {
        const el = document.querySelector(sel);
        if (el && el.scrollHeight > el.clientHeight + 20) {
          el.scrollTop = 0;
        }
      } catch (_e) {}
    }
    // 3) 最大scrollHeight要素
    let best = null, bestH = 0;
    function scanLargest(root, depth) {
      if (depth > 12) return;
      for (const c of (root.childNodes || [])) {
        if (c.nodeType !== 1) continue;
        const tag = (c.localName || '').toLowerCase();
        if (tag === 'script' || tag === 'style') continue;
        const sh = c.scrollHeight || 0;
        const ch = c.clientHeight || 0;
        if (sh > ch + 100 && sh > bestH) { bestH = sh; best = c; }
        scanLargest(c, depth + 1);
        if (c.shadowRoot) scanLargest(c.shadowRoot, depth + 1);
      }
    }
    scanLargest(document.documentElement, 0);
    if (best) best.scrollTop = 0;
    // 4) window fallback
    window.scrollTo(0, 0);
    document.documentElement.scrollTop = 0;
    document.body.scrollTop = 0;
  }

  function scrollDownStep() {
    const step = (window.innerHeight || 600) * 0.5;
    const vp = getCdkViewport();
    if (vp) {
      const prev = vp.scrollTop;
      vp.scrollTop += step;
      try { vp.dispatchEvent(new Event('scroll')); } catch (_e) {}
      return { atBottom: vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 10, prev };
    }
    // fallback: first message の祖先コンテナ
    const TARGETS = new Set(['user-query', 'model-response', 'generative-ui-response', 'dual-model-response']);
    function findFirst(root) {
      for (const c of (root.childNodes || [])) {
        if (c.nodeType !== 1) continue;
        if (TARGETS.has((c.localName || '').toLowerCase())) return c;
        if (c.shadowRoot) { const r = findFirst(c.shadowRoot); if (r) return r; }
        const r = findFirst(c); if (r) return r;
      }
      return null;
    }
    function findScrollable(el) {
      for (let d = 0; d < 30; d++) {
        if (!el) break;
        if (el.scrollHeight > el.clientHeight + 20) return el;
        if (el.parentElement) { el = el.parentElement; }
        else {
          const root = el.getRootNode ? el.getRootNode() : null;
          if (root && root.host) { el = root.host; } else break;
        }
      }
      return null;
    }
    const first = findFirst(document.documentElement);
    const container = first ? findScrollable(first) : null;
    if (container && container !== document.documentElement && container !== document.body) {
      const prev = container.scrollTop;
      container.scrollTop += step;
      return { atBottom: container.scrollTop + container.clientHeight >= container.scrollHeight - 10, prev };
    }
    const prevY = window.scrollY;
    window.scrollBy(0, step);
    return { atBottom: (window.scrollY + window.innerHeight) >= (document.body.scrollHeight - 10), prev: prevY };
  }

  // ======== メッセージ収集 (Deep Collect) ========

  const GEMINI_UI_NOISE_LINES = new Set([
    '思考プロセスを表示',
    '思考プロセスを非表示',
    'Gemini の回答',
    "Gemini's response",
    'Show thinking',
    'Hide thinking',
    'Show thinking process',
    'Hide thinking process',
  ]);

  const USER_LABELS = ['あなたのプロンプト', 'Your prompt'];
  const ASST_LABELS = ['Geminiの回答', 'Gemini の回答', "Gemini's response", 'Gemini response'];

  function deepCollect() {
    // PL-120: 共有ページは assistant turn が <response-container> (model-response 不在) のため、
    // 共有時のみ assistant 容器を差し替える (通常ページは従来どおり model-response)。 deepText が
    // <response-container> 配下を再帰収集するため、 本文・生成画像 (generated-image/single-image の
    // blob: マーカー化) とも既存 machinery にそのまま乗る。
    const TARGETS = isGeminiSharePage()
      ? new Set(['user-query', 'response-container', 'generative-ui-response', 'dual-model-response'])
      : new Set(['user-query', 'model-response', 'generative-ui-response', 'dual-model-response']);
    const out = [];
    function walk(node) {
      if (!node) return;
      if (node.nodeType === Node.ELEMENT_NODE) {
        const tag = (node.localName || '').toLowerCase();
        if (TARGETS.has(tag)) {
          if (tag === 'dual-model-response') {
            const list = [];
            function walkModel(n) {
              if (!n) return;
              if (n.nodeType === Node.ELEMENT_NODE && (n.localName || '').toLowerCase() === 'model-response') {
                const dt = deepText(n);
                list.push({ tag: 'model-response', text: dt.text, code_blocks: dt.code_blocks });
              }
              if (n.shadowRoot) { for (const c of n.shadowRoot.childNodes) { walkModel(c); } }
              for (const c of n.childNodes) { walkModel(c); }
            }
            walkModel(node);
            if (list.length) {
              out.push(...list);
            } else {
              const dt = deepText(node);
              out.push({ tag, text: dt.text, code_blocks: dt.code_blocks });
            }
          } else {
            const dt = deepText(node);
            out.push({ tag, text: dt.text, code_blocks: dt.code_blocks });
          }
          return; // 子孫への再帰は不要（ターゲット要素の内部は deepText が処理）
        }
      }
      if (node.shadowRoot) { for (const c of node.shadowRoot.childNodes) { walk(c); } }
      for (const c of node.childNodes) { walk(c); }
    }
    walk(document.documentElement);
    return out;
  }

  function stripUiNoise(text) {
    if (!text) return text;
    const lines = text.split('\n');
    const filtered = lines.filter(ln => !GEMINI_UI_NOISE_LINES.has(ln.trim()));
    return filtered.join('\n').replace(/\n{3,}/g, '\n\n').trim();
  }

  function stripRoleLabel(text, labels) {
    for (const label of labels) {
      if (text.startsWith(label)) {
        const candidate = text.slice(label.length).replace(/^[\n ]+/, '');
        return candidate || text;
      }
    }
    return text;
  }

  function fingerprint(tag, text) {
    return tag + '\x00' + text.slice(0, 80);
  }

  function messageFingerprint(message) {
    const role = String(message && message.role || '').trim().toLowerCase();
    // 比較専用の正規化: network 側は markdown 素のまま、DOM 側はレンダ後なので
    // `**bold**` / `*ital*` / `__u__` / `~~s~~` / `` `code` `` の delimiter が
    // 位置ずれを起こし overlap score を下げる。fingerprint では delimiter を
    // 除去し、連続空白もまとめて比較可能にする。保存テキストは改変しない。
    const rawText = String(message && message.text || '');
    const normalized = rawText
      .replace(/\*\*/g, '')
      .replace(/(^|[^*])\*(?!\*)/g, '$1')
      .replace(/__/g, '')
      .replace(/~~/g, '')
      .replace(/`/g, '')
      .replace(/\s+/g, ' ')
      .trim();
    return role + '\x00' + normalized.slice(0, 80);
  }

  function mergeNew(allMsgs, batch) {
    if (!allMsgs.length) return batch;
    if (!batch.length) return [];
    for (const anchorSize of [2, 1]) {
      if (allMsgs.length < anchorSize) continue;
      const anchor = allMsgs.slice(-anchorSize).map(m => fingerprint(m.tag, m.text));
      for (let i = 0; i <= batch.length - anchorSize; i++) {
        const batchSlice = batch.slice(i, i + anchorSize).map(m => fingerprint(m.tag, m.text));
        if (batchSlice.every((fp, idx) => fp === anchor[idx])) {
          return batch.slice(i + anchorSize);
        }
      }
    }
    // アンカー不一致 = CDK Virtual Scroll で上部要素がDOMから消えた
    return batch;
  }

  function dedup(msgs) {
    // PL-015 (Claude PL-014 横展開): 旧 `fingerprint(m.tag, m.text)` =
    // `tag + text.slice(0, 80)` text-based dedup は **異 DOM node の同 text** を
    // collapse する副作用あり (template 連投応答 / regenerate で同 content 連発時)。
    // occurrence-based dedup (`(tag, text, occurrence_within_input)`) に書換、
    // 同 input 内 N 回目の同 text は同 DOM msg、N+1 回目は別 msg として preserve。
    const seen = new Set();
    const occurrenceCount = new Map();
    const out = [];
    for (const m of msgs) {
      const tag = String(m && m.tag || '');
      const text = String(m && m.text || '');
      const baseKey = tag + '\x00' + text;
      const occ = occurrenceCount.get(baseKey) || 0;
      occurrenceCount.set(baseKey, occ + 1);
      const fullKey = baseKey + '\x00' + occ;
      if (!seen.has(fullKey)) { seen.add(fullKey); out.push(m); }
    }
    return out;
  }

  function buildDomMessages() {
    let allMsgs = deepCollect().filter(m => m.text.trim());
    allMsgs = dedup(allMsgs);

    const messages = [];
    for (const { tag, text, code_blocks } of allMsgs) {
      if (!text) continue;
      let role;
      let normalized;
      if (tag === 'user-query') {
        role = 'user';
        normalized = stripRoleLabel(text, USER_LABELS);
      } else {
        role = 'assistant';
        normalized = stripRoleLabel(text, ASST_LABELS);
      }
      normalized = stripUiNoise(normalized);
      if (normalized) {
        messages.push({ role, text: normalized, code_blocks: code_blocks || [] });
      }
    }
    return messages;
  }

  function contiguousOverlapScore(candidateMessages, referenceMessages) {
    try {
      const candidate = Array.isArray(candidateMessages) ? candidateMessages.map(messageFingerprint) : [];
      const reference = Array.isArray(referenceMessages) ? referenceMessages.map(messageFingerprint) : [];
      if (!candidate.length || !reference.length) {
        return 0;
      }
      let best = 0;
      for (let start = 0; start < candidate.length; start += 1) {
        let matched = 0;
        while (
          start + matched < candidate.length &&
          matched < reference.length &&
          candidate[start + matched] === reference[matched]
        ) {
          matched += 1;
        }
        if (matched > best) {
          best = matched;
        }
      }
      return best;
    } catch (_e) {}
    return 0;
  }

  function tailOverlapScore(candidateMessages, referenceMessages, maxWindow = 4) {
    try {
      const candidate = Array.isArray(candidateMessages) ? candidateMessages.map(messageFingerprint) : [];
      const reference = Array.isArray(referenceMessages) ? referenceMessages.map(messageFingerprint) : [];
      const windowSize = Math.min(maxWindow, candidate.length, reference.length);
      if (windowSize <= 0) {
        return 0;
      }
      let matched = 0;
      for (let index = 1; index <= windowSize; index += 1) {
        if (candidate[candidate.length - index] !== reference[reference.length - index]) {
          break;
        }
        matched += 1;
      }
      return matched;
    } catch (_e) {}
    return 0;
  }

  function orientNetworkMessages(networkMessages, domMessages, captureDebug) {
    try {
      if (!Array.isArray(networkMessages) || networkMessages.length < 2 || !Array.isArray(domMessages) || !domMessages.length) {
        if (captureDebug && typeof captureDebug === 'object') {
          captureDebug.networkOrderFlipped = false;
        }
        return networkMessages;
      }
      const normalScore = contiguousOverlapScore(networkMessages, domMessages);
      const reversedMessages = [...networkMessages].reverse();
      const reversedScore = contiguousOverlapScore(reversedMessages, domMessages);
      const flipped = reversedScore > normalScore;
      if (captureDebug && typeof captureDebug === 'object') {
        captureDebug.networkOrderFlipped = flipped;
        captureDebug.networkOrderScoreNormal = normalScore;
        captureDebug.networkOrderScoreReversed = reversedScore;
      }
      return flipped ? reversedMessages : networkMessages;
    } catch (_e) {}
    if (captureDebug && typeof captureDebug === 'object') {
      captureDebug.networkOrderFlipped = false;
    }
    return networkMessages;
  }

  // ======== CDK スクロールループ ========

  async function collectConversation() {
    const conversationId = getConversationIdFromUrl(location.href);
    const captureDebug = {
      scriptVersion: '2026-03-22-capture-debug-3',
      conversationId,
      captureSource: 'unknown',
      replayAttempted: false,
      replayExpanded: false,
      replayWaitMs: 0,
      replaySkipReason: '',
      replayShortCircuitReason: '',
      networkMessageCountBeforeReplay: 0,
      networkMessageCountAfterReplay: 0,
      domMessageCount: 0,
      fullHistoryReady: false,
      warmCacheNormalOverlap: 0,
      warmCacheReversedOverlap: 0,
      warmCacheTailOverlap: 0,
      warmCacheTailWindow: 0,
    };
    state.lastCaptureDebug = captureDebug;
    const domMessages = buildDomMessages();
    captureDebug.domMessageCount = domMessages.length;
    let networkMessages = null;
    if (conversationId) {
      // PL-053 段 B: 厳格モード呼出。 partial cache 詐称返却を拒否し、 全件揃った印が立った時のみ accept。
      // 拒否時 null 返却 → 下の bounded partial fallback または DOM fallback で処理。
      networkMessages = await requestExpandedHistory(conversationId, 30000, captureDebug, domMessages, {
        forceReplay: true,
        requireFullHistory: true,
        allowPartialWithoutTemplate: false,
      });
    }
    if (Array.isArray(networkMessages) && networkMessages.length > 0) {
      captureDebug.captureSource = 'network';
      captureDebug.strictFullHistory = true;
      const oriented = orientNetworkMessages(networkMessages, domMessages, captureDebug);
      // PL-083: web 引用画像は各 turn の network turn[3] から pl081ResolveWebImages が位置正確に
      // 解決済 (本文 anchor ↔ 表示グループの ID 結合)。 旧 DOM 順撒き合流 (pl078gInjectWebImages) は
      // DOM 画像を全 turn の [AI生成] へ出現順で埋めるため、 別 turn の orphan placeholder に
      // 他 turn の画像を誤注入し重複/誤位置を生んだ (PL-083 症状A root) → 撤去。
      return fillAttachmentPlaceholders(oriented, domMessages);
    }
    // PL-053 段 B' (Codex Round 6 verdict): bounded partial fallback。
    // template 不在で strict reject された場合のみ、 同一 cid の受動 sniff cache (= partial) が
    // 4 条件全充足なら採用 (= 新規会話直後の装飾喪失救済)。 全件と詐称せず、 debug field で明示分離。
    //   1. partial.length >= domMessages.length (= 同数も partial 優先 = 装飾保持版)
    //   2. partial.length <= SMALL_CONV_PARTIAL_LIMIT (= 200 件超 chat 122 cap 再発防止)
    //   3. replaySkipReason === 'no_template_partial_cache_rejected' (= template 不在原因限定)
    //   4. domMessages.length >= 1 (= 0 件は採用しない)
    const SMALL_CONV_PARTIAL_LIMIT = 40;
    if (conversationId && captureDebug.replaySkipReason === 'no_template_partial_cache_rejected') {
      const partial = getConversationStateBucket(state.networkMessagesByConversationId, conversationId);
      const partialLen = Array.isArray(partial) ? partial.length : 0;
      if (partialLen >= domMessages.length
          && partialLen <= SMALL_CONV_PARTIAL_LIMIT
          && domMessages.length >= 1) {
        captureDebug.captureSource = 'network_partial_no_full_history';
        captureDebug.strictFullHistory = false;
        captureDebug.partialFallbackAccepted = true;
        captureDebug.partialFallbackReason = 'small_conv_template_pending';
        const oriented = orientNetworkMessages(partial, domMessages, captureDebug);
        // PL-083: web 引用画像は各 turn の network turn[3] から pl081ResolveWebImages が位置正確に
      // 解決済 (本文 anchor ↔ 表示グループの ID 結合)。 旧 DOM 順撒き合流 (pl078gInjectWebImages) は
      // DOM 画像を全 turn の [AI生成] へ出現順で埋めるため、 別 turn の orphan placeholder に
      // 他 turn の画像を誤注入し重複/誤位置を生んだ (PL-083 症状A root) → 撤去。
      return fillAttachmentPlaceholders(oriented, domMessages);
      }
      captureDebug.partialFallbackAccepted = false;
      if (partialLen > SMALL_CONV_PARTIAL_LIMIT) {
        captureDebug.partialFallbackReason = 'partial_exceeds_small_conv_limit';
      } else if (domMessages.length === 0) {
        captureDebug.partialFallbackReason = 'dom_messages_empty';
      } else if (partialLen < domMessages.length) {
        captureDebug.partialFallbackReason = 'partial_below_dom_count';
      }
    }
    // last-resort DOM fallback (= bounded partial 不採用 or 別 reject 原因)
    captureDebug.captureSource = captureDebug.networkRejectedPartial
      ? 'dom_fallback_no_full_history'
      : 'dom';
    captureDebug.strictFullHistory = false;
    return domMessages;
  }

  // PL-017: network response の userText 空 turn (file-only upload) は placeholder
  // `[添付ファイル]` で push されるため、DOM walker 由来の `[添付ファイル: <name>]`
  // を user-turn index 順に対応付けて置換する。
  // PL-017 拡張 (mixed turn): 本文 + 添付の混在 turn で network userText 非空のため
  // `_attachmentPlaceholder` flag が立たず、添付名が完全に消失していた regression
  // を解消。dom user msg の 各 user turn から placeholder 行のみを抽出し、
  // network user msg と 1:1 対応で:
  //   - flag あり (file-only)        → text を placeholder で完全置換
  //   - flag なし + placeholder あり (mixed) → 本文先頭に placeholder を prepend
  //   - placeholder なし             → そのまま
  function _extractAttachmentPlaceholderLines(text) {
    if (typeof text !== 'string' || !text) return '';
    const out = [];
    for (const line of text.split('\n')) {
      const trimmed = line.trim();
      if (trimmed.indexOf('[添付ファイル:') === 0 && trimmed.charAt(trimmed.length - 1) === ']') {
        out.push(trimmed);
      }
    }
    return out.join('\n\n');
  }

  function fillAttachmentPlaceholders(networkMessages, domMessages) {
    try {
      if (!Array.isArray(networkMessages) || !Array.isArray(domMessages)) return networkMessages;
      const domUserAttachments = domMessages
        .filter(m => m && m.role === 'user')
        .map(m => _extractAttachmentPlaceholderLines(typeof m.text === 'string' ? m.text : ''));
      if (!domUserAttachments.some(s => s)) return networkMessages;
      // PL-037: CDK virtual scroll で末尾近 user-query のみ mount された状態で save すると
      // dom user msg 数 < network user msg 数 となり、cursor=0 起点 pair で dom 末尾の
      // attachment 行が network 先頭 turn に偽挿入される (cross-turn leak)。dom 側が
      // network 全 user msg を覆っていない場合は dom 由来補完を断念し、network 由来
      // file 名 (extractFileNamesFromTurn = 一次情報源) のみで attachment 表現する。
      // file-only upload (_attachmentPlaceholder=true) の dom fallback も同条件で諦める。
      const networkUserCount = networkMessages.filter(m => m && m.role === 'user').length;
      if (domUserAttachments.length < networkUserCount) return networkMessages;
      let cursor = 0;
      return networkMessages.map(m => {
        if (!m || m.role !== 'user') return m;
        const placeholders = cursor < domUserAttachments.length ? domUserAttachments[cursor] : '';
        cursor += 1;
        if (m._attachmentPlaceholder) {
          const replaced = { ...m, text: placeholders || m.text };
          delete replaced._attachmentPlaceholder;
          return replaced;
        }
        if (placeholders) {
          // network response 経由で `extractFileNamesFromTurn` が既に file 名 placeholder を
          // user text に注入している場合は dom 由来 placeholder の重複 prepend を避ける。
          // network が file 情報を取れなかった場合のみ dom 由来で補完する。
          const networkText = String(m.text || '');
          if (/\[添付ファイル[:\]]/.test(networkText)) {
            return m;
          }
          return { ...m, text: placeholders + '\n\n---\n\n' + networkText };
        }
        return m;
      });
    } catch (_e) {
      return networkMessages;
    }
  }

  // ======== サイドバータイトル取得 ========

  function getSidebarTitle() {
    try {
      const BAD = new Set([
        'new chat', '新しいチャット', 'home', 'ホーム',
        'チャットを新規作成', 'チャットを検索', 'ライブラリ', '最近',
        'ノートブックを新規作成', 'ノートブック',
      ]);
      const BAD_PATTERNS = [
        /表示$/u, /アップグレード/u, /設定/u, /ヘルプ/u, /検索/u, /フィードバック/u,
        /コミュニティ/u, /スター/u, /下書き/u, /history/i, /upgrade/i, /help/i, /settings?/i,
      ];
      // PL-062: 静的 nav ボタン / ノートブック root の href を除外 (= 「チャットを新規作成」 等が
      // class に "interactive" 含むため旧 active regex で false hit していた + ノートブック内
      // chat 開時 [aria-current="page"] が notebook 自体を指して chat title 経路と衝突するため)
      const BAD_HREF_EXACT = new Set(['/app', '/search', '/library', 'library', '/notebooks/create']);
      const BAD_HREF_PREFIX = ['/notebook/', '/notebooks/'];
      function isBadLike(t) {
        const s = (t || '').trim();
        if (!s) return true;
        const sLow = s.toLowerCase();
        if (sLow === 'gemini' || sLow === 'gem' || sLow === 'home' || sLow === 'new chat') return true;
        for (const r of BAD_PATTERNS) { try { if (r.test(s)) return true; } catch (_e) {} }
        return false;
      }
      function deepTextSimple(node) {
        function collect(n) {
          if (!n) return '';
          let s = '';
          if (n.nodeType === Node.TEXT_NODE) return n.nodeValue || '';
          if (n.shadowRoot) { for (const c of n.shadowRoot.childNodes) s += collect(c); }
          for (const c of n.childNodes) s += collect(c);
          return s;
        }
        const raw = collect(node);
        return String(raw).replace(/\r/g, '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
      }
      function isNavContainer(el) {
        if (!el) return false;
        try {
          const tag = (el.localName || '').toLowerCase();
          if (tag === 'nav') return true;
          const role = (el.getAttribute ? el.getAttribute('role') : null) || '';
          if (role.toLowerCase() === 'navigation') return true;
          const label = ((el.getAttribute ? el.getAttribute('aria-label') : null) || '').toLowerCase();
          if (label.includes('conversation') || label.includes('chat') || label.includes('history')) return true;
          if (label.includes('会話') || label.includes('チャット') || label.includes('履歴')) return true;
          const r = el.getBoundingClientRect();
          if (r && r.width > 0 && r.height > 0 && r.x <= 420 && r.width <= 420) return true;
        } catch (_e) {}
        return false;
      }
      const navs = [];
      (function walkNavs(n) {
        if (!n) return;
        if (n.nodeType === Node.ELEMENT_NODE) { try { if (isNavContainer(n)) navs.push(n); } catch (_e) {} }
        if (n.shadowRoot) { for (const c of n.shadowRoot.childNodes) walkNavs(c); }
        for (const c of n.childNodes) walkNavs(c);
      })(document.documentElement);
      if (!navs.length) return null;

      const hrefNow = location.href;
      let bestTxt = null;
      let bestScore = -1;

      function consider(el, baseScore) {
        const r = el.getBoundingClientRect();
        if (!r || r.width <= 0 || r.height <= 0) return;
        const txt = deepTextSimple(el);
        if (!txt || txt.length === 0 || txt.length > 160) return;
        if (BAD.has(txt.trim()) || BAD.has(txt.trim().toLowerCase())) return;
        if (isBadLike(txt)) return;
        const a = el.closest ? el.closest('a') : null;
        const href = a ? a.getAttribute('href') : el.getAttribute('href');
        // PL-062: 静的 nav / ノートブック root href 除外 (= sidebar 走査 1 周目と同 guard)
        if (href && BAD_HREF_EXACT.has(href)) return;
        if (href && BAD_HREF_PREFIX.some(p => href.startsWith(p))) return;
        let score = baseScore;
        if (href && hrefNow.endsWith(href)) score += 100;
        if (score > bestScore) {
          bestScore = score;
          bestTxt = txt;
        }
      }
      for (const nav of navs) {
        const links = nav.querySelectorAll ? nav.querySelectorAll('a[href]') : [];
        for (const a of links) {
          const href = a.getAttribute('href') || '';
          if (!href) continue;
          // PL-062: 静的 nav / ノートブック自体の href 除外 (= 真の chat link のみ score 対象に)
          if (BAD_HREF_EXACT.has(href)) continue;
          if (BAD_HREF_PREFIX.some(p => href.startsWith(p))) continue;
          const txt = deepTextSimple(a);
          if (!txt || txt.length === 0 || txt.length > 160) continue;
          if (BAD.has(txt.trim()) || BAD.has(txt.trim().toLowerCase()) || isBadLike(txt)) continue;
          let score = 1;
          if (hrefNow.endsWith(href)) score += 100;
          const cls = (a.className || '').toString();
          // PL-062: word boundary 化、 `mat-mdc-list-item-interactive` 等の substring 誤 hit 防止
          if (/(?:^|[^a-z])(?:active|selected|current|activated)(?:[^a-z]|$)/i.test(cls)) score += 50;
          if (score > bestScore) {
            bestScore = score;
            bestTxt = txt;
          }
        }
        const clickable = nav.querySelectorAll ? nav.querySelectorAll('[role="button"], [aria-selected="true"]') : [];
        for (const el of clickable) { consider(el, 2); }
      }
      return bestTxt || null;
    } catch (_e) {
      return null;
    }
  }

  // PL-062: chat title を Chrome タブ title (= document.title) から抽出
  // (= Gemini は chat 開いた page 内に title text 不在、 Chrome タブのみに表示の構造)
  // " - Google Gemini" / " | Google Gemini" suffix 剥がし + generic 名拒否
  // PL-121: bidi \u5236\u5fa1\u6587\u5b57 (LRM/RLM/\u57cb\u8fbc/\u5206\u96e2/ZWNBSP) \u3092\u9664\u53bb\u3002 document.title \u5148\u982d\u306e
  // U+200E \u304c PDF \u7b49\u5e45\u30d5\u30a9\u30f3\u30c8\u3067 \u303c \u8c46\u8150\u5316\u3059\u308b\u7f60\u3078\u306e\u9632\u5fa1 (\u30bf\u30a4\u30c8\u30eb\u5168\u7d4c\u8def\u306b\u9069\u7528)\u3002
  function stripBidiMarks(s) {
    return String(s || '').replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, '');
  }

  function getDocumentChatTitle() {
    try {
      const raw = stripBidiMarks((document.title || '').trim());
      if (!raw) return null;
      let stripped = raw.replace(/\s*[-|\u2013\u2014]\s*(?:Google\s*)?Gemini\s*$/u, '').trim();
      if (!stripped) return null;
      if (/^(?:Gemini|Google\s*Gemini)$/i.test(stripped)) return null;
      return stripped;
    } catch (_e) {
      return null;
    }
  }

  // PL-121: \u5171\u6709\u30da\u30fc\u30b8\u306e\u5b9f\u30bf\u30a4\u30c8\u30eb\u306f landing header \u306e h1 (.title-link h1) \u306b\u5728\u308b\u3002
  // document.title \u306f landing shell \u540d (\u300cGemini - Google AI \u306b\u76f4\u63a5\u30a2\u30af\u30bb\u30b9\u300d) + \u5148\u982d U+200E \u306e
  // \u305f\u3081\u5171\u6709\u3067\u306f\u4f7f\u308f\u305a\u3001 h1 \u3092\u4e00\u6b21\u30bd\u30fc\u30b9\u306b\u3059\u308b\u3002
  function getGeminiShareTitle() {
    try {
      if (!isGeminiSharePage()) return null;
      const h1 = document.querySelector('.title-link h1, share-landing-page h1');
      if (!h1) return null;
      const t = stripBidiMarks((h1.innerText || h1.textContent || '').trim());
      return t || null;
    } catch (_e) {
      return null;
    }
  }

  // PL-062: 現在開いてる chat が属する ノートブック名抽出
  // (= ChatGPT/Claude の project 機能と同 token 流用、 sidebar の aria-current="page" 経由)
  function getNotebookName() {
    try {
      const active = document.querySelector('[aria-current="page"]');
      if (!active) return null;
      const href = active.getAttribute('href') || '';
      if (!href.startsWith('/notebook/')) return null;
      const label = (active.getAttribute('aria-label') || '').trim();
      return label || null;
    } catch (_e) {
      return null;
    }
  }

  // ======== スナップショット構築 ========

  // ======== PL-078: AI 生成画像 (Imagen ラスター) 埋め込み ========
  // Gemini の生成画像 URL (lh3.googleusercontent.com/gg/<id>) は <img> 描画はできるが、
  // ページ内 fetch は 302→wildcard CORS で読めない (spike 確定)。 そこで取得 2 経路
  // (network parse / DOM render) では URL を失わず中間マーカー [[ASA_GEMIMG:<url>]] で残し、
  // buildSnapshot で [[ASA_IMAGE_N]] + image_blocks{img_ref} へ正規化する。 画像バイトは
  // background が host 権限付き fetch (credentials) で取得し event='image' で別送、 ASA が
  // img_ref をキーに snapshot へ後合流させる (ChatGPT PL-078 Phase B と同 infra、 取得経路
  // のみ background へ移設)。 取得失敗・未到達は asa_core renderer が [画像] 印へ degrade。
  const PL078G_GEMIMG_RE = /\[\[ASA_GEMIMG:([^\]]+)\]\]/g;
  const _pl078gSentKeys = new Set();   // 送出済 img_ref (capture/save 開始でリセット → 新 ASA へ再 flush)

  // 画像 URL から img_ref キーを生成する。 gg (Gemini 生成) は gg/<id> をキーにし
  // (API suffix 無 / DOM =s1024-rj 付でもベース path が一致 → network↔DOM 相関)、
  // tbn (web 引用) は tbn:<id>、 それ以外は URL 全体の簡易ハッシュで一意キーを作る
  // (相関不要なら一意でよい)。 空 URL のみ '' を返す。
  function pl078gImageKey(url) {
    const u = String(url || '');
    if (!u) return '';
    const gg = u.match(/\/gg(?:-dl)?\/([A-Za-z0-9_-]+)/);
    if (gg) return 'gem_' + gg[1];
    const tbn = u.match(/tbn:([A-Za-z0-9_-]+)/);
    if (tbn) return 'gem_tbn_' + tbn[1];
    let h = 0;
    for (let i = 0; i < u.length; i++) { h = (h * 31 + u.charCodeAt(i)) | 0; }
    return 'gem_url_' + (h >>> 0).toString(36);
  }

  // turn[3] (network) の JSON 部分木から AI 生成画像 gg URL を出現順・重複除去で抽出。
  function pl078gGgUrlsFromTurn(turnSlot) {
    const out = [];
    const seen = new Set();
    try {
      const json = JSON.stringify(turnSlot === undefined ? null : turnSlot);
      const re = /https?:\/\/lh3\.googleusercontent\.com\/gg(?:-dl)?\/[A-Za-z0-9_=-]+/g;
      let m;
      while ((m = re.exec(json)) !== null) {
        const url = m[0];
        const key = pl078gImageKey(url);
        if (key && !seen.has(key)) { seen.add(key); out.push(url); }
      }
    } catch (_e) {}
    return out;
  }

  // PL-081 / PL-083: turn[3] (network) から web 引用画像の「表示グループ」を構造で収集する。
  // spike (PL-083) 実測で確定した表示グループ G の形:
  //   G[0]  = [[出典画像url], null, W, H, desc, null, 内部id, 0]   (主画像 + 寸法 + 説明)
  //   G[7]  = [<anchor>, null, desc, ...]   (<anchor> = "image_retrieval/<id>" URL か
  //           image_agent_tag_<id> を含む。 本文 placeholder と同 ID = 結合キー)
  //   G[k]  = [[gstatic url], null, w, h, ...]   (表示サムネ。 複数寸法あり、 本家は出典と
  //           同 縦横比のものを描画。 250x200 等の正方形プレビューは別物)
  // grounding 複製 (turn[3] 直下 index 12 のブロック) は同 出典url/id の完全コピー →
  // sourceUrl 重複除去で排除 (content subtree を document order で先に walk するため content 優先)。
  // 検索候補が複数 (シャチ: 1 anchor に 4 出典グループ) でも本家表示は anchor 1 個 = 1 枚 →
  // 解決時に anchor ID 一致の先頭グループのみ消費、 残候補は捨てる (flood 防止)。
  // 旧実装 (gstatic 全件 regex + nearest-preceding + leftover 末尾追記) は表示画像と検索候補
  // プールを区別できず重複/誤位置/flood を生んだため撤去 (PL-083)。
  function pl081CollectDisplayGroups(turnSlot) {
    const groups = [];
    const seenSource = new Set();
    function isGroup(g) {
      return Array.isArray(g) && Array.isArray(g[0]) && Array.isArray(g[0][0])
        && typeof g[0][0][0] === 'string' && /^https?:/i.test(g[0][0][0])
        && typeof g[0][2] === 'number' && typeof g[0][3] === 'number';
    }
    // グループ部分木から gstatic 表示画像 (/images 検索サムネ or /licensed-image ライセンス
    // 画像) を集め、 出典の縦横比に最も近いものを選ぶ (寸法不明時は最大面積)。 favicon-tbn は
    // /images path を持たないため非マッチ。 PL-085: /images が在ればそれを優先し、 無い場合のみ
    // /licensed-image を採る (licensed-image のみのグループ = Hadley 形のドロップ救済、 かつ
    // /images 在りの既存ケースは選択不変で回帰なし)。
    function pickGstatic(node, srcW, srcH) {
      const cands = [];
      (function walk(v) {
        if (!Array.isArray(v)) return;
        if (Array.isArray(v[0]) && typeof v[0][0] === 'string'
            && /encrypted-tbn\d?\.gstatic\.com\/(?:images|licensed-image)/i.test(v[0][0])) {
          cands.push({ url: v[0][0], w: typeof v[2] === 'number' ? v[2] : 0, h: typeof v[3] === 'number' ? v[3] : 0 });
          return;
        }
        for (let i = 0; i < v.length; i++) walk(v[i]);
      })(node);
      if (!cands.length) return '';
      // /images が 1 つでも在れば licensed-image を除外 → 既存挙動を strict superset で保つ。
      const imagesOnly = cands.filter(function (c) { return /\/images/i.test(c.url); });
      const pool = imagesOnly.length ? imagesOnly : cands;
      if (srcW > 0 && srcH > 0) {
        const target = srcW / srcH;
        pool.sort(function (a, b) {
          const da = Math.abs((a.h ? a.w / a.h : 0) - target);
          const db = Math.abs((b.h ? b.w / b.h : 0) - target);
          if (da !== db) return da - db;
          return (b.w * b.h) - (a.w * a.h);
        });
      } else {
        pool.sort(function (a, b) { return (b.w * b.h) - (a.w * a.h); });
      }
      return pool[0].url;
    }
    // グループ部分木から本文 anchor と結合する ID (image_retrieval / image_agent_tag) を採取。
    function anchorIds(node) {
      let flat = '';
      try { flat = JSON.stringify(node); } catch (_e) {}
      const mr = flat.match(/image_retrieval\/(\d+)/);
      const mt = flat.match(/image_agent_tag_(\d+)/);
      return { retr: mr ? mr[1] : '', tag: mt ? mt[1] : '' };
    }
    // 出典サイト名を node[1] (source attribution = [[page_url], title, _, ..., "ソース名"]) から
    // 採取。 ソース名フィールドは構造可変 (密度=node[1][5] / シャチ=node[1][1]) のため
    // 「http で始まらない最後の文字列」を採る (favicon URL / page URL は除外)。
    function sourceName(node) {
      const n1 = Array.isArray(node) ? node[1] : null;
      if (!Array.isArray(n1)) return '';
      let name = '';
      for (let i = 0; i < n1.length; i++) {
        if (typeof n1[i] === 'string' && !/^https?:/i.test(n1[i])) name = n1[i];
      }
      return name;
    }
    (function walk(value) {
      if (!Array.isArray(value)) return;
      if (isGroup(value)) {
        const src = value[0][0][0];
        const desc = typeof value[0][4] === 'string' ? value[0][4] : '';
        const ids = anchorIds(value);
        const url = pickGstatic(value, value[0][2], value[0][3]);
        if (url && (ids.retr || ids.tag || desc) && !seenSource.has(src)) {
          seenSource.add(src);
          // PL-083 症状C: G[0][7] = grounding chunkIndex (support レコードの参照キー)。
          const chunkIndex = typeof value[0][7] === 'number' ? value[0][7] : null;
          groups.push({ retr: ids.retr, tag: ids.tag, desc: desc, url: url, source: sourceName(value), chunkIndex: chunkIndex });
        }
        return;   // グループ配下は再帰しない (内部 gstatic を別グループ扱いしない)
      }
      for (let i = 0; i < value.length; i++) walk(value[i]);
    })(turnSlot === undefined ? null : turnSlot);
    return groups;
  }

  // PL-083 症状C: turn[3] の grounding 支持セグメント (support segment) を収集し、 画像 chunk
  // ごとに「引用する最初の本文セグメントの本文文字列」を返す。 spike (probe_gemini_turn3_image_
  // position.js) 実測: web 引用画像 (image_retrieval 形) は本文 markdown の offset 0 に URL が
  // 前置きされる一方、 本家は URL 位置でなく「画像 chunk を引用する最初の支持セグメントの末尾」へ
  // 画像を配置する。 support レコード形 = [null, <chunkIndex:number>, <segText:string>]
  // (turn[3][12].0.0 配下。 箇条書きの入れ子 support も同形でマッチするが本文 offset が後ろのため
  // 最小 offset の先頭セグメントが勝つ)。 segText が本文の部分文字列のものだけ採用 (誤検出防止)。
  function pl083SupportSegEnds(turnSlot, bodyText) {
    const best = new Map();   // chunkIndex(number) -> { off, segText }
    const body = String(bodyText || '');
    if (!body) return best;
    (function walk(value) {
      if (!Array.isArray(value)) return;
      if (value.length >= 3 && value[0] === null
          && typeof value[1] === 'number' && typeof value[2] === 'string') {
        const seg = value[2];
        if (seg.length >= 4) {
          const off = body.indexOf(seg);
          if (off !== -1) {
            const cur = best.get(value[1]);
            if (!cur || off < cur.off) best.set(value[1], { off: off, segText: seg });
          }
        }
      }
      for (let i = 0; i < value.length; i++) walk(value[i]);
    })(turnSlot === undefined ? null : turnSlot);
    return best;
  }

  // PL-081 / PL-083: 本文の画像 placeholder (3 形) を出現順に走査し、 anchor ID/説明で
  // 表示グループへ結合して中間マーカー [[ASA_GEMIMG:url]] へ置換する。 1 anchor = 1 枚、
  // 位置は placeholder 位置を保持。 グループは 1 度のみ消費 (同 ID の余剰候補は捨て flood 回避)。
  // 解決できない placeholder は据置 → 後段 normalizeNetworkMarkdown が [AI生成] へ degrade。
  // PL-081: 画像直下に site 等価の説明文 (caption + 出典名) を斜体で添える。 caption は
  //   agent_tag 形=本文 <Image caption>、 retrieval/[Image of] 形=グループ desc。
  function pl081ResolveWebImages(text, turnSlot) {
    const groups = pl081CollectDisplayGroups(turnSlot);
    if (!groups.length) return text || '';
    let result = String(text || '');
    const used = new Set();
    function take(pred) {
      for (let i = 0; i < groups.length; i++) {
        if (used.has(i)) continue;
        if (pred(groups[i])) { used.add(i); return groups[i]; }
      }
      return null;
    }
    // 画像マーカー + 出典キャプション (caption. ソース: source)。 PL-088: 専用 caption マーカーで
    // 出し renderer が本文と区別 (html=小さめグレー / md/txt=斜体)。 site の出典表示等価。
    function emit(g, caption) {
      const marker = '[[ASA_GEMIMG:' + g.url + ']]';
      const cap = String(caption || g.desc || '').replace(/[*\[\]]/g, '').replace(/\s+/g, ' ').trim();
      const src = String(g.source || '').replace(/[*\[\]]/g, '').trim();
      let line = cap;
      if (src) line = (cap ? cap + '. ' : '') + 'ソース: ' + src;
      return line ? marker + '\n\n[[ASA_IMGCAP:' + line + ']]' : marker;
    }
    // 1) <Image src="image_agent_tag_<id>"> (replay 履歴形) → tag ID 一致グループ。 caption 属性採取。
    result = result.replace(/<Image\b[^>]*?src="image_agent_tag_(\d+)"[^>]*?>/gi, function (orig, id) {
      const g = take(function (x) { return x.tag === id; });
      if (!g) return orig;
      const capm = orig.match(/\bcaption="([^"]*)"/i) || orig.match(/\balt="([^"]*)"/i);
      return emit(g, capm ? capm[1] : '');
    });
    // 2) image_collection/image_retrieval/<id> URL placeholder → retrieval ID 一致グループ先頭
    // PL-083 症状C: この形は本文先頭に URL が前置きされるが、 本家は URL 位置でなく「画像 chunk を
    // 引用する最初の grounding 支持セグメントの末尾」へ画像を配置する (probe 実測)。 chunkIndex で
    // 対応する支持セグメントが採れる場合は URL を本文から除去し、 そのセグメント末尾へマーカーを
    // 再配置する。 採れない場合は従来どおり URL 位置に in-place (Tier 0 回帰防止の fallback)。
    const segEnds = pl083SupportSegEnds(turnSlot, text);
    const relocations = [];
    result = result.replace(/https?:\/\/[^\s"]*image_collection\/image_retrieval\/(\d+)/gi, function (orig, id) {
      const g = take(function (x) { return x.retr === id; });
      if (!g) return orig;
      const block = emit(g, '');
      const sup = (g.chunkIndex != null) ? segEnds.get(g.chunkIndex) : null;
      if (sup) { relocations.push({ segText: sup.segText, block: block }); return ''; }
      return block;
    });
    if (relocations.length) {
      result = result.replace(/^\s+/, '');   // URL 除去で残った先頭空白を畳む
      relocations.forEach(function (r) {
        const i = result.indexOf(r.segText);
        if (i !== -1) {
          const at = i + r.segText.length;
          result = result.slice(0, at) + '\n\n' + r.block + result.slice(at);
        } else {
          result = r.block + '\n\n' + result;   // セグメント消失時は先頭 (従来位置) へ退避
        }
      });
    }
    // 3) [Image of <説明>] (live sniff 形) → 説明一致グループ (無ければ未使用先頭で degrade)
    if (result.indexOf('[Image of') !== -1) {
      result = result.replace(/\[Image of ([^\]]+)\]/g, function (orig, desc) {
        const want = desc.trim();
        let g = take(function (x) { return x.desc && x.desc.trim() === want; });
        if (!g) g = take(function () { return true; });
        return g ? emit(g, want) : orig;
      });
    }
    return result;
  }

  // PL-086: ショッピング商品ブロックを turn[3] から収集する。 spike (probe_gemini_shopping_*)
  // 実測で確定した構造:
  //   商品ブロック B (block[N], N=本文 anchor 番号):
  //     B[0][0]      = "http(s)://googleusercontent.com/shopping_content/<N>_(card|link)" (結合キー)
  //     B[8]         = 見出し (本家の青リンク文言。 例 "スケッチャーズ スリップインズ")
  //     B[3][0][10]  = 商品名 (長)   B[3][0][13] = 価格 (¥9,990)   B[3][0][16] = 評価 (0.0=無)
  //     B[3][0][20]  = ブランド      B[3][0][27] = 店名            B[3][0][12] = 販売リンク url
  //     B[3][0][15]  = 商品画像配列 [ [[gstatic/shopping url], null, W, H, ...], ... ]
  // block[0] (anchor 無) は検索結果プール (複数商品混在・本家非表示) で除外。 grounding 複製
  // (12.* 配下) は同 N の重複として document order 先勝ち (content 0.0.* が先) で自然排除。
  function pl086CollectShoppingBlocks(turnSlot) {
    const byKey = {};   // "<N>_<kind>" → block info (先勝ち)
    function fieldStr(arr, i) { return (arr && typeof arr[i] === 'string') ? arr[i] : ''; }
    (function walk(value) {
      if (!Array.isArray(value)) return;
      const head = value[0];
      const anchor = (Array.isArray(head) && typeof head[0] === 'string') ? head[0] : '';
      const m = anchor.match(/shopping_content\/(\d+)_(card|link)/);
      // 商品ブロック判定: anchor + 商品データ B[3][0] を持つ
      if (m && Array.isArray(value[3]) && Array.isArray(value[3][0])) {
        const key = m[1] + '_' + m[2];
        if (!byKey[key]) {
          const p = value[3][0];
          // 画像配列 p[15] は [ [ [[gstatic url],null,W,H,...] ], ... ] とラッパーが 1 段挟まるため
          // 部分木 walk で gstatic url を出現順収集 (固定 index 依存を避ける)。
          const imgs = [];
          (function imgWalk(v) {
            if (!Array.isArray(v)) return;
            if (Array.isArray(v[0]) && typeof v[0][0] === 'string'
                && /^https?:\/\/encrypted-tbn\d?\.gstatic\.com\//i.test(v[0][0])) {
              imgs.push(v[0][0]); return;
            }
            for (let i = 0; i < v.length; i++) imgWalk(v[i]);
          })(p[15]);
          byKey[key] = {
            title: fieldStr(p, 10), price: fieldStr(p, 13), rating: fieldStr(p, 16),
            brand: fieldStr(p, 20), link: fieldStr(p, 12),
            images: imgs,
          };
        }
        return;   // ブロック配下は再帰しない
      }
      for (let i = 0; i < value.length; i++) walk(value[i]);
    })(turnSlot === undefined ? null : turnSlot);
    return byKey;
  }

  // PL-086: 本文の shopping_content/<N>_(card|link) placeholder を商品ブロックで解決する。
  // 本文の埋め込み形 (spike で確定):
  //   _link = `[見出し](…/shopping_content/N_link)` の markdown リンク (本家の青リンク)。
  //           → URL 部だけ実販売リンクへ差し替え (見出しは本文側を保持、 二重リンク化を回避)。
  //   _card = 単独 URL (自前段落)。 → 画像 (先頭 SHOP_CARD_IMG_MAX 枚) + 商品情報 + 販売リンクの
  //           カードへ展開。 見出しは直前の _link が担うため card 側では出さない (重複回避)。
  // 解決できない placeholder は素 URL 露出を避けるため除去 (normalizeNetworkMarkdown でも保険)。
  function pl086ResolveShopping(text, turnSlot) {
    let result = String(text || '');
    if (result.indexOf('shopping_content') === -1) return result;
    const blocks = pl086CollectShoppingBlocks(turnSlot);
    const SHOP_CARD_IMG_MAX = 3;   // 本家カルーセル初期表示相当 (spike: Skechers 3 枚観測)
    function infoLine(b) {
      const meta = [];
      if (b.price) meta.push(b.price);
      if (b.brand) meta.push(b.brand);
      if (b.rating && b.rating !== '0.0' && b.rating !== '0') meta.push('★' + b.rating);
      let line = b.title || '';
      if (meta.length) line = (line ? line + ' — ' : '') + meta.join(' · ');
      return line.replace(/[*\]]/g, '').replace(/\s+/g, ' ').trim();
    }
    return result.replace(
      /https?:\/\/(?:[a-z0-9.-]+\.)?googleusercontent\.com\/shopping_content\/(\d+)_(card|link)/gi,
      function (orig, n, kind) {
        const b = blocks[n + '_' + kind];
        if (!b) return '';   // 未解決は素 URL を出さず除去
        if (kind === 'link') {
          // markdown リンクの URL 部のみ差し替え (本文の `[見出し]` はそのまま。 HTML/PDF は
          // クリック可・URL 非表示で描画される)。 PL-103: 販売 URL 内の生 ( ) は markdown
          // リンク解析を壊し PDF/HTML で URL がテキスト流出するため %28/%29 へエンコードして
          // 安全化 (リンク機能は維持しつつ長 URL の露出を防ぐ)。
          const link = b.link || '';
          return link
            ? link.replace(/\(/g, '%28').replace(/\)/g, '%29')
            : orig.replace(/^https?:\/\/.*$/, '');
        }
        // _card: 画像 + 商品情報 (見出しは直前 _link が担当、 販売リンクも _link 側のみ。
        // 本家のカードに「商品ページを見る」ボタンは無いため card 側にリンクは付けない)。
        const lines = [];
        const imgs = b.images.slice(0, SHOP_CARD_IMG_MAX);
        if (imgs.length) {
          lines.push(imgs.map(function (u) { return '[[ASA_GEMIMG:' + u + ']]'; }).join(' '));
          lines.push('');
        }
        const info = infoLine(b);
        if (info) { lines.push('*' + info + '*'); }
        return lines.join('\n');
      }
    );
  }

  // assistant raw text 内の画像 placeholder を、 同 turn の gg URL と出現順で対応づけて
  // 中間マーカー [[ASA_GEMIMG:url]] へ置換する。 gg URL が足りない placeholder は従来どおり
  // [AI生成] へ落とす (degrade)。 対象 placeholder 2 形 (1 pass・共有 index で document order 維持):
  //   - image_generation_content URL (PL-078: 通常の AI 生成画像)
  //   - `[image-tag: code-generated-image-<n>-<id>]` (PL-125: コード実行生成チャート)
  function pl078gMarkInlineImages(text, ggUrls) {
    if (!text) return text || '';
    let i = 0;
    return String(text).replace(
      /https?:\/\/(?:[a-z0-9.-]+\.)?googleusercontent\.com\/image_generation_content\/\d+|\[image-tag:\s*code-generated-image-\d+-\d+\]/gi,
      function () {
        const url = ggUrls && ggUrls[i++];
        return url ? '[[ASA_GEMIMG:' + url + ']]' : '[AI生成]';
      }
    );
  }

  // 中間マーカー [[ASA_GEMIMG:url]] を [[ASA_IMAGE_N]] (message 内 image_blocks 添字) +
  // image_blocks へ正規化し、 取得すべき (key,url) を収集して返す。
  // PL-086: gstatic /shopping (商品カルーセル) サムネは CORS で ACAO を返さずバイト fetch 不可
  // (本家/capture も <img src> 参照で表示するのみ)。 これらは img_ref を立てず src=URL 直参照の
  // block にし fetch 対象から除外する (embed→<img src=url> オンライン表示、 txt→[画像] degrade)。
  // licensed-image / images / gg 等 (ACAO 有 or cookie fetch 可) は従来どおり img_ref でバイト埋込。
  function pl078gNormalizeImages(messages) {
    const refs = [];
    const seen = new Set();
    if (!Array.isArray(messages)) return refs;
    for (const msg of messages) {
      if (!msg || typeof msg.text !== 'string' || msg.text.indexOf('[[ASA_GEMIMG:') === -1) continue;
      const blocks = Array.isArray(msg.image_blocks) ? msg.image_blocks.slice() : [];
      let idx = blocks.length;
      msg.text = msg.text.replace(PL078G_GEMIMG_RE, function (_match, url) {
        const isShoppingRef = /encrypted-tbn\d?\.gstatic\.com\/shopping\b/i.test(url);
        if (isShoppingRef) {
          const marker = '[[ASA_IMAGE_' + idx + ']]';
          idx++;
          // src=URL 直参照 + inline=true (横並び gallery。 renderer が段落内インライン配置)。
          blocks.push({ alt: '', src: url, data_uri: '', title: '', img_ref: '', inline: true });
          return marker;
        }
        const key = pl078gImageKey(url);
        if (!key) return '[AI生成]';
        const marker = '[[ASA_IMAGE_' + idx + ']]';
        idx++;
        // PL-089: AI 生成画像 (lh3 /gg/ path か 表示用 blob:) は本家同様 列幅まで拡大表示
        // させたいので gen フラグを立てる。 web 引用 (licensed-image / encrypted-tbn) は false。
        const isGen = /\/gg(?:-dl)?\//.test(url) || /^blob:/i.test(url);
        blocks.push({ alt: '', src: '', data_uri: '', title: '', img_ref: key, gen: isGen });
        if (!seen.has(key)) { seen.add(key); refs.push({ key: key, url: url }); }
        return marker;
      });
      if (blocks.length) msg.image_blocks = blocks;
    }
    return refs;
  }

  let _pl078gInflight = [];   // 進行中の画像取得依頼 (background 応答=取得+別送完了で resolve)

  // blob: URL をページ内 (content.js) で data URI 化する。 blob はページローカルのため CORS 無しで
  // fetch でき、 background からは参照できない。
  function pl078gFetchBlobDataUri(url) {
    return fetch(url).then(r => r.blob()).then(blob => new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onloadend = () => resolve(fr.result);
      fr.onerror = reject;
      fr.readAsDataURL(blob);
    }));
  }

  // 収集した画像参照を取得依頼する。 URL scheme で経路を振り分ける:
  //   http(s) (gg 生成 lh3 / web 引用 gstatic) → background が host 権限 fetch (asa_image_url)
  //   blob:   (Gemini が表示用に blob 化した生成画像) → content.js が fetch し data URI を中継 (asa_image)
  // いずれも ASA は event='image' を key=img_ref で受け snapshot に後合流する。 送出済キーは再送しない。
  // 各依頼の完了 promise を蓄積し、 通常保存は await して「画像が ASA に届いてから snapshot 送出」を保証する。
  function pl078gRequestImages(refs) {
    if (!Array.isArray(refs) || !refs.length) return;
    const convId = getConversationIdFromUrl(location.href);
    for (const r of refs) {
      if (!r || !r.key || !r.url || _pl078gSentKeys.has(r.key)) continue;
      _pl078gSentKeys.add(r.key);
      let p;
      if (/^blob:/i.test(r.url)) {
        p = pl078gFetchBlobDataUri(r.url).then(dataUri => new Promise(resolve => {
          try {
            chrome.runtime.sendMessage(
              { type: 'asa_image', key: r.key, data_uri: dataUri, conv_id: convId },
              () => { void chrome.runtime.lastError; resolve(); }
            );
          } catch (_e) { resolve(); }
        })).catch(() => {});
      } else {
        p = new Promise(resolve => {
          try {
            chrome.runtime.sendMessage(
              { type: 'asa_image_url', key: r.key, url: r.url, conv_id: convId },
              () => { void chrome.runtime.lastError; resolve(); }
            );
          } catch (_e) { resolve(); }
        });
      }
      _pl078gInflight.push(p);
    }
  }

  // 進行中の画像取得依頼が全て完了 (= ASA へ別送済) するまで待つ。 タイムアウトで打ち切り
  // (画像 1 枚の取得失敗で保存全体を止めない)。
  function pl078gAwaitImages(timeoutMs) {
    const ps = _pl078gInflight.slice();
    _pl078gInflight = [];
    if (!ps.length) return Promise.resolve();
    const timeout = new Promise(resolve => setTimeout(resolve, timeoutMs || 25000));
    return Promise.race([Promise.all(ps), timeout]);
  }

  function pl078gResetSentImages() { _pl078gSentKeys.clear(); _pl078gInflight = []; }

  function buildSnapshot(messages) {
    // PL-078: 正規化は msg.text / image_blocks を書き換えるため、 呼び出し元が再利用する配列を
    // 汚染しないよう message を複製してから処理する (防御的複製)。
    const cloned = Array.isArray(messages)
      ? messages.map(m => {
          if (!m || typeof m !== 'object') return m;
          const c = Object.assign({}, m);
          if (Array.isArray(m.image_blocks)) c.image_blocks = m.image_blocks.map(b => Object.assign({}, b));
          return c;
        })
      : messages;
    const imageRefs = pl078gNormalizeImages(cloned);     // PL-078: 中間マーカー→image_blocks 正規化
    pl078gRequestImages(imageRefs);                       // PL-078: 画像バイトを background 経由で取得・別送
    messages = cloned;
    return {
      type: 'snapshot_result',
      url: location.href,
      title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
      project: getNotebookName(),
      timestamp: new Date().toISOString(),
      messages,
      pageState: currentPageState(),
      debug: {
        ...(state.lastCaptureDebug && typeof state.lastCaptureDebug === 'object' ? state.lastCaptureDebug : {}),
        isTemporaryChat: isTemporaryChat(),
        conversationId: getConversationIdFromUrl(location.href),
        hasHistoryTemplate: (() => {
          const conversationId = getConversationIdFromUrl(location.href);
          return !!getConversationStateBucket(state.historyTemplateByConversationId, conversationId);
        })(),
        networkMessageCount: (() => {
          const conversationId = getConversationIdFromUrl(location.href);
          const networkMessages = conversationId ? getConversationStateBucket(state.networkMessagesByConversationId, conversationId) : null;
          return Array.isArray(networkMessages) ? networkMessages.length : 0;
        })(),
        pageHookMessageCount: state.pageHookMessageCount,
        pageHookInstalled: !!state.hookStatus.installed,
        pageHookCandidateRequestCount: Number(state.hookStatus.candidateRequestCount || 0),
        pageHookCandidateResponseCount: Number(state.hookStatus.candidateResponseCount || 0),
        pageHookTemplateCount: Number(state.hookStatus.templateCount || 0),
        pageHookLastTemplateReason: String(state.hookStatus.lastTemplateReason || ''),
        pageHookLastCandidateUrl: String(state.hookStatus.lastCandidateUrl || ''),
        lastNetworkParseStatus: String(state.lastNetworkParseStatus || ''),
        lastNetworkParseSample: String(state.lastNetworkParseSample || ''),
        returnedMessageCount: Array.isArray(messages) ? messages.length : 0,
      },
    };
  }

  // ======== メッセージング ========

  function sendRuntimeMessage(message) {
    try {
      chrome.runtime.sendMessage(message, () => {
        void chrome.runtime.lastError;
      });
    } catch (_error) {
      return;
    }
  }

  function reportStatus(force) {
    const pageState = currentPageState();
    const key = `${location.href}::${pageState}`;
    if (key === state.lastStatusKey) {
      if (!force) return;
    }
    state.lastStatusKey = key;
    sendRuntimeMessage({
      type: 'page_status',
      pageState,
      url: location.href,
      title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
      project: getNotebookName(),
      observedAt: new Date().toISOString(),
      details: collectStatusDetails(),
    });
  }

  // ======== メッセージリスナー ========

  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (!message || typeof message !== 'object') {
      return false;
    }
    if (message.type === 'capture_snapshot') {
      // CDK スクロールループは非同期 (最大 ~4分) → return true で応答チャネルを保持
      collectConversation().then(messages => {
        sendResponse(buildSnapshot(messages));
      }).catch(_err => {
        sendResponse(buildSnapshot([]));
      });
      return true;
    }
    if (message.type === 'get_dom_messages') {
      // 現在DOMに表示されているメッセージのみを即時取得（ネットワーク再生なし）
      // スクロール&スナップ用：ユーザーがスクロールした位置のメッセージを都度収集する
      const messages = buildDomMessages();
      pl078gRequestImages(pl078gNormalizeImages(messages));  // PL-078: buildSnapshot 非経由のため個別正規化
      const vp = getCdkViewport();
      const scrollTop = vp !== null ? vp.scrollTop : window.scrollY;
      const scrollHeight = vp ? vp.scrollHeight : document.documentElement.scrollHeight;
      const clientHeight = vp ? vp.clientHeight : window.innerHeight;
      sendResponse({
        type: 'dom_messages_result',
        messages,
        url: location.href,
        title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
        project: getNotebookName(),
        scrollTop: Math.round(scrollTop),
        scrollHeight: Math.round(scrollHeight),
        clientHeight: Math.round(clientHeight),
      });
      return true;
    }
    if (message.type === 'ping_content') {
      sendResponse({
        ok: true,
        url: location.href,
        title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
        project: getNotebookName(),
      });
      return true;
    }
    if (message.type === 'popup_action') {
      // PL-095: toolbar popup の「保存 / 設定」ボタン。 widget クリックと同一経路。
      const action = String(message.action || '');
      if (action === 'save_open' || action === 'settings') {
        Promise.resolve(performWidgetAction(action))
          .then(() => { try { sendResponse({ ok: true }); } catch (_e) {} })
          .catch(() => { try { sendResponse({ ok: false }); } catch (_e) {} });
        return true;
      }
      sendResponse({ ok: false });
      return true;
    }
    if (message.type === 'start_realtime_capture') {
      pl078gResetSentImages();   // PL-078: 新 capture session → 画像を fresh ASA へ再 flush
      sendResponse({ ok: true });
      return true;
    }
    if (message.type === 'stop_realtime_capture') {
      sendResponse({ ok: true });
      return true;
    }
    if (message.type === 'get_realtime_buffer') {
      // 保存要求ごとに現 DOM を新規収集する。 Gemini は画面外の turn も DOM に保持するため
      // 全 turn が得られ、 画像も現 src (gg は表示用 blob:、 web 引用は gstatic) で拾える。
      const messages = buildDomMessages();
      sendResponse(buildSnapshot(messages));
      return true;
    }
    return false;
  });

  // ======== Widget (PL-013 CP3) ========

  function sendHeartbeat() {
    try {
      chrome.runtime.sendMessage(
        {
          type: 'bridge_heartbeat',
          pageState: currentPageState(),
          url: location.href,
          title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
          project: getNotebookName(),
          observedAt: new Date().toISOString(),
        },
        (response) => {
          void chrome.runtime.lastError;
          if (response && response.widget_state) {
            applyWidgetState(response.widget_state);
          }
        }
      );
    } catch (_error) {
      return;
    }
  }

  function sendWidgetAction(action, payload) {
    return new Promise((resolve) => {
      try {
        chrome.runtime.sendMessage(
          {
            type: 'widget_action',
            action: String(action || ''),
            url: location.href,
            title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
            project: getNotebookName(),
            payload: payload && typeof payload === 'object' ? payload : {},
          },
          (response) => {
            void chrome.runtime.lastError;
            if (response && response.widget_state) {
              applyWidgetState(response.widget_state);
            }
            resolve(response || { ok: false, widget_state: {} });
          }
        );
      } catch (_error) {
        resolve({ ok: false, widget_state: {} });
      }
    });
  }

  // PL-095: widget クリックと toolbar popup の両方から同一保存/設定経路を呼ぶため
  // wrap() の中核 (save_pending → collectConversation → save_open snapshot 送出) を関数化。
  // popup は別 document でページ DOM/API に触れないため、 content script の本関数へ
  // 委譲する (= subscription downgrade で widget が消えても保存導線を残す)。
  async function performWidgetAction(action) {
    if (action === 'save_open') {
      pl078gResetSentImages();   // PL-078: 手動保存 → 画像を fresh ASA へ再 flush
      await sendWidgetAction('save_pending', { capture_origin: 'widget_click' });
      let snapshot = null;
      try {
        const messages = await collectConversation();
        snapshot = buildSnapshot(messages);     // PL-078: normalize + 画像取得依頼を発火
        await pl078gAwaitImages();               // PL-078: 画像が ASA へ届くまで待ってから保存送出 (data_uri 合流順序保証)
      } catch (_e) {}
      return sendWidgetAction('save_open', { snapshot, capture_origin: 'widget_click' });
    }
    return sendWidgetAction(action, null);
  }

  function widgetTemplate() {
    return `
<style>
:host { all: initial; color: inherit; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; display: block; }
.asa-dock { display: grid; grid-template-columns: repeat(2, 36px); gap: 4px; padding: 6px; background: transparent; color: inherit; }
.asa-dock button { position: relative; width: 36px; height: 36px; padding: 0; border: none; background: transparent; cursor: pointer; font-size: 18px; border-radius: 4px; line-height: 36px; color: inherit; transition: transform 80ms ease, background-color 80ms ease, box-shadow 80ms ease; }
.asa-dock button:hover { background: rgba(127,127,127,.18); }
.asa-dock button:active { transform: translateY(1px) scale(0.94); background: rgba(127,127,127,.32); box-shadow: inset 0 1px 2px rgba(0,0,0,.25); }
.asa-dock button[data-active="true"] { background: rgba(217,60,62,.22); }
.asa-dock button[data-action="toggle_topmost"] { background: transparent; }
.asa-dock button[data-action="toggle_topmost"][data-active="true"] { background: transparent; }
/* PL-095: save 矢印 (U+2B07) は欧文フォントだと細字・絵文字フォントだと青四角になり
   英語 locale で見た目が崩れる。 日本語フォントの黒太グリフを全 locale で採用させるため
   font-family のみ固定 (位置決め padding-top/line-height/中央寄せは不変、 元配置に忠実)。 */
.asa-dock button[data-action="save_open"] { padding-top: 2px; box-sizing: border-box; font-family: "Yu Gothic", "Meiryo", "MS Gothic", sans-serif; }
.asa-dock button[data-tooltip]::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, -6px); background: #1f1f1f; color: #fff; padding: 3px 8px 1px; border-radius: 4px; font-size: 12px; line-height: 1.3; white-space: nowrap; pointer-events: none; opacity: 0; transition: opacity 80ms ease; z-index: 1; }
:host([data-theme="dark"]) .asa-dock button[data-tooltip]::after { background: #fff; color: #1f1f1f; box-shadow: 0 1px 4px rgba(0,0,0,.15); }
.asa-dock button[data-tooltip][data-tooltip-pos="down"]::after { top: 100%; bottom: auto; transform: translate(-50%, 6px); }
.asa-dock button[data-tooltip]:hover::after { opacity: 1; transition-delay: 120ms; }
</style>
<div class="asa-dock" role="toolbar">
  <button type="button" data-action="save_open" data-i18n="save" data-i18n-tooltip="true" data-tooltip="会話を保存">⬇</button>
  <button type="button" data-action="settings" data-i18n="settings" data-i18n-tooltip="true" data-tooltip="設定">🛠</button>
  <button type="button" data-action="toggle_realtime" data-i18n="realtime" data-i18n-tooltip="true" data-tooltip="キャプチャモード">⦿</button>
  <button type="button" data-action="toggle_topmost" data-i18n="toggle_topmost" data-i18n-tooltip="true" data-tooltip="ダイアログを最前面に固定">★</button>
</div>`;
  }

  function widgetSide() {
    try {
      const v = localStorage.getItem(WIDGET_SIDE_KEY);
      if (v === 'left') return 'left';
    } catch (_e) {
      void 0;
    }
    return 'right';
  }

  function widgetHostStyle() {
    if (widgetSide() === 'left') {
      return 'position: absolute; top: 50%; left: 0; transform: translate(calc(-100% - 8px), -50%); z-index: 10;';
    }
    return 'position: absolute; top: 50%; right: 0; transform: translate(calc(100% + 8px), -50%); z-index: 10;';
  }

  function findComposerAnchor() {
    for (const sel of WIDGET_ANCHOR_SELECTORS) {
      const el = document.querySelector(sel);
      if (el) return el;
    }
    return null;
  }

  function ensureAnchorPositioned(anchor) {
    try {
      const computed = getComputedStyle(anchor).position;
      if (computed === 'static' || !computed) {
        anchor.style.position = 'relative';
      }
    } catch (_e) {
      void 0;
    }
  }

  function buildWidgetHost() {
    const host = document.createElement('div');
    host.id = WIDGET_HOST_ID;
    host.style.cssText = widgetHostStyle();
    const root = host.attachShadow({ mode: 'closed' });
    root.innerHTML = widgetTemplate();
    state.widgetRoot = root;
    state.widgetHost = host;
    bindWidgetTheme(host);
    bindWidgetButtons(root);
    applyWidgetI18n(state.widgetLang);
    applyCachedTopmost(root);
    return host;
  }

  function applyCachedTopmost(root) {
    let cached;
    try {
      const v = localStorage.getItem(WIDGET_TOPMOST_KEY);
      if (v === 'true') cached = true;
      else if (v === 'false') cached = false;
    } catch (_e) {
      void 0;
    }
    if (cached === undefined) return;
    const btn = root.querySelector('[data-action="toggle_topmost"]');
    if (btn) {
      btn.dataset.active = cached ? 'true' : 'false';
      btn.textContent = cached ? '★' : '☆';
    }
  }

  function injectWidget() {
    if (!document.body) return false;
    const existing = document.getElementById(WIDGET_HOST_ID);
    const anchor = findComposerAnchor();
    if (!anchor) {
      if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
      state.widgetAnchor = null;
      return false;
    }
    ensureAnchorPositioned(anchor);
    if (existing && existing.parentElement === anchor) {
      state.widgetAnchor = anchor;
      return true;
    }
    if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
    const host = buildWidgetHost();
    anchor.appendChild(host);
    state.widgetAnchor = anchor;
    return true;
  }

  function attachAnchorObserver() {
    if (state.widgetObserver) return;
    try {
      const obs = new MutationObserver(() => {
        const anchor = findComposerAnchor();
        const host = document.getElementById(WIDGET_HOST_ID);
        if (!anchor) return;
        if (!host || host.parentElement !== anchor) {
          injectWidget();
        }
      });
      obs.observe(document.body, { childList: true, subtree: true });
      state.widgetObserver = obs;
    } catch (_e) {
      void 0;
    }
    if (!state.widgetSentinelInterval) {
      state.widgetSentinelInterval = window.setInterval(() => {
        const anchor = findComposerAnchor();
        const host = document.getElementById(WIDGET_HOST_ID);
        if (!anchor) return;
        if (!host || host.parentElement !== anchor) {
          injectWidget();
        }
      }, 1000);
    }
  }

  function bindWidgetTheme(host) {
    try {
      const apply = () => {
        const cls = (document.body && document.body.className) || '';
        host.dataset.theme = cls.includes('dark-theme') ? 'dark' : 'light';
      };
      apply();
      if (document.body) {
        const obs = new MutationObserver(apply);
        obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
      }
    } catch (_e) {
      host.dataset.theme = 'light';
    }
  }

  function bindWidgetButtons(root) {
    // PL-025 CP5: async push (collectConversation 数分 wait) の bug/正常 区別不能 UX を解消するため
    // split events 採用。save_open click 同期で `widget_save_pending` 即送 → ASA 側で fetching dialog
    // 表示開始 → collectConversation + buildSnapshot await → `widget_save_open` snapshot 同梱送信 →
    // ASA 側 dialog dismiss + 保存続行。pending 即送は background.js fix-A singleton と併用の防御 2 段
    // (race は fix-A 単独で消えるが、await sendMessage で逐次保証も併せて行う)。
    // PL-024 CP3: collectConversation 自走 + snapshot 同梱 push (sender tab 自確定) は維持。
    // PL-095: save_open の中核は performWidgetAction に集約 (popup からも同一経路を呼ぶため)。
    const wrap = (action) => async (e) => {
      e.preventDefault();
      e.stopPropagation();
      await performWidgetAction(action);
    };
    root.querySelector('[data-action="save_open"]').addEventListener('click', wrap('save_open'));
    root.querySelector('[data-action="settings"]').addEventListener('click', wrap('settings'));
    root.querySelector('[data-action="toggle_realtime"]').addEventListener('click', wrap('toggle_realtime'));
    root.querySelector('[data-action="toggle_topmost"]').addEventListener('click', wrap('toggle_topmost'));
    root.querySelectorAll('button[data-i18n-tooltip="true"]').forEach((btn) => {
      btn.addEventListener('mouseenter', () => {
        const rect = btn.getBoundingClientRect();
        btn.dataset.tooltipPos = rect.top < 40 ? 'down' : 'up';
      });
    });
  }

  function applyWidgetI18n(lang) {
    state.widgetLang = lang;
    if (!state.widgetRoot) return;
    const dict = WIDGET_I18N[lang] || WIDGET_I18N.ja;
    if (state.widgetHost) state.widgetHost.dataset.lang = lang;
    state.widgetRoot.querySelectorAll('[data-i18n]').forEach((el) => {
      const text = dict[el.dataset.i18n];
      if (!text) return;
      if (el.dataset.i18nTooltip === 'true') {
        el.dataset.tooltip = text;
        el.setAttribute('aria-label', text);
      } else {
        el.textContent = text;
      }
    });
  }

  function applyWidgetState(ws) {
    if (!state.widgetRoot || !ws || typeof ws !== 'object') return;
    if (ws.lang) applyWidgetI18n(String(ws.lang));
    if (ws.capabilities && typeof ws.capabilities === 'object') {
      const caps = ws.capabilities;
      const rt = state.widgetRoot.querySelector('[data-action="toggle_realtime"]');
      const tp = state.widgetRoot.querySelector('[data-action="toggle_topmost"]');
      if (rt) rt.style.display = caps.realtime === false ? 'none' : '';
      if (tp) tp.style.display = caps.toggle_topmost === false ? 'none' : '';
    }
    if (typeof ws.realtime_state === 'boolean') {
      const btn = state.widgetRoot.querySelector('[data-action="toggle_realtime"]');
      if (btn) btn.dataset.active = ws.realtime_state ? 'true' : 'false';
    }
    if (typeof ws.dialog_topmost === 'boolean') {
      const btn = state.widgetRoot.querySelector('[data-action="toggle_topmost"]');
      if (btn) {
        btn.dataset.active = ws.dialog_topmost ? 'true' : 'false';
        btn.textContent = ws.dialog_topmost ? '★' : '☆';
      }
      try {
        localStorage.setItem(WIDGET_TOPMOST_KEY, ws.dialog_topmost ? 'true' : 'false');
      } catch (_e) {
        void 0;
      }
    }
  }

  function ensureWidget() {
    const tryInject = () => {
      if (!document.body) return;
      const ok = injectWidget();
      if (ok) {
        attachAnchorObserver();
      } else {
        window.setTimeout(tryInject, WIDGET_ANCHOR_RETRY_MS);
      }
    };
    if (document.body) {
      tryInject();
    } else {
      window.addEventListener('DOMContentLoaded', tryInject, { once: true });
    }
  }

  // ======== ハートビート・ステータス ========

  window.addEventListener('pagehide', () => {
    sendRuntimeMessage({
      type: 'tab_closed',
      url: location.href,
      title: getGeminiShareTitle() || getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
      project: getNotebookName(),
      observedAt: new Date().toISOString(),
    });
  });

  window.setInterval(() => {
    const current = String(location.href || '');
    if (current !== state.lastUrl) {
      state.lastUrl = current;
      state.lastStatusKey = '';
      reportStatus(true);
    }
  }, 1000);

  window.setInterval(() => {
    reportStatus(false);
  }, STATUS_INTERVAL_MS);

  window.setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);

  sendRuntimeMessage({
    type: 'content_ready',
    url: location.href,
    title: getDocumentChatTitle() || getSidebarTitle() || 'Gemini_Conversation',
    project: getNotebookName(),
    observedAt: new Date().toISOString(),
  });
  reportStatus(true);
  ensureWidget();
})();
