// ChatGPT の会話 JSON ({mapping, current_node}) を ASA の発言形へ変換する共有部品。
//
// PL-134: 公式エクスポート ZIP の取込アドオンは、拡張が site API から受け取るのと
// 同じ形の JSON を扱う。変換を Python 側へ書き写すと同じ処理が 2 か所になり、
// 片方だけ直る食い違い (PL-056) を再発させるため、この file を唯一の変換元とし
// 拡張 (content.js の前に読み込み) と取込ページの両方から使う。
//
// 依存: 無し (DOM / chrome API に触れない純粋なデータ変換)。
(function (root) {
  'use strict';
  function _pl078FileId(assetPointer) {
    // PL-134: scheme を先に落とす。 `file-service://file-XXXX` 形は scheme 自体が
    // `file-` で始まるため、 素で match すると先頭の `file-service` に当たり全ての
    // 画像が同じ id ('file_service') に潰れる (公式エクスポート 617 会話で 22 枚が
    // 衝突するのを実測)。 潰れた id は候補収集の file 重複除去にも掛かるため、
    // 同一会話に複数枚あると 2 枚目以降が「同じ画像」として捨てられていた。
    // `sediment://file_XXXX` 形は影響を受けていなかった。
    const tail = String(assetPointer || '').split('/').pop();
    const m = tail.match(/file[_-]([A-Za-z0-9]+)/);
    return m ? 'file_' + m[1] : '';     // DOM src は file_ 形 (spike 確定)
  }

  // PL-099: 複数枚画像生成の候補は assistant 生成ノードの子孫に親子チェーンで並ぶ
  // (tool ctype=multimodal_text + asset_pointer、 image_gen_group_id で束ね
  // generation_index で順序)。 first-break だと採用 1 枚しか拾わないため、 チェーンを
  // DFS で降りて候補 asset_pointer を全収集する。 「tool + multimodal_text +
  // asset_pointer」 が続く間だけ辿り、 次 turn の text node で停止 (取りすぎ防止)。
  // 返却: [{msgId, assetPointer, title, gidx}] を generation_index 昇順 + file id dedup。
  function _pl099CollectImageGenVariants(mapping, assistantNode) {
    const variants = [];
    const seenNode = new Set();
    const seenFile = new Set();
    let groupId = null;   // 最初の候補の image_gen_group_id (別生成の連鎖を弾く境界)
    // PL-118: 画像生成クラスタ (dalle 呼び出し → tool 中間 node → multimodal 画像) の
    // 内部だけを辿る再帰 boundary。 旧実装は最初の非 multimodal_text node で即停止し、
    // dalle と実画像の間に tool(ct=text) が 1 段挟まる生成 (データ駆動グラフ等) で画像に
    // 届かず turn ごと drop していた。 逆に無条件再帰は children が次ターンを指す線形
    // mapping 構造ゆえ後続ターンの画像まで巻き込む。 そこで「生成機構 node」(role=tool /
    // recipient≠all の assistant) と画像 node にだけ降り、 user / recipient=all assistant /
    // system = 次発言枠の境界では降りない。 これで中間 node 跨ぎの深部画像に到達しつつ
    // 後続ターン混入を防ぐ (共有 RSC 復号 + live 保存 + 既存会話 4 件で旧挙動一致を確認)。
    function _isGenMachinery(node) {
      const m = node && node.message;
      if (!m) return true;                  // 構造 node は通過
      const role = String((m.author || {}).role || '').toLowerCase();
      if (role === 'tool') return true;
      if (role === 'assistant') {
        const rcpt = m.recipient;
        return !!(rcpt && rcpt !== 'all');  // dalle 等の tool-call assistant
      }
      return false;                         // user / assistant(all) / system = 次ターン境界
    }
    function _isMultimodal(node) {
      const m = node && node.message;
      // PL-120: role チェック無しだと user 添付画像 node (次発言枠の境界) を素通しして
      // しまい、 候補チェーンに user 画像が混入する (境界貫通で後続の別生成まで巻き込む)。
      // _isGenMachinery の user 境界と揃える。
      const role = String((m && m.author && m.author.role) || '').toLowerCase();
      if (role === 'user') return false;
      const c = (m && m.content) || {};
      return c.content_type === 'multimodal_text';
    }
    function visit(nodeId) {
      if (!nodeId || seenNode.has(nodeId)) return;
      seenNode.add(nodeId);
      const n = mapping[nodeId];
      const m = n && n.message;
      const content = (m && m.content) || {};
      if (content.content_type === 'multimodal_text') {
        const parts = content.parts || [];
        const ptrs = [];
        for (const p of parts) {
          if (p && typeof p === 'object' && p.asset_pointer) ptrs.push(String(p.asset_pointer));
        }
        if (ptrs.length > 0) {
          const meta = m.metadata || {};
          const gid = meta.image_gen_group_id ? String(meta.image_gen_group_id) : null;
          // 別生成の連鎖を併合しない: 最初の候補の group を基準に、 group 付きで不一致なら
          // この枝を停止 (子も辿らない = 別生成画像の混入防止、 旧挙動維持)。
          if (gid && groupId !== null && gid !== groupId) return;
          if (gid && groupId === null) groupId = gid;
          const title = String(meta.image_gen_title || meta.async_task_title || '').trim();
          const gidx = (typeof meta.generation_index === 'number') ? meta.generation_index : variants.length;
          for (const ptr of ptrs) {
            const fileId = _pl078FileId(ptr);
            if (!fileId || seenFile.has(fileId)) continue;
            seenFile.add(fileId);
            variants.push({ msgId: nodeId, assetPointer: ptr, fileId, title, gidx });
          }
        }
      }
      const kids = (n && n.children) || [];
      for (const ch of kids) {
        const cn = mapping[ch];
        if (_isGenMachinery(cn) || _isMultimodal(cn)) visit(ch);   // PL-118: 機構/画像 node だけ降りる
      }
    }
    const startKids = (assistantNode && assistantNode.children) || [];
    for (const ch of startKids) {
      const cn = mapping[ch];
      if (_isGenMachinery(cn) || _isMultimodal(cn)) visit(ch);
    }
    variants.sort((a, b) => a.gidx - b.gidx);
    return variants;
  }

  // PL-133: 思考経由 (ct=thoughts / reasoning_recap を挟む) の画像生成では tool call の
  // assistant node が作られず、 画像を持つ role=tool node が user 発言の直下に単独で
  // 付く (旧形は assistant(recipient=dalle 等) → tool(multimodal_text) の 2 段)。 起点の
  // assistant が無いため _pl099CollectImageGenVariants は 1 枚も拾えず、 role filter が
  // tool を落として turn ごと消えていた (画像だけでなく発言枠ごと消失)。 単独 tool node を
  // assistant 発言枠として拾い直す。 旧形の tool node は候補収集側が既に消費しているので、
  // 呼び出し側で消費済 node を skip し二重取り込みを防ぐ。
  // 起点 node に依存せず、 node 単体が「画面に見える画像」を持つかだけで判定する。 この
  // 箇所は起点依存の設計ゆえ ChatGPT の構造変更で 4 度壊れており (PL-078 / PL-099 /
  // PL-118 / PL-133)、 起点の形に条件を置かないのが再発を止める唯一の道。
  // 返却は turn ではなく画像 block 群 + 束ね key。 turn 化は呼び出し側が行う (連続する
  // 同 group の候補を 1 発言にまとめるため)。
  function _pl133StandaloneImageBlocks(msg) {
    const content = msg.content || {};
    if (content.content_type !== 'multimodal_text') return null;
    const meta = msg.metadata || {};
    if (meta.is_visually_hidden_from_conversation) return null;
    const rcpt = msg.recipient;
    if (rcpt && rcpt !== 'all') return null;
    const title = String(meta.image_gen_title || meta.async_task_title || '').trim();
    const gidx = (typeof meta.generation_index === 'number') ? meta.generation_index : 0;
    const blocks = [];
    const seenFile = new Set();
    for (const part of content.parts || []) {
      if (!part || typeof part !== 'object' || !part.asset_pointer) continue;
      const fileId = _pl078FileId(String(part.asset_pointer));
      if (!fileId || seenFile.has(fileId)) continue;
      seenFile.add(fileId);
      // PL-078 と同作法: 本文はマーカー参照、 実体は enrich が DOM src 経由で充填。
      // PL-134: gen=true = AI 生成画像。renderer が「格子にせず元の形で縦に並べる」
      // 判定に使う (横長の生成画像を正方タイルに収めると小さくなり余白が残る)。
      blocks.push({ gidx, block: { alt: title, src: '', data_uri: '', title, img_ref: fileId, gen: true } });
    }
    if (blocks.length === 0) return null;
    return {
      groupId: meta.image_gen_group_id ? String(meta.image_gen_group_id) : null,
      blocks,
    };
  }

  // 捨てた node のうち中身 (画像 / 本文) を持っていた物を数える。 従来 drop は完全に無言で、
  // PL-133 は user が偶然マーケ素材を作らなければ発見されなかった。 件数が普段より多い =
  // ChatGPT 側の構造が変わった合図として使う。
  function _pl133CountIfLossy(msg, tally) {
    const content = (msg && msg.content) || {};
    const parts = content.parts || [];
    let images = 0;
    let textLen = 0;
    for (const part of parts) {
      if (typeof part === 'string') textLen += part.length;
      else if (part && typeof part === 'object' && part.asset_pointer) images += 1;
    }
    if (images === 0 && textLen === 0) return;
    const role = String(((msg && msg.author) || {}).role || '?');
    const ctype = String(content.content_type || '?');
    const key = `${role}/${ctype}`;
    const hit = tally[key] || (tally[key] = { nodes: 0, images: 0, chars: 0 });
    hit.nodes += 1;
    hit.images += images;
    hit.chars += textLen;
  }

  // PL-038 Phase 3.2: ChatGPT API mapping の inline markers は Private Use Area
  // 文字 (\uE200 開始 / \uE201 終了 / \uE202 内部 separator) で囲まれた構造化
  // markup。 site UI は React app が PUA delimiter を読んで styled span 化、
  // raw text には PUA 文字 + 構造データが残る。 entity (人物 / 組織 / ソフト
  // 等の認識) は表示名 (3 要素のうち 2 番目の quoted string) を抽出、 cite
  // (footnote 引用) 等の他 marker は subtree 全削除。 単一 PUA 文字 stray も
  // 残ると `□` glyph で視覚 noise のため最終 strip pass で除去。
  const CHATGPT_PUA_ENTITY_RE = /\uE200entity\uE202\["[^"\]]*", "([^"\]]*)", \d+\]\uE201/g;
  const CHATGPT_PUA_BLOCK_RE = /\uE200[\s\S]*?\uE201/g;
  const CHATGPT_PUA_STRAY_RE = /[\uE200-\uE207]/g;
  // ChatGPT API msg.content.parts の code block delimiter は ` ```lang id="xxx" `
  // 形式で id 属性 (= edit feature 用 anchor) が混入する。 ASA 既存
  // markdown→HTML converter は ` ```lang ` を `<pre><code class="language-lang">`
  // 灰色枠 化するが id 属性付きは認識不可で raw 残置するため、 id 属性のみ
  // 除去して言語 tag は維持、 converter に渡して灰色枠 render 復帰させる。
  const CHATGPT_CODE_BLOCK_ID_RE = /(```\w+) id="[^"]+"/g;

  // PL-114: ChatGPT のインタラクティブ・グラフ (棒グラフ等) は AI 回答本文に PUA 文字で
  // 囲んだ genui directive `genui{"charts_widget_v2":{"content":{...}}}`
  // として埋まる。 alt は null で content_references 置換が効かず、 残った PUA ブロックを
  // cleanChatGPTApiMarkers の CHATGPT_PUA_BLOCK_RE が丸ごと除去 → グラフ消失。 ここで
  // PUA 除去より前に genui の chart spec を取り出し、 構造マーカー [[ASA_CHART:<base64 JSON>]]
  // に変換して本文の元位置に残す (PL-111 商品カードと同型、 レンダラが棒グラフ/表に展開)。
  // chart 以外の genui ウィジェットは従来どおり PUA 除去に委ねる (回帰なし)。
  const CHATGPT_GENUI_RE = /genui([\s\S]*?)/g;

  function _pl114NormalizeChart(content) {
    // genui chart spec を renderer 非依存の正規形に: {type,title,description,cats,series}。
    if (!content || typeof content !== 'object') return null;
    const xKey = content.xKey || 'x';
    const rows = Array.isArray(content.data) ? content.data : [];
    if (!rows.length) return null;
    const cats = rows.map((r) => String((r && r[xKey] != null) ? r[xKey] : ''));
    const seriesDef = Array.isArray(content.series) ? content.series : [];
    const series = (seriesDef.length ? seriesDef : [{ dataKey: 'value', label: '' }]).map((s) => {
      const dk = (s && s.dataKey) || 'value';
      return {
        label: String((s && s.label) || dk || ''),
        values: rows.map((r) => {
          const v = r && r[dk];
          const n = typeof v === 'number' ? v : parseFloat(v);
          return isFinite(n) ? n : 0;
        }),
      };
    });
    const meta = content.meta || {};
    return {
      type: String(content.chartType || 'bar'),
      title: String(meta.title || ''),
      description: String(meta.description || ''),
      cats,
      series,
    };
  }

  function _pl114ChartMarkers(text) {
    if (!text || text.indexOf('genui') === -1) return text;
    return String(text).replace(CHATGPT_GENUI_RE, (whole, jsonStr) => {
      try {
        const obj = JSON.parse(jsonStr);
        const widget = obj && obj.charts_widget_v2;
        const content = widget && widget.content;
        const norm = _pl114NormalizeChart(content);
        if (!norm) return whole;   // chart でない genui は素通し (後段 PUA 除去に委ねる)
        const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(norm))));
        return '\n\n[[ASA_CHART:' + b64 + ']]\n\n';
      } catch (_e) {
        return whole;
      }
    });
  }

  function cleanChatGPTApiMarkers(text) {
    if (!text) return text;
    return String(text)
      // PL-114: API 経路の user/assistant text が CRLF を保持し、 PDF の CID フォントで
      // CR (\r) が .notdef の箱グリフ (〼) になる。 DOM 経路 (deepText) は既に \r を除去
      // しており API 経路だけ非対称だった。 ここで CR を除去して両経路を揃える。
      .replace(/\r/g, '')
      .replace(CHATGPT_PUA_ENTITY_RE, '$1')
      .replace(CHATGPT_PUA_BLOCK_RE, '')
      .replace(CHATGPT_CODE_BLOCK_ID_RE, '$1')
      .replace(CHATGPT_PUA_STRAY_RE, '');
  }

  // PL-111: shopping の product / products カルーセルは content_reference の `alt` が
  // 名前+価格のみで画像 URL を含まず、 画像が保存に出ない (ドロップ)。 ref.product /
  // ref.products[] から構造化データ (画像/名前/価格/評価/レビュー数/店舗) を抽出し、
  // 構造マーカー [[ASA_PRODUCTS:<base64 JSON>]] で本文に埋める。 レンダラ側が本家同様の
  // 横並びカード (HTML/PDF) / テキスト列 (txt/md) に展開する。 画像は image_group と同じく
  // live URL 参照 (host 権限追加不要、 閲覧時にブラウザ/PDF 側が取得)。 base64 で markdown
  // 変換に巻き込まれず通過させる (前後空行で 1 段落に分離)。
  // PL-111 follow-up: 商品カードのコメント文は商品オブジェクトの外、 message.metadata.shopping
  // .product_carousel_descriptions = { "<start_idx>:<end_idx>": { "<商品id>": "コメント" } } に
  // 格納される (probe で確定。 product.description は常に null のダミー枠)。 重要: キーは directive
  // の文字位置 "start:end" で、 カルーセル (products) ref の range のみ存在する。 同じ商品 id が
  // 単体 hero (render_as=hero/block) とカルーセルの両方に出ても、 hero の range は descriptions に
  // 無い → サイトは hero にコメントを出さない。 ゆえ id でなく ref 自身の range で引く (id 平坦
  // マージだと hero がカルーセルのコメントを誤取得する)。
  function _pl111RangeDescMap(meta, ref) {
    try {
      const groups = meta && meta.shopping && meta.shopping.product_carousel_descriptions;
      if (!groups || typeof groups !== 'object') return null;
      const range = String(ref.start_idx) + ':' + String(ref.end_idx);
      const byId = groups[range];
      return (byId && typeof byId === 'object') ? byId : null;
    } catch (_e) {
      return null;
    }
  }

  // kind = hero / block / carousel。 サイトは render_as ごとに出す項目を変える (probe v5 実測):
  // hero=店舗名+レビュー数あり / block=価格+★のみ / carousel=コメントあり・店舗名なし。
  // レンダラがこの kind で項目を出し分けるため marker に載せる。
  // 商品検索リンクの検索語。 表示 title はカルーセルだと AI の曖昧ラベル ("白×グリーン定番
  // カラー" 等→塗料が誤ヒット) のため、 product_lookup_key.data 内の provider_title (店舗の
  // 実商品名・商品ごとに正確) を優先。 generated_product_query はターン共有でゴミ化する回が
  // あるため次点。 どちらも無ければ title (probe v7 で全 item に provider_title 存在を確認)。
  function _pl111SearchQuery(p) {
    try {
      const raw = p && p.product_lookup_key && p.product_lookup_key.data;
      if (raw) {
        const d = JSON.parse(raw);
        if (d.provider_title) return String(d.provider_title).trim();
        if (d.generated_product_query) return String(d.generated_product_query).trim();
      }
    } catch (_e) { /* no-op */ }
    return '';
  }

  function _pl111ProductItem(p, byId, kind) {
    if (!p || typeof p !== 'object') return null;
    const title = String(p.title || '').trim();
    const img = (Array.isArray(p.image_urls) ? p.image_urls : []).filter(Boolean)[0] || '';
    if (!title && !img) return null;
    const pid = String(p.id || '');
    const raw = (byId && pid) ? byId[pid] : '';
    const comment = (typeof raw === 'string') ? raw.trim() : '';
    return {
      t: title,
      p: String(p.price || '').trim(),
      img,
      r: (p.rating != null && p.rating !== '') ? String(p.rating) : '',
      n: (p.num_reviews != null && p.num_reviews !== '') ? String(p.num_reviews) : '',
      m: String(p.merchants || '').trim(),
      c: comment,
      k: kind,
      q: _pl111SearchQuery(p),
    };
  }

  function _pl111ProductsMarker(rawProducts, byId, kind) {
    const items = rawProducts.map((p) => _pl111ProductItem(p, byId, kind)).filter(Boolean);
    if (!items.length) return null;
    try {
      const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(items))));
      return '\n\n[[ASA_PRODUCTS:' + b64 + ']]\n\n';
    } catch (_e) {
      return null;
    }
  }

  function _pl111ProductAlt(ref, meta) {
    if (!ref || typeof ref !== 'object') return null;
    // この ref の range に一致するコメント辞書のみ取得 (hero/block は通常一致せず null)。
    const byId = _pl111RangeDescMap(meta, ref);
    if (ref.type === 'product' && ref.product) {
      const kind = (ref.render_as === 'hero' || ref.render_as === 'block') ? ref.render_as : 'block';
      return _pl111ProductsMarker([ref.product], byId, kind);
    }
    if (ref.type === 'products' && Array.isArray(ref.products)) {
      return _pl111ProductsMarker(ref.products, byId, 'carousel');
    }
    return null;
  }

  function parseConversationApiResponse(jsonText) {
    let data;
    try {
      data = JSON.parse(String(jsonText || ''));
    } catch (_e) {
      return null;
    }
    const mapping = data && data.mapping;
    if (!mapping || typeof mapping !== 'object') return null;
    const currentNode = data.current_node;
    if (!currentNode || !mapping[currentNode]) return null;
    // Walk parent chain from current_node back to root
    const chain = [];
    const visited = new Set();
    let cur = currentNode;
    while (cur && mapping[cur] && !visited.has(cur)) {
      chain.push(cur);
      visited.add(cur);
      cur = mapping[cur].parent;
    }
    chain.reverse();
    const messages = [];
    // PL-133: 旧形で候補収集が消費済の tool node id (単独 tool 拾い直しとの二重取り込み防止)
    const consumedImageNodes = new Set();
    const droppedTally = {};
    // PL-133: 連続する同 group の候補画像を 1 発言に束ねる緩衝。 旧形は起点 assistant が
    // 束ね役だったが、 新形には起点が無いので group id で束ねる。 起点 node の metadata に
    // ある selected_image_message_id も無いため、 採用/候補のキャプションは付かない
    // (PL-099 の「単数生成は無印」と同じ見え方に落ちる)。
    let pendingImages = null;
    const flushPendingImages = () => {
      if (!pendingImages) return;
      const ordered = pendingImages.blocks.slice().sort((a, b) => a.gidx - b.gidx);
      const markers = ordered.map((_, i) => `[[ASA_IMAGE_${i}]]`).join('');
      messages.push({
        role: 'assistant',
        text: markers,
        code_blocks: [],
        image_blocks: ordered.map((o) => o.block),
        msg_id: pendingImages.msgId,
      });
      pendingImages = null;
    };
    for (const nodeId of chain) {
      const node = mapping[nodeId];
      const msg = node && node.message;
      if (!msg) continue;
      const author = msg.author || {};
      const role = String(author.role || '').toLowerCase();
      // PL-133: 画像を単独で持つ tool node は user UI 上 assistant 発言枠 1 つを占める
      // (思考経由の画像生成)。 旧形で候補収集が既に拾った node は skip。
      if (role === 'tool') {
        const found = consumedImageNodes.has(nodeId) ? null : _pl133StandaloneImageBlocks(msg);
        if (found) {
          if (pendingImages && found.groupId && pendingImages.groupId === found.groupId) {
            pendingImages.blocks.push(...found.blocks);
          } else {
            flushPendingImages();
            pendingImages = { groupId: found.groupId, blocks: found.blocks, msgId: msg.id || nodeId };
          }
        } else if (!consumedImageNodes.has(nodeId) && !(msg.metadata || {}).is_visually_hidden_from_conversation) {
          // hidden は site が意図して隠している分なので数えない (常時発生 = 合図にならない)。
          _pl133CountIfLossy(msg, droppedTally);
        }
        continue;
      }
      flushPendingImages();
      // PL-038 Phase 3.1: user / assistant のみ保持、 system / tool は user-facing
      // でない (system instruction / tool 結果の生 dump) ため drop
      if (role !== 'user' && role !== 'assistant') {
        _pl133CountIfLossy(msg, droppedTally);
        continue;
      }
      // PL-038 Phase 3.4: msg.recipient === 'all' のみ user-facing。 image gen
      // prompt (`recipient='t2uay3k.sj1i4kz'` 等の hashed tool target) や search
      // (`recipient='web'`) 等の tool call assistant msg は drop。 通常 user msg /
      // assistant 最終 reply は recipient='all' で統一されている (probe で全件確認済)。
      const recipient = msg.recipient;
      if (recipient && recipient !== 'all') {
        // PL-038 Phase 3.5: 画像生成 tool call (recipient='dalle.text2im' /
        // 't2uay3k.sj1i4kz' 等) の child に tool ctype=multimodal_text + asset_pointer
        // (sediment://file_...) を持つ msg がある場合、 user UI 上は画像が inline
        // 表示される = 発言枠 1 つ消費する。 drop すると assistant 発言枠が完全に
        // 消えて文脈が不自然 (user 発話の直後に次 user 発話が並ぶ等) になるため、
        // `[画像]` placeholder で枠保持。 search tool 等の child に image asset
        // 不在なら発言枠不在で drop が妥当。
        if (role === 'assistant') {
          // PL-099: 候補チェーンを全収集 (旧 first-break は採用 1 枚しか拾わず
          // 複数枚生成の非選択候補を落としていた)。 title (= AI が付けたキャプション,
          // metadata.image_gen_title) は候補ごとに保持 (PL-050 contract)。
          const variants = _pl099CollectImageGenVariants(mapping, node);
          variants.forEach((v) => consumedImageNodes.add(v.msgId));
          if (variants.length > 0) {
            // PL-099: selected_image_message_id (assistant ノード metadata) が採用候補の
            // tool node id を指す。 候補 >1 のときだけ「採用/候補」を可視キャプション
            // ([[ASA_IMGCAP:..]] = renderer が画像直下に小さめグレー段落で描画、 PL-088 と
            // 同機構) で区別表示。 alt は画像破損時しか出ず不可視なので印には使わない。
            // 単数生成は従来どおり無印 (回帰なし)。
            const selectedId = String((msg.metadata || {}).selected_image_message_id || '');
            const multi = variants.length > 1;
            const parts = [];
            const imageBlocks = [];
            variants.forEach((v, i) => {
              parts.push(`[[ASA_IMAGE_${i}]]`);
              if (multi) {
                const tag = (selectedId && v.msgId === selectedId) ? '採用' : '候補';
                // caption は `]` を含めない (renderer の [^\]] 終端を壊さない)。 v.title は
                // AI キャプション、 無ければ tag 単体。
                const cap = v.title ? `${tag}: ${v.title}` : tag;
                parts.push(`[[ASA_IMGCAP:${cap.replace(/]/g, '')}]]`);
              }
              // PL-078: 本文はマーカー参照、 image_blocks で別持ち (code_blocks と同作法)。
              // data_uri は後段 enrichImageBlocks が DOM src 経由 fetch で充填、 取得失敗時
              // は asa_core renderer が [画像: title] 印に落とす。 src は短命 URL のため空固定。
              // img_ref = 別送画像との相関キー (= file id)。
              imageBlocks.push({
                alt: v.title, src: '', data_uri: '', title: v.title, img_ref: v.fileId,
                gen: true,   // PL-134: 生成画像は格子化せず元の形で縦並び
              });
            });
            messages.push({
              role: 'assistant',
              text: parts.join(''),
              code_blocks: [],
              image_blocks: imageBlocks,
              msg_id: msg.id || nodeId,
            });
          } else {
            // PL-133: 起点はあるのに候補が 1 枚も取れない = 子側の構造が変わった合図。
            _pl133CountIfLossy(msg, droppedTally);
          }
        }
        continue;
      }
      const meta = msg.metadata || {};
      // hidden は site 側が意図して隠している分なので数えない (常時発生 = 合図にならない)。
      if (meta.is_visually_hidden_from_conversation) continue;
      const content = msg.content || {};
      const ctype = String(content.content_type || 'text');
      // PL-038 Phase 3.1: visible content type のみ保持。 user_editable_context /
      // model_editable_context / code (tool call query) / tether_browsing_display
      // 等は UI 上 hidden or styled rendering、 raw text 取り込みは noise。
      if (ctype !== 'text' && ctype !== 'multimodal_text') {
        _pl133CountIfLossy(msg, droppedTally);
        continue;
      }
      const parts = content.parts || [];
      const textParts = [];
      const userImagePtrs = [];
      for (const part of parts) {
        if (typeof part === 'string') {
          if (part) textParts.push(part);
        } else if (role === 'user' && part && typeof part === 'object' && part.asset_pointer) {
          // PL-120: user 添付画像 (multimodal_text の asset_pointer part)。 生成候補
          // (_pl099CollectImageGenVariants) とは別経路、 候補/採用キャプション無しの
          // 素の添付として image_blocks 化する (assistant 側は候補チェーン専用のまま維持)。
          userImagePtrs.push(String(part.asset_pointer));
        }
        // 非 string の assistant 側 asset は候補チェーン (recipient!=='all' 分岐) で処理済
      }
      let text = textParts.join('\n\n');
      let userImageBlocks = null;
      if (userImagePtrs.length > 0) {
        userImageBlocks = [];
        let imgMarkers = '';
        const seenUserFile = new Set();
        for (const ptr of userImagePtrs) {
          const fileId = _pl078FileId(ptr);
          if (!fileId || seenUserFile.has(fileId)) continue;
          seenUserFile.add(fileId);
          imgMarkers += `[[ASA_IMAGE_${userImageBlocks.length}]]`;
          userImageBlocks.push({ alt: '', src: '', data_uri: '', title: '', img_ref: fileId });
        }
        if (userImageBlocks.length > 0) {
          text = text ? (imgMarkers + ' ' + text) : imgMarkers;
        } else {
          userImageBlocks = null;
        }
      }
      // PL-067: ChatGPT が web 検索画像引用を `image_group{...JSON spec...}`
      // directive で返す turn では parts[0] = directive 生 string + 真の
      // markdown は metadata.content_references[].alt field に格納される。
      // site renderer は content_references[].matched_text を .alt で置換
      // render するため user 視認は markdown 10 件、 ASA も alt 採用に書換
      // しないと directive 生 string そのまま保存または別 layer drop で
      // assistant 発言完全消失する。 generic 設計 (= type 列挙不要、 alt
      // 持つ ref を全部置換) で将来の content_references type 追加にも自動
      // 追従、 ChatGPT 内部 directive 変動への abstraction layer 整備。
      if (role === 'assistant' && text) {
        const refs = Array.isArray(meta.content_references) ? meta.content_references : [];
        if (refs.length > 0) {
          let replaced = text;
          for (const ref of refs) {
            if (!ref || typeof ref !== 'object') continue;
            const matched = ref.matched_text;
            // PL-111: product / products は画像 URL を含む alt を組み直す (なければ既定 alt)。
            // コメントは ref 自身の range で引く (hero/block は range 不一致でコメント無し)。
            const alt = _pl111ProductAlt(ref, meta) || ref.alt;
            if (typeof matched === 'string' && matched && typeof alt === 'string' && alt) {
              replaced = replaced.split(matched).join(alt);
            }
          }
          text = replaced;
        }
      }
      text = _pl114ChartMarkers(text);   // PL-114: genui グラフを PUA 除去前にマーカー化
      text = cleanChatGPTApiMarkers(text);
      text = String(text || '').trim();
      // PL-038 Phase 3.3: file-only attachment user msg は parts=[''] (text 空)
      // + metadata.attachments に file 名格納される設計、 text 単独 filter で
      // drop されると user turn 全消失する。 attachments が non-empty なら
      // `[添付ファイル: <name>]` placeholder で再構築 (Gemini PL-017 / Claude
      // PL-012 と同 contract)。
      // PL-134: 本文がある発言の添付が消えていた (旧: text 空のときだけ印を作る)。
      // site は本文と別にファイルのカードを出すので、本文の有無にかかわらず残す。
      // 画像として既に出ている添付 (image_blocks) は二重に出さない。
      {
        const attachments = Array.isArray(meta.attachments) ? meta.attachments : [];
        const shownRefs = new Set((userImageBlocks || []).map((b) => String(b.img_ref || '')));
        const placeholders = [];
        for (const a of attachments) {
          const name = (a && (a.name || a.id)) || '';
          if (!name) continue;
          const fid = a && a.id ? _pl078FileId(String(a.id)) : '';
          if (fid && shownRefs.has(fid)) continue;
          placeholders.push('[添付ファイル: ' + String(name) + ']');
        }
        if (placeholders.length > 0) {
          const joined = placeholders.join(String.fromCharCode(10, 10));
          const sep = String.fromCharCode(10, 10);
          text = text ? (text + sep + joined) : joined;
        }
      }
      if (!text) {
        // PL-133: 中身を持ちながら text 化できなかった node (未知の part 形式等)。
        _pl133CountIfLossy(msg, droppedTally);
        continue;
      }
      const msgId = msg.id || nodeId;
      const outMsg = { role, text, code_blocks: [], msg_id: msgId };
      if (userImageBlocks && userImageBlocks.length > 0) outMsg.image_blocks = userImageBlocks;
      messages.push(outMsg);
    }
    flushPendingImages();
    // PL-133: 中身のある node を捨てた時だけ 1 行出す。 普段は無出力なので、 出た事自体が
    // 「ChatGPT 側の構造が変わったかもしれない」の合図になる。
    const droppedKeys = Object.keys(droppedTally);
    if (droppedKeys.length > 0) {
      try {
        console.log('[PL-133-drop]', JSON.stringify({
          t: Date.now(), kept: messages.length, chain: chain.length, dropped: droppedTally,
        }));
      } catch (_e) {}
    }
    return messages;
  }
  root.ASA_CHATGPT_PARSE = {
    parseConversationApiResponse: parseConversationApiResponse,
    cleanChatGPTApiMarkers: cleanChatGPTApiMarkers,
  };
})(typeof globalThis !== 'undefined' ? globalThis : this);
