/**
 * OpenAI Realtime API Connector
 * WebRTC接続を使用したOpenAI Realtime APIとの通信モジュール
 * 音声入力・テキスト入力に対してテキスト回答を返す
 */

import { SERVER_EVENT_TYPES } from "./event-types.js";
import { ephemeralTokenManager } from "./ephemeral-token-manager.js";
import { createLogger } from "./debug-logger.js";
import { sessionLogger } from "./session-logger.js";
import { captureException } from "./exception-reporter.js";
import { classifyResponseError } from "./response-error-classifier.js";
// INV-12: UI に出るエラー文言は §4.4 の辞書経由 (ns-rest.js は他モジュールを
// import しないため循環参照にならない)。
import { t } from "../ns/ns-rest.js";
import { uuidv4 } from "../utils/uuid.js";
// Issue #455: 接続所要時間を localStorage 履歴に蓄積し、Safari/Firefox の
// navigator.connection 未対応環境でも slow 判定できるようにする
import { recordConnectionDuration } from "../utils/network-preference.js";

export { SERVER_EVENT_TYPES };
export { ephemeralTokenManager };
export { sessionLogger };

// デバッグロガー
const logger = createLogger('OpenAIRealtimeConnector');

const REALTIME_API_URL = "https://api.openai.com/v1/realtime/calls";

// Issue #453: platform 判定はユーティリティに集約 (UA 直叩きを排除して
// iPad desktop mode / CriOS / EdgiOS 等の取りこぼしを防ぐ)
import {
  isIOS as detectIOS,
  isIOSSafari as detectIOSSafari,
  isAndroid as detectAndroid,
  snapshot as browserSnapshot,
} from '../utils/browser-features.js';
// 旧コード互換のためモジュール定数として保持。値は load 時 1 回評価で十分。
// (本ファイルが export している同名関数 `isAndroid` との衝突を避けるため
// import 側を detectXxx に rename している)
// 注: IS_IOS は IS_IOS_SAFARI === IS_IOS && isSafari() の左辺なので恒等的に
// IS_IOS_SAFARI と一致する (iOS は WebKit のため isSafari()=true)。
// ロジック分岐には IS_IOS_SAFARI / IS_ANDROID のみ用いる。
const IS_ANDROID = detectAndroid();
const IS_IOS_SAFARI = detectIOSSafari();

// デバッグ: iOS検出結果をログ出力（サーバーにも送信）
logger.log(`Platform detection: IS_IOS=${detectIOS()}, IS_IOS_SAFARI=${IS_IOS_SAFARI}, IS_ANDROID=${IS_ANDROID}`);
logger.log(`UserAgent: ${(typeof navigator !== 'undefined' && navigator.userAgent) || ''}`);
logger.log(`Browser feature snapshot:`, browserSnapshot());

// STUN/TURN構成 (Issue #413)
// 既定は Google STUN のみで接続時間を短縮するが、企業ネットワーク・VPN・厳格 NAT
// では relay 候補が無いと接続できないケースがあるため、tenant 設定 / 環境変数から
// iceServers を上書きできる経路を用意する。
//
// 上書き経路 (優先順):
//   1. window.RTC_CONFIG_OVERRIDE (テスト専用 — localhost/開発ホストでのみ有効)
//   2. 既定 (Google STUN)
// (旧1位のNECCOSPEAK_RTC_ICE_SERVERSはdev.40で削除 — resolveRtcConfig()参照)
//
// iceServers の各要素は { urls: string|string[], username?: string, credential?: string }。
// tenant ごとの TURN credential は短期発行されたものを使い、永続シークレットは
// クライアントに渡さない (ephemeral token と同じ運用)。
//
// セキュリティ補足: window.RTC_CONFIG_OVERRIDE は XSS 経由で攻撃者に
// 任意の TURN サーバを注入されると WebRTC メディアを中継できるため、
// 本番ホストでは無効化する。tenant 設定 (#1) は EJS による server-rendered
// 注入で改ざんが困難なため本番でも有効にする。
const DEFAULT_RTC_CONFIG = {
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" }
  ]
};

function isTestHost() {
  try {
    if (typeof window === "undefined" || !window.location) return false;
    const h = window.location.hostname || "";
    return (
      h === "localhost" ||
      h === "127.0.0.1" ||
      h === "::1" ||
      h.endsWith(".local") ||
      h.endsWith(".test")
    );
  } catch (_e) {
    return false;
  }
}

// 許可する ICE server scheme (XSS / SSTI 経由の任意 URL 注入を防ぐホワイトリスト)
const ICE_URL_SCHEME_RE = /^(stun|stuns|turn|turns):/i;

function isValidIceServerEntry(entry) {
  if (!entry || typeof entry !== "object") return false;
  const urls = entry.urls;
  const list = Array.isArray(urls) ? urls : [urls];
  for (const u of list) {
    if (typeof u !== "string" || !ICE_URL_SCHEME_RE.test(u)) return false;
  }
  return true;
}

function sanitizeIceServers(servers) {
  if (!Array.isArray(servers)) return null;
  const filtered = servers.filter(isValidIceServerEntry);
  return filtered.length > 0 ? filtered : null;
}

function resolveRtcConfig() {
  if (typeof window === "undefined") return DEFAULT_RTC_CONFIG;
  // 0.3.0-dev.40: window.NECCOSPEAK_RTC_ICE_SERVERS のテナント上書き分岐を
  // 削除（本番 pocxr の EJS 注入前提の残骸で、NS には設定する側のコードが
  // 存在せず一度も発火しない死に分岐だった）。TURN が必要になったら
  // オプション+PHP注入として正式実装する。
  // RTC_CONFIG_OVERRIDE は本番ホスト (localhost/.local/.test 以外) では無視する
  if (isTestHost()) {
    const testOverride = window.RTC_CONFIG_OVERRIDE;
    if (testOverride && Array.isArray(testOverride.iceServers)) {
      const sanitized = sanitizeIceServers(testOverride.iceServers);
      if (sanitized) return { ...testOverride, iceServers: sanitized };
    }
  }
  return DEFAULT_RTC_CONFIG;
}

// モジュールロード時に snapshot を取り、後段の XSS で window.NECCOSPEAK_RTC_ICE_SERVERS が
// 上書きされても影響を受けないようにする (Object.freeze で属性追加・変更も防ぐ)。
// EJS server-rendered の値が信頼できる前提で snapshot し、ページロード後に第三者が
// window 経由で iceServers を差し替えるルートを遮断する。
const _rtcSnapshot = resolveRtcConfig();
const RTC_CONFIG = Object.freeze({
  ...(_rtcSnapshot.iceTransportPolicy ? { iceTransportPolicy: _rtcSnapshot.iceTransportPolicy } : {}),
  iceServers: Object.freeze((_rtcSnapshot.iceServers || []).map((s) => Object.freeze({ ...s }))),
});

/**
 * ICE candidate種別を解析
 * @param {string} candidateStr - ICE candidateの文字列
 * @returns {string} - 種別（host, srflx, prflx, relay, unknown）
 */
function parseIceCandidateType(candidateStr) {
  if (!candidateStr) return 'unknown';
  if (candidateStr.includes('typ host')) return 'host';
  if (candidateStr.includes('typ srflx')) return 'srflx';
  if (candidateStr.includes('typ prflx')) return 'prflx';
  if (candidateStr.includes('typ relay')) return 'relay';
  return 'unknown';
}

/**
 * Android端末かどうかを返す
 * @returns {boolean}
 */
export function isAndroid() {
  return IS_ANDROID;
}

/**
 * iOS Safari かどうかを返す（スタンドアロン関数版）
 * @returns {boolean}
 */
export function isIOSSafari() {
  return IS_IOS_SAFARI;
}

/**
 * 最適化: ネットワークタイプに応じた適応的ICE gatheringタイムアウトを計算
 * @returns {number} タイムアウト時間（ミリ秒）
 */
function getAdaptiveIceGatheringTimeout() {
  try {
    // Network Information API を使用（Chrome/Edge/Opera）
    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
    if (connection && connection.effectiveType) {
      const effectiveType = connection.effectiveType;
      logger.log(`[Network] effectiveType: ${effectiveType}`);

      // ネットワークタイプに応じたタイムアウト
      switch (effectiveType) {
        case '4g':
          return 2000; // 4G: 高速ネットワーク
        case '3g':
          return 3000; // 3G: 標準
        case '2g':
        case 'slow-2g':
          return 5000; // 2G: 低速ネットワーク
        default:
          return 3000; // デフォルト
      }
    }
  } catch (e) {
    logger.warn(`[Network] Failed to detect network type: ${e.message}`);
  }

  // Network Information API が利用できない場合はデフォルト値
  return 3000;
}

// 接続時間計測用ヘルパー
let connectionTimingStart = 0;
let timingLogCallback = null;

/** 接続時間異常検知の閾値（ミリ秒）— ネットワーク種別に応じて動的に決定 */
function getConnectionTimeoutThresholdMs() {
  const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
  if (conn && conn.effectiveType) {
    const slow = ['slow-2g', '2g', '3g'];
    if (slow.includes(conn.effectiveType)) {
      return 30000;
    }
    return 15000;
  }
  // navigator.connection が利用不可の場合は寛容な閾値を使用
  return 20000;
}

/**
 * タイミングログのコールバックを設定
 * @param {Function} callback - ログを受け取るコールバック関数
 */
export function setTimingLogCallback(callback) {
  timingLogCallback = callback;
}

/**
 * connectionTimingStart からの経過時間を取得（ms）
 * mark() と同じ基準で外部からも elapsed_ms を計算できるようにする
 */
export function getElapsedSinceConnectionStart() {
  return connectionTimingStart > 0 ? Math.round(performance.now() - connectionTimingStart) : null;
}

// タイミングラベルからイベントタイプへのマッピング
const TIMING_TO_EVENT_TYPE = {
  'CLICK': 'click',
  'PC_READY': 'pc_ready',
  'GUM_START': 'gum_start',
  'GUM_DONE': 'gum_done',
  'OFFER_START': 'offer_start',
  'OFFER_DONE': 'offer_done',
  'SEND_OFFER': 'send_offer',
  'ONTRACK': 'ontrack',
  'MIC_ATTACHED': 'mic_attached'
};

const mark = (label) => {
  const elapsedMs = connectionTimingStart > 0 ? performance.now() - connectionTimingStart : 0;
  const elapsed = elapsedMs.toFixed(1);
  const message = `[TIMING] ${label.padEnd(12)} ${elapsed}ms`;
  logger.log(message);
  // コールバックが設定されていれば呼び出す
  if (timingLogCallback) {
    timingLogCallback(message);
  }
  // セッションログにイベントを記録（非同期、エラーは無視）
  // isCreated() チェックを外し、常に logEvent() に委譲する。
  // セッション未作成時は logEvent() 内部でバッファリングされ、
  // セッション作成後に flushPendingEvents() でバッチ送信される。
  const eventType = TIMING_TO_EVENT_TYPE[label];
  if (eventType) {
    sessionLogger.logEvent(eventType, Math.round(elapsedMs)).catch(() => {});
  }
};

/**
 * 接続時間計測を開始
 * ウィジェット展開時（ボタンクリック時）に呼び出す
 * @param {string} userId - ユーザーID（optional）
 * @returns {Promise<string|null>} セッションID（UUID形式）、または失敗時はnull
 */
export async function startConnectionTiming(userId = null) {
  console.log(`[startConnectionTiming] Called with userId: ${userId}`);
  connectionTimingStart = performance.now();

  // セッションをPostgresに登録し、session_id（UUID）を取得
  let sessionId = null;
  try {
    sessionId = await sessionLogger.startSession(userId);
  } catch (error) {
    console.error(`[startConnectionTiming] Failed to start session:`, error);
  }

  mark('CLICK');
  return sessionId;
}

export class OpenAIRealtimeConnector {
  constructor() {
    this.peerConnection = null;
    this.dataChannel = null;
    this.mediaStream = null;
    this.audioElement = null;

    // iOS Safari用: WebRTC音声をスピーカーから再生するためのAudioContext
    this.remoteAudioContext = null;
    this.remoteAudioSource = null;
    this.remoteGainNode = null;
    this.remoteAudioUnlocked = false;
    this.silentMediaElement = null; // iOS Safari用: メディアモード確立用サイレント要素

    this.callbacks = {
      onConnectionStateChange: null,
      onTextResponse: null,
      onTranscript: null,
      onTranscriptDelta: null,
      onUserAudioCommitted: null,
      onOutputAudioStarted: null,
      onOutputAudioStopped: null,
      onFunctionCall: null,
      onError: null,
      onLog: null,
      onSessionCreated: null,
      onSpeechStarted: null,
      onSpeechStopped: null,
      onMicrophoneError: null, // マイク取得失敗時のコールバック（NotAllowedError等）
      onMicrophoneLost: null,  // 使用中マイクの物理切断検知コールバック（_watchMicTrackEnded 経由）
      onTextDelta: null,       // テキストデルタ受信時（カスタムTTSパイプライン用）
      onResponseCreated: null, // レスポンス作成時
      onResponseFailed: null,  // レスポンス失敗時
      onResponseDone: null,    // レスポンス完了時（usage データ付き）
      // ユーザー割込等で会話アイテムが途中切り詰めされた時 (CONVERSATION_ITEM_TRUNCATED)。
      // signature: ({ item_id, audio_end_ms, ... }) => void
      // widget 側でストリーミング中の bubble を確定 + Map から外すために使う。
      onConversationItemTruncated: null,
      // SDP書き換え失敗の通知 (Issue #441)
      // signature: ({ phase: 'optimize' | 'set_local', error: Error }) => void
      //   - 'optimize': optimizeSdpForOpus 実行中の例外。元 SDP で接続を続行する。
      //   - 'set_local': setLocalDescription(optimizedOffer) 失敗。元 offer で再試行する。
      onSdpRewriteError: null,
    };

    this.eventListeners = {};

    this.currentResponseText = "";
    this.currentResponseItemId = null;
    this.currentResponseMessageId = null;
    this.currentFunctionCall = null;
    this.currentTranscription = {};
    this.isConnected = false;
    this._intentionalDisconnect = false;
    this.isResponseInProgress = false;
    this.currentResponseId = null;
    this.currentResponseMetadata = null;
    this._pendingTextSend = false;   // sendTextMessage中のitem_id捕捉用
    this._remoteMuted = false;       // setRemoteAudioMuted() の望ましい状態(ontrack 再適用用)
    // dev.61: タブ非表示中の「背景ミュート」オーバーレイ。ユーザーのミュート意図
    // (app 側 isMicMuted/isSpeakerMuted) とは独立した第2軸で、実効ミュートは
    // 「ユーザー意図 OR 背景ミュート」。
    // 重要: _remoteMuted と違い disconnect()/connect() ではリセットしない。
    // これはセッションの状態ではなくタブの状態であり、hidden のまま自動再接続が
    // 走った場合に新セッションが素通しになるのを防ぐ。解除するのは
    // widget-app-persistent.js の _onTabVisible() だけ。
    this._backgroundMuted = false;
    this._suspendClearEventId = null; // suspendSession() が送る input_audio_buffer.clear の event_id
                                       // (ERROR ハンドラで event_id 相関により良性判定するため保持)
    this._outputClearEventIds = new Set(); // _clearOutputAudioBuffer() が送る output_audio_buffer.clear の
                                           // event_id 群 (同上の event_id 相関で良性判定。バージインの度に
                                           // 増えるため上限超過分は古い順に間引く)
    this._cancelResolve = null;      // cancelCurrentResponse待ちのPromise解決用
    this._sessionUpdatedResolvers = []; // waitForSessionUpdated 用 FIFO queue (Issue #558)
    this._disconnectTimeout = null;  // disconnected状態の回復待ちタイマー
    this._pcEventAbort = null;       // setupPeerConnection() の addEventListener を session 間で剥がすための AbortController
    this._dcEventAbort = null;       // setupDataChannel() の addEventListener("message") を session 間で剥がすための AbortController

    // text↔voice 高速切替対策: suspendSession()/resumeSession() 用の状態。
    // true の間は RTCPeerConnection / DataChannel / リモート音声要素は維持したまま
    // マイクだけ物理停止している（詳細は各メソッドのコメント参照）。
    this._suspended = false;

    // セッション設定の保持（session.update時に消失しないようにする）
    this.sessionConfig = {
      output_modalities: ["audio"],
      tools: [],
      audio: null,
    };

    // DataChannel準備完了を待機するためのPromise
    this._dataChannelReadyResolve = null;
    this._dataChannelReady = null;

    // 最適化: RTCPeerConnectionの事前初期化を削除
    // 理由: ICE gathering は非同期なので、事前作成しても効果がない
    // setupPeerConnection() で必要時に作成する方が効率的
  }

  setCallbacks(callbacks) {
    this.callbacks = { ...this.callbacks, ...callbacks };
  }

  addEventListener(eventType, listener) {
    if (!this.eventListeners[eventType]) {
      this.eventListeners[eventType] = [];
    }
    this.eventListeners[eventType].push(listener);
  }

  removeEventListener(eventType, listener) {
    if (!this.eventListeners[eventType]) return;
    this.eventListeners[eventType] = this.eventListeners[eventType].filter(
      (l) => l !== listener
    );
  }

  emitEvent(eventType, eventData) {
    if (this.eventListeners[eventType]) {
      this.eventListeners[eventType].forEach((listener) => {
        try {
          listener(eventData);
        } catch (error) {
          this.log(`Event listener error for ${eventType}: ${error.message}`);
        }
      });
    }
  }

  log(message) {
    logger.log(message);
    if (this.callbacks.onLog) {
      this.callbacks.onLog(message);
    }
  }

  /**
   * リモートオーディオ（WebRTC受信音声）のミュート状態を設定
   * @param {boolean} muted - ミュートするかどうか
   */
  setRemoteAudioMuted(muted) {
    this._remoteMuted = !!muted;

    // GainNode(iOS 正常経路)があれば gain で制御
    if (this.remoteGainNode) {
      this.remoteGainNode.gain.value = muted ? 0 : (this._remoteAudioVolume ?? 1);
    }

    // audio 要素は常にミュート制御する。iOS は volume セッターを無視するが
    // muted は尊重されるため、これが iOS フォールバック経路での唯一の停止手段。
    // volume には触らない(iOS 正常経路の AEC 参照 volume=0.001 を壊さない)。
    if (this.audioElement) this.audioElement.muted = !!muted;

    this.log(`Remote audio ${muted ? "muted" : "unmuted"} (gain=${!!this.remoteGainNode}, element=${!!this.audioElement})`);
  }

  /**
   * dev.61: タブ非表示中のオーバーレイミュート。
   * @param {boolean} muted
   * @param {{userMicMuted?: boolean, userRemoteMuted?: boolean}} intent
   *   解除時に戻す「ユーザーのミュート意図」。connector に二重保持すると
   *   dev.56 A-4 の resume 意図引き継ぎと二重管理になるため、毎回渡してもらう。
   * @returns {boolean} 適用後の _backgroundMuted
   */
  setBackgroundMuted(muted, { userMicMuted = false, userRemoteMuted = false } = {}) {
    // レビュー F2(i) (dev.61 blocker): 値が変わらないなら何もしない。
    if (this._backgroundMuted === !!muted) return this._backgroundMuted;
    this._backgroundMuted = !!muted;
    // レビュー F2(ii) (dev.61 blocker): suspend 中はリモート音声の適用を
    // スキップする。suspend 中は dev.56 契約で suspendSession() がリモート
    // ミュート(_remoteMuted)を所有しており、resume 時に
    // resumeSession({remoteMuted}) がユーザー意図を再適用する(A-4)。
    // ここで触ると、Stage2 昇格(hidden 継続 → switchToTextMode →
    // suspendSession())後の visible 復帰で _backgroundMuted が true→false に
    // 変化し、「テキスト画面のまま AI の声が突然鳴り出す」事故になる
    // (レビュー実測)。
    if (!this._suspended) {
      // リモート音声: 実効 = userRemoteMuted || _backgroundMuted。
      // _remoteMuted に合成結果が入るので、hidden 中に ontrack が発火しても
      // (901 / 957 / 981 / 991 の再適用が) 正しくミュートで生まれる。
      this.setRemoteAudioMuted(!!userRemoteMuted || this._backgroundMuted);
    }
    // マイク側は suspend 中 mediaStream=null で自然に no-op になるため、
    // suspend 分岐を作らず常に呼ぶ。
    this._applyMicEnabled(!!userMicMuted);
    return this._backgroundMuted;
  }

  /** dev.61: 現用マイクトラックへ実効 enabled を適用する (冪等)。 */
  _applyMicEnabled(userMicMuted = false) {
    const enabled = !(userMicMuted || this._backgroundMuted);
    this.mediaStream?.getAudioTracks?.().forEach((t) => { t.enabled = enabled; });
    return enabled;
  }

  /**
   * リモートオーディオの音量を設定（0.0〜1.0）
   * iOS Safariでは通話音量が直接制御できないため、GainNodeで音量を調整
   * @param {number} volume - 音量（0.0〜1.0）
   */
  setRemoteAudioVolume(volume) {
    const clampedVolume = Math.max(0, Math.min(1, volume));

    // 音量を保存（再接続時に復元するため）。ミュート中でも保存だけは行う。
    this._remoteAudioVolume = clampedVolume;

    // ミュート中は実際の適用を保留する(setRemoteAudioMuted(false) で復元される)。
    if (this._remoteMuted) return;

    // iOS Safari: GainNodeで制御
    if (this.remoteGainNode) {
      this.remoteGainNode.gain.value = clampedVolume;
      this.log(`[iOS] Remote audio volume set to ${clampedVolume}`);
    } else if (this.audioElement) {
      // 通常のaudio要素の音量制御（iOS以外）
      this.audioElement.volume = clampedVolume;
    }
  }

  /**
   * リモートオーディオの現在の音量を取得
   * @returns {number} - 音量（0.0〜1.0）
   */
  getRemoteAudioVolume() {
    if (IS_IOS_SAFARI && this.remoteGainNode) {
      return this.remoteGainNode.gain.value;
    }
    if (this.audioElement) {
      return this.audioElement.volume;
    }
    return this._remoteAudioVolume ?? 1.0;
  }

  /**
   * iOS Safari かどうかを返す（外部から参照用）
   * @returns {boolean}
   */
  isIOSSafari() {
    return IS_IOS_SAFARI;
  }

  /**
   * iOS Safari用: WebRTC音声をスピーカーから再生するためのオーディオシステムを初期化
   * ユーザーインタラクション内で呼び出す必要がある
   *
   * iOS Safariの音声ルーティング問題への対策:
   * 1. WebRTCで双方向音声(sendrecv)を使用すると、iOSは「通話」と判断してイヤースピーカーに出力
   * 2. これを回避するため、以下の多重対策を実施:
   *    a) メディア要素（audio/video）を先に再生して「メディアモード」を確立
   *    b) AudioContextを「playback」モードで作成
   *    c) WebRTC音声をAudioContext経由でルーティング
   */
  async initRemoteAudioForIOS() {
    this.log(`[iOS] initRemoteAudioForIOS called: IS_IOS_SAFARI=${IS_IOS_SAFARI}, alreadyUnlocked=${this.remoteAudioUnlocked}`);

    if (!IS_IOS_SAFARI) {
      this.log("[iOS] Skipped: Not iOS Safari");
      return;
    }
    if (this.remoteAudioUnlocked) {
      this.log("[iOS] Skipped: Already unlocked");
      return;
    }

    try {
      // Step 1: サイレントオーディオ要素を作成して「メディア再生モード」を確立
      // iOS SafariではWebRTC前にメディア再生を開始することで、スピーカー出力を強制できる場合がある
      await this.initSilentMediaElement();

      // Step 2: AudioContextを「playback」モードで作成
      // latencyHint: 'playback'を指定することで、iOSがメディア再生として扱う
      this.remoteAudioContext = new (window.AudioContext || window.webkitAudioContext)({
        latencyHint: 'playback',
        sampleRate: 24000 // OpenAI Realtime APIのサンプルレート
      });
      this.log(`[iOS] AudioContext created: state=${this.remoteAudioContext.state}, sampleRate=${this.remoteAudioContext.sampleRate}`);

      // iOS Safari: 電話着信やSiri起動による `interrupted` 状態からの復帰処理
      this.remoteAudioContext.addEventListener('statechange', () => {
        const state = this.remoteAudioContext?.state;
        this.log(`[iOS] AudioContext statechange: ${state}`);

        if (state === 'interrupted') {
          this.log('[iOS] AudioContext interrupted (phone call / Siri). Audio paused.');
          if (typeof this.onAudioInterrupted === 'function') {
            this.onAudioInterrupted();
          }
        }

        if (state === 'running') {
          this.log('[iOS] AudioContext resumed from interruption. Audio recovered.');
          if (typeof this.onAudioRecovered === 'function') {
            this.onAudioRecovered();
          }
        }

        // `interrupted` 終了後に `suspended` へ遷移する場合がある
        // ユーザー操作をトリガーに resume() を試みる
        if (state === 'suspended') {
          this.log('[iOS] AudioContext suspended after interruption. Waiting for user interaction to resume.');
          const resumeOnInteraction = () => {
            if (this.remoteAudioContext && this.remoteAudioContext.state === 'suspended') {
              this.remoteAudioContext.resume().then(() => {
                this.log(`[iOS] AudioContext resumed via user interaction: state=${this.remoteAudioContext?.state}`);
              }).catch((err) => {
                this.log(`[iOS] AudioContext resume on interaction failed: ${err.message}`);
              });
            }
            document.removeEventListener('touchstart', resumeOnInteraction);
            document.removeEventListener('click', resumeOnInteraction);
          };
          document.addEventListener('touchstart', resumeOnInteraction, { once: true });
          document.addEventListener('click', resumeOnInteraction, { once: true });
        }
      });

      // suspended状態の場合はresumeを試みる（タイムアウト付き）
      // iOS Safariでは他のAudioContextとの競合でresume()がハングする場合がある
      if (this.remoteAudioContext.state === 'suspended') {
        this.log("[iOS] AudioContext is suspended, calling resume() with timeout...");
        const RESUME_TIMEOUT_MS = 500; // 500ms タイムアウト

        try {
          await Promise.race([
            this.remoteAudioContext.resume(),
            new Promise((_, reject) =>
              setTimeout(() => reject(new Error('resume() timeout')), RESUME_TIMEOUT_MS)
            )
          ]);
          this.log(`[iOS] AudioContext resumed: state=${this.remoteAudioContext.state}`);
        } catch (resumeError) {
          this.log(`[iOS] AudioContext resume failed or timed out: ${resumeError.message}`);
          // resume失敗しても続行（サイレント再生で再度試みる）
        }
      }

      // Step 3: AudioContext経由でサイレント音声を再生してオーディオ出力をアンロック
      try {
        // より長い無音バッファを再生（iOSがオーディオセッションを確立するのに十分な時間）
        const silentBuffer = this.remoteAudioContext.createBuffer(1, 24000, 24000); // 1秒間の無音
        const silentSource = this.remoteAudioContext.createBufferSource();
        silentSource.buffer = silentBuffer;
        silentSource.connect(this.remoteAudioContext.destination);
        silentSource.start();
        this.log("[iOS] Silent audio played via AudioContext to unlock output");
      } catch (silentError) {
        this.log(`[iOS] Silent audio play failed: ${silentError.message}`);
      }

      this.remoteAudioUnlocked = true;
      this.log(`[iOS] Remote audio context initialized: state=${this.remoteAudioContext.state}`);
    } catch (e) {
      this.log(`[iOS] Remote audio context init failed: ${e.message}`);
      this.log(`[iOS] Error stack: ${e.stack}`);
    }
  }

  /**
   * iOS Safari用: サイレントメディア要素を初期化して「メディア再生モード」を確立
   * WebRTC接続前に呼び出すことで、iOSのオーディオセッションをメディアモードに設定
   */
  async initSilentMediaElement() {
    try {
      // 既存のサイレント要素があれば再利用
      if (this.silentMediaElement) {
        this.log("[iOS] Silent media element already exists, playing...");
        await this.playSilentMedia();
        return;
      }

      // サイレントオーディオ要素を作成
      // data URI でインライン無音MP3を埋め込み（外部ファイル不要）
      // これは最小サイズの有効なMP3ファイル（無音）
      const SILENT_MP3_DATA_URI = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGluZwAAAA8AAAACAAABhgC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7//////////////////////////////////////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAAYYoRwmHAAAAAAD/+1DEAAAGAAGn9AAAIgAANP8AAARMQVNSX0lORk8AAAAJAAAADQAAACEAAAA0AAAAPQAAADUAAAAtAAAAJgAAAB0AAAAUAAAAAQAAAA==';

      this.silentMediaElement = document.createElement('audio');
      this.silentMediaElement.id = 'ios-silent-audio';
      this.silentMediaElement.src = SILENT_MP3_DATA_URI;
      this.silentMediaElement.loop = true;
      this.silentMediaElement.volume = 0.01; // 最小音量（0だとiOSが再生をスキップする場合がある）
      this.silentMediaElement.playsInline = true;
      this.silentMediaElement.setAttribute('playsinline', 'true');
      this.silentMediaElement.setAttribute('webkit-playsinline', 'true');
      this.silentMediaElement.style.display = 'none';
      document.body.appendChild(this.silentMediaElement);

      this.log("[iOS] Silent media element created");

      await this.playSilentMedia();
    } catch (e) {
      this.log(`[iOS] Silent media element init failed: ${e.message}`);
    }
  }

  /**
   * サイレントメディア要素を再生
   */
  async playSilentMedia() {
    if (!this.silentMediaElement) return;

    try {
      const playPromise = this.silentMediaElement.play();
      if (playPromise) {
        await playPromise;
        this.log("[iOS] Silent media playing - media mode established");
      }
    } catch (e) {
      this.log(`[iOS] Silent media play failed: ${e.message}`);
    }
  }

  /**
   * サイレントメディア要素を停止・削除
   */
  cleanupSilentMediaElement() {
    if (this.silentMediaElement) {
      this.silentMediaElement.pause();
      this.silentMediaElement.src = '';
      if (this.silentMediaElement.parentNode) {
        this.silentMediaElement.parentNode.removeChild(this.silentMediaElement);
      }
      this.silentMediaElement = null;
      this.log("[iOS] Silent media element cleaned up");
    }
  }

  async connect(config = {}) {
    const { instructions = "", deviceId = null, sessionConfig = null, toolsEnabled = null, voiceSettings = {}, signal = null } = config;

    try {
      // Issue #413: 外側 timeout が abort した場合は connect 全体を打ち切る。
      // signal は startSession() に伝搬し、SDP fetch を AbortController で中止する。
      if (signal?.aborted) {
        throw new DOMException("connect aborted before start", "AbortError");
      }
      // 前回 disconnect() で立てた intentional flag を新規 connect 開始時に reset する。
      // これを忘れると次回の真の障害 (datachannel onclose) でエラー通知が永久に抑制される。
      this._intentionalDisconnect = false;
      // disconnect() を経由しない再接続経路の保険として、ここでも _remoteMuted を
      // リセットする。前セッションが suspend 中(_remoteMuted=true)のまま破棄された
      // 場合、これを忘れると新しいセッションの audio 要素が muted=true で生まれ
      // 全編無音になる。
      // レビュー F3 (dev.61): 単純に false へリセットせず、_backgroundMuted を
      // 引き継ぐ形にする。F1(a) で _onTabHidden() の getIsConnected() 門を
      // 外したことで、hidden 中に新しいセッションが張られる経路(在来のバグ
      // 修正・手動再接続等)へ実際に到達し得るようになった — その新セッション
      // のスピーカー側も背景ミュートを維持しないと、hidden 中に音が漏れる。
      this.setRemoteAudioMuted(this._backgroundMuted);
      // dev.61: _backgroundMuted は意図的にここでリセット**しない**(_remoteMuted
      // と意図的に逆)。_remoteMuted はセッションの状態だが _backgroundMuted は
      // タブの状態 — hidden のまま自動再接続が走った場合に、新セッションが
      // 背景ミュートを素通しにしてしまうのを防ぐ。解除するのは
      // widget-app-persistent.js の _onTabVisible() だけ(tests/test-suspend-integrity.js
      // C6 が固定)。
      this.log("Starting the connection...");

      // PC READYを計測（事前初期化済みのため即座に完了）
      mark('PC_READY');

      // 最初にマイクアクセス許可を取得（ユーザー操作直後に呼び出すことで許可ダイアログを表示）
      mark('GUM_START');
      const micResult = await this.requestMicrophoneAccess(deviceId);
      mark('GUM_DONE');
      if (micResult.success) {
        this.log("Microphone access acquired");
      } else {
        this.log(`Microphone access failed (${micResult.errorType})`);
        // マイク拒否時はWebRTC接続を中断し、呼び出し元でテキストモードフォールバックを実行する
        return { connected: false, micDenied: true, errorType: micResult.errorType };
      }

      const token = await this.getEphemeralToken(sessionConfig, instructions, toolsEnabled, voiceSettings);
      if (!token) {
        const tokenError = new Error(t("err_token_failed", "Failed to obtain the authentication token"));
        // レート制限判定 (429/503) はローカライズ済みメッセージ文字列をパースせず、
        // 数値プロパティで行う契約 (日本語環境で文字列マッチが壊れるため)。
        // ephemeralTokenManager 側は HTTP エラー時の response.status を保持しているので
        // それをそのままエラーオブジェクトに転記する。
        const tokenHttpStatus = ephemeralTokenManager.getLastErrorStatus?.();
        if (typeof tokenHttpStatus === "number") {
          tokenError.httpStatus = tokenHttpStatus;
        }
        throw tokenError;
      }
      this.log("Token acquired");

      // トークン取得時のセッション設定を保存（session.update時に消失しないようにする）
      const cachedConfig = ephemeralTokenManager.getLastSessionConfig();
      if (cachedConfig) {
        this.setSessionConfig(cachedConfig);
      }

      await this.setupPeerConnection();
      this.log("PeerConnection set up");

      await this.startSession(token, { signal });
      this.log("Session started");

      this.isConnected = true;
      return { connected: true };
    } catch (error) {
      this.log(`Connection error: ${error.message}`);
      // AbortError は意図的な disconnect / signal abort によるキャンセル。
      // - errors テーブル: text↔voice 切替の度に false positive 記録となるためスキップ
      // - onError: widget の handleRealtimeError → addMessage "音声モードエラー" を抑制
      const isAbort = error.name === "AbortError";
      if (!isAbort && sessionLogger.isCreated()) {
        sessionLogger.logError('connection', error.name, error.message).catch(() => {});
      }
      if (!isAbort && this.callbacks.onError) {
        this.callbacks.onError(error);
      }
      return { connected: false, micDenied: false };
    }
  }
  
  /**
   * マイクアクセス許可を取得
   * ユーザー操作直後に呼び出すことで、許可ダイアログを最初に表示する
   * @returns {Object} { success: boolean, error?: Error, errorType?: string }
   */
  async requestMicrophoneAccess(deviceId) {
    try {
      // dev.56 A-5: 自動再接続(cold)経路で旧マイクが live のまま孤児化するのを防ぐ。
      // suspend 中の resumeSession() は別経路(独自に track.stop 済み)なので無関係。
      if (this.mediaStream) {
        this.mediaStream.getTracks().forEach((t) => t.stop());
        this.mediaStream = null;
      }
      this.mediaStream = await this.getMediaStream(deviceId);
      this.log("Microphone acquired");
      return { success: true };
    } catch (error) {
      this.log(`Failed to acquire the microphone: ${error.message}`);
      this.log("Continuing the connection with a silent track (text input only)");

      // セッションログにエラーを記録（非同期、エラーは無視）
      if (sessionLogger.isCreated()) {
        sessionLogger.logError('gum', error.name, error.message).catch(() => {});
      }

      // エラータイプを判定
      let errorType = 'unknown';
      if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') {
        errorType = 'permission_denied';
      } else if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') {
        errorType = 'device_not_found';
      } else if (error.name === 'NotReadableError' || error.name === 'TrackStartError') {
        errorType = 'device_in_use';
      } else if (error.name === 'OverconstrainedError') {
        errorType = 'constraints_error';
      }

      // コールバックを呼び出し
      if (this.callbacks.onMicrophoneError) {
        this.callbacks.onMicrophoneError({
          error,
          errorType,
          errorName: error.name,
          errorMessage: error.message
        });
      }

      this.mediaStream = null;
      return { success: false, error, errorType };
    }
  }

  async getEphemeralToken(sessionConfig, instructions, toolsEnabled, voiceSettings) {
    // EphemeralTokenManagerを使用してトークンを取得
    // sessionConfig があればそれを使用（サーバーサイド統一方式）
    ephemeralTokenManager.setLogCallback((msg) => this.log(msg));
    if (sessionConfig) {
      return ephemeralTokenManager.getToken(sessionConfig);
    }
    // フォールバック: 旧方式（sessionConfig なし） — server side で fetchLatestRealtimeModel に
    // 任せるのが正だが、その経路自体が失敗した時の保険として現行の汎用 GA モデルを直書き。
    return ephemeralTokenManager.getToken({ instructions, tools: [], model: "gpt-realtime-2", voice_settings: voiceSettings });
  }

  async setupPeerConnection() {
    // 旧 session の addEventListener を切り離す。これがないと text↔voice 高速切替時に
    // 旧 pc の close 状態通知が新 session 接続後に届き、widget が誤って
    // 新セッションの cleanupRealtimeSession を走らせて状態を破壊する。
    if (this._pcEventAbort) {
      this._pcEventAbort.abort();
    }
    this._pcEventAbort = new AbortController();
    const pcEventSignal = this._pcEventAbort.signal;

    // PC を毎回新規作成する。旧 state-based reuse は track / answer 二重適用を生み
    // InvalidAccessError / InvalidStateError を発生させていた (NeonDB errors 2026-05-15)。
    if (this.peerConnection) {
      this.log("Discarding the existing RTCPeerConnection and recreating it");
      this.peerConnection.close();
    }
    this.peerConnection = new RTCPeerConnection(RTC_CONFIG);
    this.log("RTCPeerConnection created");

    // ICE接続状態の変化を監視（Android遅延診断用）
    this.peerConnection.addEventListener("iceconnectionstatechange", () => {
      const iceState = this.peerConnection.iceConnectionState;
      mark(`ICE ${iceState.toUpperCase()}`);
      this.log(`[ICE] iceConnectionState: ${iceState}`);

      // Android特有のTURNフォールバック検出
      // checking → failed → checking → connected のパターンはTURN地獄の兆候
      if (iceState === 'failed') {
        this.log(`[ICE] WARNING: ICE connection failed - possible TURN fallback`);
      }
    }, { signal: pcEventSignal });

    this.peerConnection.addEventListener("connectionstatechange", () => {
      const state = this.peerConnection.connectionState;
      mark(`PC ${state.toUpperCase()}`);
      this.log(`[PC] connectionState: ${state}`);

      if (this.callbacks.onConnectionStateChange) {
        this.callbacks.onConnectionStateChange(state);
      }

      if (state === "connected") {
        // 回復した場合はdisconnectタイマーをクリア
        if (this._disconnectTimeout) {
          clearTimeout(this._disconnectTimeout);
          this._disconnectTimeout = null;
          this.log(`[PC] recovered from the disconnected state`);
        }
      } else if (state === "failed") {
        this.isConnected = false;
        if (this._disconnectTimeout) {
          clearTimeout(this._disconnectTimeout);
          this._disconnectTimeout = null;
        }
        this.log(`[PC] connection failed - notifying the user`);
        if (this.callbacks.onError) {
          this.callbacks.onError(
            new Error(t("err_webrtc_failed", "The WebRTC connection failed. Please reconnect.")),
            { type: "connection_failed", state }
          );
        }
      } else if (state === "disconnected") {
        // disconnectedは一時的な場合があるため、5秒待って回復しなければ通知
        this.isConnected = false;
        if (this._disconnectTimeout) {
          clearTimeout(this._disconnectTimeout);
        }
        this._disconnectTimeout = setTimeout(() => {
          this._disconnectTimeout = null;
          // タイムアウト時点でまだdisconnectedかfailedなら通知
          const currentState = this.peerConnection?.connectionState;
          if (currentState === "disconnected" || currentState === "failed") {
            this.log(`[PC] disconnected state persisted for over 5s - notifying the user`);
            if (this.callbacks.onError) {
              this.callbacks.onError(
                new Error(t("err_connection_unstable", "The connection is unstable. Please reconnect.")),
                { type: "connection_disconnected_timeout", state: currentState }
              );
            }
          }
        }, 5000);
      } else if (state === "closed") {
        this.isConnected = false;
        if (this._disconnectTimeout) {
          clearTimeout(this._disconnectTimeout);
          this._disconnectTimeout = null;
        }
      }
    }, { signal: pcEventSignal });

    this.audioElement = document.createElement("audio");
    this.audioElement.autoplay = true;
    this.audioElement.playsInline = true; // iOS対応
    this.audioElement.muted = this._remoteMuted; // 望ましいミュート状態を初期適用(suspend跨ぎ再適用)
    this.audioElement.volume = 1.0; // 音量を最大に設定（ユーザー側で調整可能）

    // メディア音声として扱うための属性を追加
    this.audioElement.setAttribute('x-webkit-airplay', 'allow'); // AirPlay対応
    this.audioElement.setAttribute('playsinline', 'true'); // iOS対応（重複だが明示的に）

    // audio要素をDOMに追加（必須: 追加しないとメディア音声として認識されない）
    this.audioElement.style.display = 'none'; // 非表示にする
    document.body.appendChild(this.audioElement);

    this.peerConnection.ontrack = (event) => {
      const elapsedMs = connectionTimingStart > 0 ? performance.now() - connectionTimingStart : 0;
      mark('ONTRACK');

      // デバッグ: iOS Safari検出とAudioContext状態を詳細にログ出力
      this.log(`[ontrack] IS_IOS_SAFARI=${IS_IOS_SAFARI}, remoteAudioContext=${!!this.remoteAudioContext}, remoteAudioUnlocked=${this.remoteAudioUnlocked}`);
      if (IS_IOS_SAFARI) {
        this.log(`[ontrack] AudioContext state: ${this.remoteAudioContext?.state || 'null'}`);
        this.log(`[ontrack] silentMediaElement exists: ${!!this.silentMediaElement}`);
        if (this.silentMediaElement) {
          this.log(`[ontrack] silentMediaElement paused: ${this.silentMediaElement.paused}, readyState: ${this.silentMediaElement.readyState}`);
        }
        // WebRTCストリーム情報
        const stream = event.streams[0];
        if (stream) {
          const audioTracks = stream.getAudioTracks();
          this.log(`[ontrack] WebRTC stream has ${audioTracks.length} audio track(s)`);
          audioTracks.forEach((track, i) => {
            this.log(`[ontrack] Audio track ${i}: enabled=${track.enabled}, muted=${track.muted}, readyState=${track.readyState}`);
          });
        }
      }

      // iOS Safari: AudioContextを通して再生（スピーカーから出力するため）
      if (IS_IOS_SAFARI && this.remoteAudioContext && this.remoteAudioUnlocked) {
        try {
          // 既存のソースがあれば切断
          if (this.remoteAudioSource) {
            this.remoteAudioSource.disconnect();
            this.log("[iOS] Disconnected existing audio source");
          }

          // WebRTCストリームをAudioContextに接続
          this.remoteAudioSource = this.remoteAudioContext.createMediaStreamSource(event.streams[0]);

          // GainNodeを通して接続（音量制御用）
          // iOS Safariではデフォルトでミュート状態（音量0）で開始
          // これにより、サイレントモード時の予期しない音声再生を防ぐ
          if (!this.remoteGainNode) {
            this.remoteGainNode = this.remoteAudioContext.createGain();
            // 保存された音量値があればそれを使用、なければ0（ミュート）で開始
            // ただし _remoteMuted===true の間は ontrack 発火時点の初期音量が
            // ミュートを突き破らないよう常に0にする(gain は setRemoteAudioMuted()
            // が後から個別に触るが、GainNode 新規作成タイミングと ontrack が
            // 競合する一瞬でも音が漏れないようにする)。
            const initialVolume = this._remoteMuted ? 0 : (this._remoteAudioVolume ?? 0);
            this.remoteGainNode.gain.value = initialVolume;
            this.remoteGainNode.connect(this.remoteAudioContext.destination);
            this.log(`[iOS] Created GainNode for volume control (initial volume: ${initialVolume})`);
          }
          this.remoteAudioSource.connect(this.remoteGainNode);

          // AudioContextがsuspended状態なら再開
          if (this.remoteAudioContext.state === 'suspended') {
            this.log("[iOS] AudioContext is suspended in ontrack, attempting resume...");
            this.remoteAudioContext.resume().then(() => {
              this.log(`[iOS] AudioContext resumed in ontrack: state=${this.remoteAudioContext.state}`);
            }).catch((err) => {
              this.log(`[iOS] AudioContext resume failed in ontrack: ${err.message}`);
            });
          }

          this.log(`[iOS] WebRTC audio routed through AudioContext: state=${this.remoteAudioContext.state}, sampleRate=${this.remoteAudioContext.sampleRate}`);

          // audio要素も並行して設定（エコーキャンセラー参照用 + フォールバック用）
          // 重要: muted=true ではなく volume=0.001 を使用
          // muted=true だとエコーキャンセラーが参照音を取得できずエコーが発生する
          // volume=0.001 なら音声信号は流れるが聞こえず、エコーキャンセラーが機能する
          this.audioElement.srcObject = event.streams[0];
          this.audioElement.muted = this._remoteMuted;
          this.audioElement.volume = 0.001; // ほぼ無音だがエコーキャンセラー用に信号を維持
          this.audioElement.play().catch(() => {}); // エラーは無視
          this.log(`[iOS] Audio element set for echo cancellation reference (volume=0.001)`)

        } catch (err) {
          this.log(`[iOS] AudioContext routing failed: ${err.message}, stack: ${err.stack}`);
          this.log(`[iOS] Falling back to audio element playback`);
          // フォールバック: 通常のaudio要素を使用
          this.audioElement.srcObject = event.streams[0];
          this.audioElement.muted = this._remoteMuted;
          this.audioElement.play().catch((e) => this.log(`Audio play failed: ${e.message}`));
        }
      } else {
        // 通常の再生（非iOS、またはAudioContextが初期化されていない場合）
        this.log(`[ontrack] Using audio element fallback (not iOS Safari path)`);
        if (IS_IOS_SAFARI) {
          this.log(`[ontrack] iOS Safari but AudioContext not ready: remoteAudioContext=${!!this.remoteAudioContext}, remoteAudioUnlocked=${this.remoteAudioUnlocked}`);
        }
        this.audioElement.srcObject = event.streams[0];

        // モバイルでメディア音声として扱うために明示的に再生を開始
        this.audioElement.play().catch((err) => {
          this.log(`Audio play failed: ${err.message}`);
        });
      }

      // セッションログに接続完了を記録（非同期、エラーは無視）
      if (sessionLogger.isCreated()) {
        sessionLogger.markConnected(Math.round(elapsedMs)).catch(() => {});
      }

      // Issue #455: 接続所要時間を localStorage 履歴に蓄積。次回起動時に slow
      // 判定の材料になる (Safari/Firefox の navigator.connection 未対応環境用)。
      // 5 秒超過時のみ記録される (helper 側で閾値判定)。
      recordConnectionDuration(elapsedMs);

      // 接続時間異常検知: 閾値を超えた場合にエラーとして報告
      const thresholdMs = getConnectionTimeoutThresholdMs();
      if (elapsedMs > thresholdMs) {
        const anomalyError = new Error(`Abnormal connection time: ${Math.round(elapsedMs)}ms (threshold: ${thresholdMs}ms)`);
        anomalyError.name = 'ConnectionTimingAnomaly';
        captureException(anomalyError, 'connectionTiming', {
          severity: 'warn',
          connectionDurationMs: Math.round(elapsedMs),
          thresholdMs,
          notificationCategory: 'anomaly_timeout',
        });
        this.log(`[connection-time anomaly] ${Math.round(elapsedMs)}ms > ${thresholdMs}ms`);
      }
    };

    // OpenAI Realtime APIはsendrecvでのoffer送信が必要
    // マイクトラックをcreateOffer前に追加することで、OpenAI側が音声を受け取る準備ができる
    // 注: 以前のrecvonly方式ではOpenAI側に音声が送信されなかった
    if (this.mediaStream) {
      const audioTrack = this.mediaStream.getTracks()[0];
      if (audioTrack) {
        this.peerConnection.addTrack(audioTrack, this.mediaStream);
        this.log("Microphone track added (sendrecv)");
        mark('MIC_ATTACHED');
      } else {
        this.log("No audio track found - continuing in receive-only mode");
        this.peerConnection.addTransceiver("audio", { direction: "recvonly" });
      }
    } else {
      // マイクがない場合は受信専用モードで続行（テキスト入力のみ）
      this.log("No microphone stream - continuing in receive-only mode");
      this.peerConnection.addTransceiver("audio", { direction: "recvonly" });
    }

    this.setupDataChannel();
  }

  /**
   * dev.63: `exactDevice` — ユーザーがデバイス選択UIで明示的に選んだ切替は
   * `{ exact }` 制約で取得する。従来の `{ ideal }` はソフト制約で、要求
   * デバイスを開けない場合(他アプリ使用中・deviceId失効等)にブラウザが
   * **エラーなしで別デバイス(通常は現行既定)を返すことが許されており**、
   * 「切り替えたのに実際は変わらない」報告の根本原因だった。exact なら
   * 「要求デバイスを開く」か「OverconstrainedError を投げる」の二択になり、
   * 失敗は呼び出し元の既存 catch(切替失敗メッセージ+旧マイク維持)に乗る。
   * 初回接続(requestMicrophoneAccess)と復帰(resumeSession)は、保存済み
   * デバイスが抜かれていても既定マイクで会話を継続できるよう ideal のまま。
   */
  async getMediaStream(deviceId, { exactDevice = false } = {}) {
    // Audio設定を最適化（SDP軽量化 + getUserMedia高速化のため）
    // - mono (channelCount: 1): ステレオ不要
    // - 16kHz (sampleRate: 16000): 音声認識用途には十分
    // - echoCancellation: true（エコーキャンセルは必要）
    // - noiseSuppression: iOS Safariではエコー除去強化のため有効
    // - autoGainControl: false（初期化高速化のため無効）
    // ※ OpenAI側で音声処理されるため、ブラウザ側の処理は最小限でOK
    const audioConstraints = {
      channelCount: 1,
      sampleRate: 16000,
      echoCancellation: true,
      // iOS Safariではエコー問題対策としてノイズサプレッションも有効化
      noiseSuppression: IS_IOS_SAFARI,
      autoGainControl: false,
    };

    if (IS_IOS_SAFARI) {
      this.log(`[iOS] Audio constraints with enhanced echo cancellation: echoCancellation=true, noiseSuppression=true`);
    }

    if (deviceId) {
      audioConstraints.deviceId = exactDevice ? { exact: deviceId } : { ideal: deviceId };
    }

    const constraints = { audio: audioConstraints };

    if (!navigator.mediaDevices?.getUserMedia) {
      throw new Error(
        t("err_media_devices_unavailable", "mediaDevices is not available (insecure context or missing iframe permission)")
      );
    }

    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    this._watchMicTrackEnded(stream);
    // dev.61: hidden 中に走った再接続 / デバイス切替で新しく取得したトラックが
    // 背景ミュートを突き破らないようにする。マイク取得の3経路
    // (setupPeerConnection / requestMicrophoneAccess / resumeSession) が
    // すべてここを通るので、一点で塞げる (_watchMicTrackEnded と同じ理由)。
    if (this._backgroundMuted) {
      stream.getAudioTracks().forEach((t) => { t.enabled = false; });
    }
    return stream;
  }

  // マイクの物理切断検知（デバイス取得の3経路 = setupPeerConnection() /
  // requestMicrophoneAccess() / resumeSession() は全てこの getMediaStream() を
  // 通るため、ここ一点を監視すれば取得経路によらず検知できる）。
  // track.stop() は "ended" を発火しないので、自前で stop した旧トラック/別トラックを
  // 誤検知しないよう「発火したトラックが現用トラックそのものか」を必ず確認する。
  _watchMicTrackEnded(stream) {
    const track = stream?.getAudioTracks?.()[0];
    if (!track || typeof track.addEventListener !== "function") return;
    track.addEventListener("ended", () => {
      if (this._suspended) return; // suspend 中は mediaStream=null が正常系
      if (this.mediaStream?.getAudioTracks?.()[0] !== track) return; // 旧トラック/自前 stop
      this.log("[mic] active microphone track ended (device lost)");
      if (this.callbacks.onMicrophoneLost) {
        this.callbacks.onMicrophoneLost({ reason: "ended", label: track.label || "" });
      }
    });
    track.addEventListener("mute", () => this.log("[mic] track muted (transient?)"));
    track.addEventListener("unmute", () => this.log("[mic] track unmuted"));
  }

  createSilentAudioTrack() {
    const audioContext = new (window.AudioContext ||
      window.webkitAudioContext)();
    const oscillator = audioContext.createOscillator();
    const gainNode = audioContext.createGain();
    const destination = audioContext.createMediaStreamDestination();

    gainNode.gain.value = 0;
    oscillator.connect(gainNode);
    gainNode.connect(destination);
    oscillator.start();

    return destination.stream.getAudioTracks()[0];
  }

  /**
   * SDPを最適化（opusのみに限定）
   * Android Chrome対策: 不要なcodecを削除してSDP parseを高速化
   * - stereo=0（モノラル）
   * - maxaveragebitrate=32000（低ビットレート）
   *
   * Issue #441: 書き換え失敗時は元SDPをそのまま返す。Safari/iOS や OpenAI Realtime
   * 側の SDP 仕様変更で書き換えが構造的に破綻するケースで、UX を完全停止させない
   * ための fallback。書き換え失敗ログは monitoring 側で集計する。
   *
   * @param {string} sdp - 元のSDP
   * @returns {string} - 最適化されたSDP（書き換え失敗時は元SDP）
   */
  optimizeSdpForOpus(sdp) {
    if (!sdp) return sdp;

    try {
      return this._optimizeSdpForOpusCore(sdp);
    } catch (e) {
      // Issue #441: 書き換えロジックが破綻 → 元SDPで接続継続を試みる
      this.log(`[SDP] optimization failed, falling back to original SDP: ${e.message}`);
      if (this.callbacks.onSdpRewriteError) {
        try {
          this.callbacks.onSdpRewriteError({ phase: 'optimize', error: e });
        } catch (cbErr) { /* ignore callback error */ }
      }
      return sdp;
    }
  }

  _optimizeSdpForOpusCore(sdp) {
    const lines = sdp.split('\r\n');
    const optimizedLines = [];
    let inAudioSection = false;
    let opusPayloadType = null;
    
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      
      // audio m-lineを検出
      if (line.startsWith('m=audio')) {
        inAudioSection = true;
        // opusのペイロードタイプを探す
        const rtpmapIndex = lines.findIndex((l, idx) => 
          idx > i && l.includes('a=rtpmap:') && l.includes('opus/48000')
        );
        if (rtpmapIndex !== -1) {
          const match = lines[rtpmapIndex].match(/a=rtpmap:(\d+)/);
          if (match) {
            opusPayloadType = match[1];
          }
        }
        
        // m=audio行をopusのみに書き換え
        if (opusPayloadType) {
          const parts = line.split(' ');
          // m=audio [port] [proto] [payload types...]
          // opusのペイロードタイプのみを残す
          const newLine = `${parts[0]} ${parts[1]} ${parts[2]} ${opusPayloadType}`;
          optimizedLines.push(newLine);
          this.log(`[SDP] audio m-line optimization: limited to opus(${opusPayloadType}) only`);
          continue;
        }
      }
      
      // video m-lineを検出（audioセクション終了）
      if (line.startsWith('m=video') || line.startsWith('m=application')) {
        inAudioSection = false;
      }
      
      // audioセクション内で不要なcodec関連行をスキップ
      if (inAudioSection && opusPayloadType) {
        // rtpmap行: opusのみ残す
        if (line.startsWith('a=rtpmap:')) {
          if (!line.includes(`a=rtpmap:${opusPayloadType}`)) {
            continue; // opus以外のrtpmapをスキップ
          }
        }
        // fmtp行: opusのみ残し、最適化パラメータを追加
        if (line.startsWith('a=fmtp:')) {
          if (line.includes(`a=fmtp:${opusPayloadType}`)) {
            // opusのfmtp行を最適化
            // stereo=0（モノラル）、maxaveragebitrate=32000（低ビットレート）
            let optimizedFmtp = line;
            if (!optimizedFmtp.includes('stereo=')) {
              optimizedFmtp += ';stereo=0';
            }
            if (!optimizedFmtp.includes('maxaveragebitrate=')) {
              optimizedFmtp += ';maxaveragebitrate=32000';
            }
            optimizedLines.push(optimizedFmtp);
            continue;
          } else {
            continue; // opus以外のfmtpをスキップ
          }
        }
        // rtcp-fb行: opusのみ残す
        if (line.startsWith('a=rtcp-fb:')) {
          if (!line.includes(`a=rtcp-fb:${opusPayloadType}`)) {
            continue; // opus以外のrtcp-fbをスキップ
          }
        }
      }
      
      optimizedLines.push(line);
    }
    
    return optimizedLines.join('\r\n');
  }

  setupDataChannel() {
    // disconnect() を経由せず connect() が再呼び出しされる再利用パスでも
    // 旧 DC の property handler を null 化してから close する。
    // null 化が無いと旧 DC の onclose が新 session 接続後に late fire し、
    // widget の cleanupRealtimeSession() を新 session 中に実行する
    // race condition が disconnect() 経由と同じく発生する (PR #611 AI Review medium round2)。
    // 旧 session の addEventListener("message", ...) を session 間で剥がすための
    // AbortController。dc.close() 直前に JS event loop に積まれた in-flight メッセージが
    // close 後も handleMessage() を呼び出す race を防ぐ (PR #611 AI Review medium round1)。
    // abort → close の順序は disconnect() と統一 (PR #611 round3 low)。
    if (this._dcEventAbort) {
      this._dcEventAbort.abort();
    }
    this._dcEventAbort = new AbortController();
    const dcEventSignal = this._dcEventAbort.signal;

    if (this.dataChannel) {
      this.dataChannel.onopen = null;
      this.dataChannel.onclose = null;
      this.dataChannel.onerror = null;
      this.dataChannel.close();
      this.dataChannel = null;
    }

    this.dataChannel = this.peerConnection.createDataChannel("oai-events");

    // DataChannel準備完了Promiseを初期化
    this._dataChannelReady = new Promise((resolve) => {
      this._dataChannelReadyResolve = resolve;
    });

    this.dataChannel.onopen = () => {
      this.log("The DataChannel opened");
      if (this._dataChannelReadyResolve) {
        this._dataChannelReadyResolve();
        this._dataChannelReadyResolve = null;
      }
    };

    this.dataChannel.onclose = () => {
      this.log("The DataChannel closed");
      this.isConnected = false;
      if (this.callbacks.onConnectionStateChange) {
        this.callbacks.onConnectionStateChange("closed");
      }
      // 正常切断（disconnect()経由）の場合はエラー通知しない
      if (!this._intentionalDisconnect && this.callbacks.onError) {
        this.callbacks.onError(
          new Error(t("err_datachannel_closed", "The data channel was disconnected. Please reconnect.")),
          { type: "datachannel_closed" }
        );
      }
    };

    this.dataChannel.addEventListener("message", (event) => {
      try {
        this.handleMessage(JSON.parse(event.data));
      } catch (error) {
        this.log(`DataChannel message parse error: ${error.message}`);
      }
    }, { signal: dcEventSignal });
  }

  /**
   * SDPをサーバーに送信し、接続を確立する
   * ICE候補収集完了時またはタイムアウト時に呼び出される
   * @param {string} token - 認証トークン
   * @returns {Promise<void>}
   */
  async sendOfferToServer(token, { signal } = {}) {
    // 並行 disconnect() による null race 対策: local pc capture (NeonDB errors 2026-05-15)。
    const pc = this.peerConnection;
    if (!pc) {
      throw new DOMException("Aborting the SDP exchange because PeerConnection is null", "AbortError");
    }

    const offer = pc.localDescription;
    mark('SEND_OFFER');
    this.log("Sending the SDP to the server...");

    // Issue #413: AbortSignal を渡すことで外側の timeout で fetch を確実に abort する。
    // signal が無い場合は従来通り (後方互換)。
    const fetchOptions = {
      method: "POST",
      body: offer.sdp,
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/sdp",
      },
    };
    if (signal) fetchOptions.signal = signal;
    // NS の realtime-token レスポンスに含まれる model を ?model= として付与する
    // (NS 既存 widget-core.js と同じ流儀 — §3.2)。
    const model = ephemeralTokenManager.getLastModel?.();
    const requestUrl = model
      ? `${REALTIME_API_URL}?model=${encodeURIComponent(model)}`
      : REALTIME_API_URL;

    // Issue T (401ループ): OpenAI のエフェメラルトークンは WebRTC SDP 交換に使うと
    // 消費される使い捨てトークンである。ここで実際にネットワークへ token を
    // 送信する時点で、成功/失敗/例外を問わずキャッシュを破棄する。これにより
    // 「有効期限内だから」という理由だけで消費済みトークンが再利用されて
    // 401 ループに陥る構造的原因を断つ。finally で行うため、fetch 自体が
    // reject した場合 (signal abort 等、ネットワークに実際には出ていない場合を
    // 含む) も破棄されるが、破棄されて損をするのは「次回に1回余分なトークン
    // 取得が走る」程度であり、401 ループより十分に安全側。
    let response;
    try {
      response = await fetch(requestUrl, fetchOptions);
    } finally {
      ephemeralTokenManager.clearCache();
    }

    if (!response.ok) {
      const sdpError = new Error(t("err_sdp_send", "SDP send error: {status}").replace("{status}", response.status));
      // 数値プロパティで HTTP status を伝搬する（メッセージ文字列パース禁止 — 日本語環境で壊れるため）。
      sdpError.httpStatus = response.status;
      throw sdpError;
    }

    const answerSdp = await response.text();
    const answer = {
      type: "answer",
      sdp: answerSdp,
    };

    // fetch 中に PC がすり替わった場合 (disconnect→新 connect) は旧 PC への setRemote を回避。
    if (this.peerConnection !== pc) {
      throw new DOMException("Aborting: PeerConnection was replaced during the SDP exchange", "AbortError");
    }

    await pc.setRemoteDescription(answer);
    this.log("Remote description set");
  }

  async startSession(token, { signal } = {}) {
    return new Promise(async (resolve, reject) => {
      // 状態フラグは onAbort クロージャより前に宣言する (TDZ 回避)。
      let iceSent = false;
      let iceTimeout = null;

      // 外側 timeout が abort 信号を立てたら ICE 収集も SDP fetch も中止する
      const onAbort = () => {
        if (iceTimeout) {
          clearTimeout(iceTimeout);
          iceTimeout = null;
        }
        if (!iceSent) {
          iceSent = true;
          reject(new DOMException("startSession aborted", "AbortError"));
        }
      };
      if (signal) {
        if (signal.aborted) {
          reject(new DOMException("startSession aborted", "AbortError"));
          return;
        }
        signal.addEventListener("abort", onAbort, { once: true });
      }
      // 最適化: ネットワークタイプに応じた適応的ICE gatheringタイムアウト
      // 4G: 2000ms, 3G: 3000ms, 2G: 5000ms
      // Android環境ではICE候補収集に40秒以上かかる場合があるため、
      // 適応的タイムアウトを設定し、収集済みの候補でSDPを送信する
      const ICE_GATHERING_TIMEOUT_MS = getAdaptiveIceGatheringTimeout();
      const iceCandidateCounts = {};  // 種別ごとの候補数を集計

      // ICE候補収集完了時またはタイムアウト時にSDPを送信
      const sendOfferOnce = async (reason) => {
        if (iceSent) return;
        iceSent = true;

        if (iceTimeout) {
          clearTimeout(iceTimeout);
          iceTimeout = null;
        }

        mark('OFFER_DONE');
        const summary = Object.entries(iceCandidateCounts).map(([t, c]) => `${c} ${t}`).join(', ');
        this.log(`ICE candidate gathering ${reason} (${summary || 'none'})`);

        try {
          // Issue #413: signal を fetch に伝搬させ、外側 timeout で SDP POST を abort
          await this.sendOfferToServer(token, { signal });
          // 正常完了パスでも abort listener を削除する (signal が外側で再利用された場合のリーク防止)
          if (signal) signal.removeEventListener("abort", onAbort);
          resolve();
        } catch (error) {
          if (signal) signal.removeEventListener("abort", onAbort);
          this.log(`Session start error: ${error.message}`);
          reject(error);
        }
      };
      
      // ICE候補収集タイムアウト設定
      // Android Chrome/WebViewではICE候補収集に40秒以上かかる場合があるため、
      // 3秒後に収集済みの候補でSDPを送信する
      iceTimeout = setTimeout(() => {
        if (!iceSent) {
          this.log(`[ICE] timeout (${ICE_GATHERING_TIMEOUT_MS}ms) - sending the SDP with the candidates gathered so far`);
          sendOfferOnce('timed out - continuing with the candidates gathered so far');
        }
      }, ICE_GATHERING_TIMEOUT_MS);
      
      // ICE候補収集イベントハンドラ
      this.peerConnection.onicecandidate = async (event) => {
        if (event.candidate === null) {
          // ICE候補収集完了（candidate === nullは収集完了を示す）
          sendOfferOnce('complete');
        } else {
          // ICE候補が収集された（種別をカウント、個別ログは省略）
          const candidateStr = event.candidate.candidate;
          const candidateType = parseIceCandidateType(candidateStr);
          iceCandidateCounts[candidateType] = (iceCandidateCounts[candidateType] || 0) + 1;

          // relay（TURN）が使用された場合は警告（初回のみ）
          if (candidateType === 'relay' && iceCandidateCounts[candidateType] === 1) {
            this.log(`[ICE] WARNING: TURN relay in use - the connection may be slower`);
          }

          // 早期送信最適化: host + srflx 候補が揃った時点で即送信
          // Android Chrome では onicecandidate(null) が発火しない/40秒以上遅延する
          // ケースがあるため、タイムアウト待ちを回避して大幅に高速化する。
          // host（ローカル）+ srflx（STUN経由）があればNAT越え接続に十分。
          if (!iceSent && iceCandidateCounts['host'] > 0 && iceCandidateCounts['srflx'] > 0) {
            sendOfferOnce('sent early - host+srflx ready');
          }
        }
      };

      // ICE収集状態の変化を監視
      this.peerConnection.onicegatheringstatechange = () => {
        this.log(`ICE gathering state: ${this.peerConnection.iceGatheringState}`);
      };

      try {
        // 並行 disconnect() による null race 対策: local pc capture (NeonDB errors 2026-05-15)。
        const pc = this.peerConnection;
        if (!pc) {
          reject(new DOMException("Aborting createOffer because PeerConnection is null", "AbortError"));
          return;
        }

        // オファーを作成してローカル記述を設定
        // setLocalDescription()は非同期でICE候補収集を開始し、すぐに返る
        mark('OFFER_START');

        // offerToReceiveAudioを明示指定
        const offerOptions = {
          offerToReceiveAudio: true,
          offerToReceiveVideo: false
        };

        // UX保険: createOfferにタイムアウトを設定（Android対策）
        // 万が一createOfferが長時間ブロックした場合にエラーとして検出
        const OFFER_TIMEOUT_MS = 5000;
        const offer = await Promise.race([
          pc.createOffer(offerOptions),
          new Promise((_, timeoutReject) =>
            setTimeout(() => timeoutReject(new Error("createOffer timeout (5s) - Android Audio HAL issue suspected")), OFFER_TIMEOUT_MS)
          )
        ]);
        this.log("Offer created");

        // createOffer 後の identity check
        if (this.peerConnection !== pc) {
          reject(new DOMException("Aborting: PeerConnection was replaced during createOffer", "AbortError"));
          return;
        }

        // SDP軽量化: opusのみに限定（Android Chrome対策）
        // 不要なcodecを削除することでSDP parseを高速化
        const optimizedSdp = this.optimizeSdpForOpus(offer.sdp);
        const optimizedOffer = {
          type: offer.type,
          sdp: optimizedSdp
        };

        // Issue #441: setLocalDescription 失敗時は元SDPで再試行する。
        // Safari の SDP 解釈は厳密で、optimized SDP が拒否されるケースがある。
        // 書き換えで接続不能になるよりは、最適化を諦めても接続成立を優先する。
        try {
          await pc.setLocalDescription(optimizedOffer);
          this.log("Local description set (ICE gathering started, SDP already optimized)");
        } catch (setLocalErr) {
          this.log(`[SDP] setLocalDescription with optimized SDP failed: ${setLocalErr.message}, retrying with original SDP`);
          if (this.callbacks.onSdpRewriteError) {
            try {
              this.callbacks.onSdpRewriteError({ phase: 'set_local', error: setLocalErr });
            } catch (cbErr) { /* ignore */ }
          }
          // 元 offer で再試行する前に identity check (retry 経路でも race window は同じ)
          if (this.peerConnection !== pc) {
            reject(new DOMException("Aborting: PeerConnection was replaced just before the setLocalDescription retry", "AbortError"));
            return;
          }
          await pc.setLocalDescription(offer);
          this.log("Local description set (retry with the original SDP succeeded)");
        }
        // ここでブロックせず、onicecandidateイベントまたはタイムアウトでSDP送信
      } catch (error) {
        // エラー時はタイムアウトをクリア
        if (iceTimeout) {
          clearTimeout(iceTimeout);
        }
        this.log(`Offer creation error: ${error.message}`);
        reject(error);
      }
    });
  }

  handleMessage(event) {
    const eventType = event.type;

    this.emitEvent(eventType, event);

    switch (eventType) {
      case SERVER_EVENT_TYPES.SESSION_CREATED:
        this.log("The session was created");
        if (this.callbacks.onSessionCreated) {
          this.callbacks.onSessionCreated(event);
        }
        break;

      case SERVER_EVENT_TYPES.SESSION_UPDATED:
        this.log("The session was updated");
        // Issue #558: 並列待機の全 resolver を発火 (1 session.update → 1 SESSION_UPDATED 前提だが、
        // 並列待ちが立った場合にサイレントタイムアウトする旧実装の問題を回避)
        if (this._sessionUpdatedResolvers.length > 0) {
          const pending = this._sessionUpdatedResolvers.splice(0);
          for (const resolver of pending) resolver();
        }
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_CREATED:
        this.log(`Conversation item created: item_id=${event.item?.id}, type=${event.item?.type}, role=${event.item?.role}`);
        // テキスト送信への応答 → サーバー割当のitem_idを捕捉して重複排除用に通知
        if (this._pendingTextSend && event.item?.role === "user") {
          this._pendingTextSend = false;
          if (this.callbacks.onTextItemCreated) {
            this.callbacks.onTextItemCreated(event.item.id);
          }
        }
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_ADDED:
        this.log(`Conversation item added: item_id=${event.item?.id}, type=${event.item?.type}`);
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_DONE:
        this.log(`Conversation item completed: item_id=${event.item?.id}`);
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_RETRIEVED:
        this.log(`Conversation item retrieved: item_id=${event.item?.id}`);
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_DELTA:
        if (event.delta) {
          const itemId = event.item_id;
          if (!this.currentTranscription[itemId]) {
            this.currentTranscription[itemId] = "";
          }
          this.currentTranscription[itemId] += event.delta;
          if (this.callbacks.onTranscriptDelta) {
            this.callbacks.onTranscriptDelta(this.currentTranscription[itemId], itemId, "user");
          }
        }
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_COMPLETED:
        if (event.transcript && this.callbacks.onTranscript) {
          const itemId = event.item_id;
          delete this.currentTranscription[itemId];
          this.callbacks.onTranscript(event.transcript, "user", itemId);
        }
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_SEGMENT:
        this.log(`Transcription segment: item_id=${event.item_id}, text=${event.text}`);
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_INPUT_AUDIO_TRANSCRIPTION_FAILED:
        this.log(`Transcription failed: item_id=${event.item_id}, error=${event.error?.message}`);
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_TRUNCATED:
        this.log(`Conversation item truncated: item_id=${event.item_id}`);
        if (this.callbacks.onConversationItemTruncated) {
          this.callbacks.onConversationItemTruncated(event);
        }
        break;

      case SERVER_EVENT_TYPES.CONVERSATION_ITEM_DELETED:
        this.log(`Conversation item deleted: item_id=${event.item_id}`);
        break;

      case SERVER_EVENT_TYPES.INPUT_AUDIO_BUFFER_COMMITTED:
        this.log(`Audio buffer committed: item_id=${event.item_id}`);
        // 0.3.0-dev.46: ユーザー発話の時系列アンカー。commit はこの発話への
        // response より必ず先に届くため、UI 側はここで placeholder を確保する
        // ことで「transcript 完了が AI 応答の確定より遅れて届き、バブルが
        // 末尾に追加されて時系列が逆転する」競走を根本的に回避できる。
        if (this.callbacks.onUserAudioCommitted && event.item_id) {
          this.callbacks.onUserAudioCommitted(event.item_id);
        }
        break;

      case SERVER_EVENT_TYPES.OUTPUT_AUDIO_BUFFER_STARTED:
        this.log(`Output audio buffer started: response_id=${event.response_id}`);
        if (this.callbacks.onOutputAudioStarted) {
          this.callbacks.onOutputAudioStarted(event);
        }
        break;

      case SERVER_EVENT_TYPES.OUTPUT_AUDIO_BUFFER_STOPPED:
        // 0.3.0-dev.47: 再生バッファ枯渇 ≒ AI の発話を再生し終えた合図。
        // navigate_page の「言い終わってから遷移する」トリガーに使う。
        this.log(`Output audio buffer stopped: response_id=${event.response_id}`);
        if (this.callbacks.onOutputAudioStopped) {
          this.callbacks.onOutputAudioStopped(event);
        }
        break;

      case SERVER_EVENT_TYPES.OUTPUT_AUDIO_BUFFER_CLEARED:
        // dev.73: _clearOutputAudioBuffer() が受理された合図。意図的な打ち切り
        // (バージイン/resume) なので onOutputAudioStopped (「言い終えた」扱いで
        // 遷移予約の grace タイマーを起動する) には**流さない** — 割り込んだ直後に
        // 旧応答由来のページ遷移が発火する事故を防ぐ。遷移予約自体は widget 側の
        // バージイン処理 (_clearPendingNavigation) が破棄する。
        this.log(`Output audio buffer cleared: response_id=${event.response_id}`);
        break;

      case SERVER_EVENT_TYPES.INPUT_AUDIO_BUFFER_SPEECH_STARTED:
        this.log(`Audio input started: item_id=${event.item_id}, audio_start_ms=${event.audio_start_ms}`);
        if (this.callbacks.onSpeechStarted) {
          this.callbacks.onSpeechStarted(event);
        }
        break;

      case SERVER_EVENT_TYPES.INPUT_AUDIO_BUFFER_SPEECH_STOPPED:
        this.log(`Audio input ended: item_id=${event.item_id}, audio_end_ms=${event.audio_end_ms}`);
        if (this.callbacks.onSpeechStopped) {
          this.callbacks.onSpeechStopped(event);
        }
        break;

      case SERVER_EVENT_TYPES.INPUT_AUDIO_BUFFER_TIMEOUT_TRIGGERED:
        this.log(`Audio buffer timeout: item_id=${event.item_id}`);
        break;

      case SERVER_EVENT_TYPES.RESPONSE_CREATED:
        this.log(`Response created: response_id=${event.response?.id}, status=${event.response?.status}`);
        this.isResponseInProgress = true;
        this.currentResponseId = event.response?.id || null;
        // metadataを保存（response.createで指定したものがここに返ってくる）
        this.currentResponseMetadata = event.response?.metadata || null;
        if (this.currentResponseMetadata) {
          this.log(`Response metadata: ${JSON.stringify(this.currentResponseMetadata)}`);
        }
        // statusがin_progressの場合、コールバックを呼び出す
        if (event.response?.status === "in_progress" && this.callbacks.onResponseCreated) {
          this.callbacks.onResponseCreated(event.response);
        }
        break;

      case SERVER_EVENT_TYPES.RESPONSE_DONE:
        this.log(`Response done: response_id=${event.response?.id}, status=${event.response?.status}`);
        this.isResponseInProgress = false;
        this.currentResponseId = null;

        // PR #601: response が完了する時点で _streamingElements に積まれっぱなしの
        // 最後の item を必ず finalize する。gpt-realtime-2 系では RESPONSE_OUTPUT_TEXT_DONE
        // が item ごとに発火しないことがあるため、RESPONSE_DONE をフェイルセーフとして
        // 採用する (status が cancelled/failed のときも timestamp 付きで bubble を確定して
        // ユーザに「中断時点までの内容」を見せる)。
        if (this.currentResponseMessageId && this.callbacks.onTextResponse) {
          this.log(`RESPONSE_DONE: dispatching finalize for the leftover item: ${this.currentResponseMessageId}`);
          this.callbacks.onTextResponse(
            this.currentResponseText,
            false,
            this.currentResponseMessageId,
            this.currentResponseMetadata,
          );
        }

        // Issue #526 再発対応: RESPONSE_OUTPUT_TEXT_DONE / RESPONSE_OUTPUT_AUDIO_TRANSCRIPT_DONE は
        // 必ずしも常に発火しない (e.g. status='cancelled' / status='failed' でユーザー割込時)。
        // それらに頼って text 状態を reset する旧設計だと、次のレスポンスで `currentResponseText`
        // / `currentResponseMessageId` が前ターンの値を保持したまま新しい delta が乗り、
        // widget 側で bubble overwrite 表示の原因になる。
        // RESPONSE_DONE で必ず reset する defense-in-depth として追加。
        this.currentResponseText = "";
        this.currentResponseItemId = null;
        this.currentResponseMessageId = null;

        // キャンセル待ちのPromiseを解決（キャンセル対象のresponse_idと一致する場合のみ）
        if (this._cancelResolve && (!this._cancellingResponseId || this._cancellingResponseId === event.response?.id)) {
          this._cancelResolve();
        }

        // レスポンスが失敗した場合のエラーハンドリング
        if (event.response?.status === "failed") {
          const errorDetails = event.response?.status_details?.error || {};
          const errorType = errorDetails.type || "unknown";
          const errorCode = errorDetails.code || "unknown";
          const errorMessage = errorDetails.message || t("err_unknown", "An unknown error occurred");

          // error.type に基づくカテゴリ分類とユーザー向けメッセージの取得
          const classified = classifyResponseError({ errorType, errorCode, errorMessage });

          this.log(`Response failed: type=${errorType}, code=${errorCode}, category=${classified.category}, message=${errorMessage}`);

          // エラーログDBに登録
          const error = new Error(`OpenAI Realtime API response failed: [${errorType}] ${errorCode} - ${errorMessage}`);
          error.name = "RealtimeResponseError";
          captureException(error, "handleResponseDone", {
            severity: "error",
            errorType,
            errorCode,
            errorCategory: classified.category,
            responseId: event.response?.id,
            errorMessage
          });

          // コールバックでウィジェットに通知
          if (this.callbacks.onResponseFailed) {
            this.callbacks.onResponseFailed({
              responseId: event.response?.id,
              errorType,
              errorCode,
              errorMessage,
              category: classified.category,
              userMessage: classified.userMessage,
            });
          }
        }

        // usage データをウィジェットに通知（成功・キャンセル済みレスポンスのみ）
        if (event.response?.status !== "failed" && this.callbacks.onResponseDone) {
          this.callbacks.onResponseDone(event.response?.usage ?? null);
        }
        break;

      case SERVER_EVENT_TYPES.RESPONSE_OUTPUT_TEXT_DELTA: {
        if (!event.delta) break;
        // 一意な itemId が無いと messageId がターン跨ぎで衝突するので skip。
        const itemId = event.item_id || event.response_id;
        if (!itemId) {
          console.warn("[RealtimeConnector] RESPONSE_OUTPUT_TEXT_DELTA missing item_id/response_id; skipping");
          break;
        }
        this.log(`Delta received - item_id: ${itemId}, currentItemId: ${this.currentResponseItemId}, messageId: ${this.currentResponseMessageId}`);
        if (!this.currentResponseMessageId || this.currentResponseItemId !== itemId) {
          // PR #601: gpt-realtime-2 系は同一 response 内で複数 message item を出力する
          // (例: 「少しだけ確認して...」+ function_call 結果を踏まえた本回答)。Item 切替時に
          // 旧 Item の `response.output_text.done` が必ずしも先行発火しないため、
          // 旧 Item を _streamingElements に orphan として残さないよう、ここで明示的に
          // finalize ディスパッチする (defense-in-depth)。
          if (this.currentResponseMessageId && this.callbacks.onTextResponse) {
            this.log(`Item switch: dispatching finalize for the previous item: ${this.currentResponseMessageId}`);
            this.callbacks.onTextResponse(
              this.currentResponseText,
              false,
              this.currentResponseMessageId,
              this.currentResponseMetadata,
            );
          }
          this.currentResponseText = "";
          this.currentResponseItemId = itemId;
          this.currentResponseMessageId = `assistant-${itemId}`;
          this.log(`New response started - messageId: ${this.currentResponseMessageId}`);
        }
        this.currentResponseText += event.delta;
        if (this.callbacks.onTextDelta) {
          this.callbacks.onTextDelta(event.delta, itemId);
        }
        if (this.callbacks.onTextResponse) {
          this.callbacks.onTextResponse(this.currentResponseText, true, this.currentResponseMessageId);
        }
        break;
      }

      case SERVER_EVENT_TYPES.RESPONSE_OUTPUT_TEXT_DONE: {
        // PR #601: event 自身の item_id / text を優先する。currentResponse* 系の内部
        // state は item 切替や RESPONSE_DONE 後の遅延到着で event と乖離することがあり、
        // その場合に widget へ「messageId=null + text=空」をディスパッチして空 bubble を
        // 生み出していた。event の値を SSOT として扱うことで gpt-realtime-2 系の
        // 多 item / 遅延 done どちらにも頑健に対応する。
        //
        // PR #601 v3 AI Review (low): item_id 不在時は currentResponseMessageId に
        // フォールバックする (response_id は item レベルの体系と異なり、
        // `assistant-resp_xxx` のような messageId は既存 streaming element と一致せず、
        // widget 側で意図しない新規 bubble を作る危険があるため response_id へは倒さない)。
        const itemId = event.item_id || null;
        const messageId = itemId ? `assistant-${itemId}` : this.currentResponseMessageId;
        // event.text は最終確定テキスト (Reference §22355)。delta 累積より信頼できる。
        const finalText =
          typeof event.text === "string"
            ? event.text
            : this.currentResponseText;
        if (this.callbacks.onTextResponse && messageId) {
          this.callbacks.onTextResponse(finalText, false, messageId, this.currentResponseMetadata);
        }
        // 内部 state は「現在追跡中の item」と一致するときのみリセット。
        // 別 item の遅延 done が混じった場合は state を壊さない。
        if (!itemId || this.currentResponseItemId === itemId) {
          this.currentResponseText = "";
          this.currentResponseItemId = null;
          this.currentResponseMessageId = null;
        }
        break;
      }

      case SERVER_EVENT_TYPES.RESPONSE_OUTPUT_AUDIO_TRANSCRIPT_DELTA: {
        if (!event.delta) break;
        const itemId = event.item_id || event.response_id;
        if (!itemId) {
          console.warn("[RealtimeConnector] RESPONSE_OUTPUT_AUDIO_TRANSCRIPT_DELTA missing item_id/response_id; skipping");
          break;
        }
        this.log(`Audio transcript delta - item_id: ${itemId}, currentItemId: ${this.currentResponseItemId}`);
        if (!this.currentResponseMessageId || this.currentResponseItemId !== itemId) {
          // PR #601 v2 AI Review (low): TEXT_DELTA に適用した orphan 防止 finalize を
          // AUDIO_TRANSCRIPT_DELTA にも横展開する (gpt-realtime-2 系の音声モードでも
          // 同じ multi-item 挙動が起こり得るため)。
          if (this.currentResponseMessageId && this.callbacks.onTextResponse) {
            this.log(`Audio transcript: item switch, dispatching finalize for the previous item: ${this.currentResponseMessageId}`);
            this.callbacks.onTextResponse(
              this.currentResponseText,
              false,
              this.currentResponseMessageId,
              this.currentResponseMetadata,
            );
          }
          this.currentResponseText = "";
          this.currentResponseItemId = itemId;
          this.currentResponseMessageId = `assistant-${itemId}`;
        }
        this.currentResponseText += event.delta;
        if (this.callbacks.onTextResponse) {
          this.callbacks.onTextResponse(this.currentResponseText, true, this.currentResponseMessageId);
        }
        break;
      }

      case SERVER_EVENT_TYPES.RESPONSE_OUTPUT_AUDIO_TRANSCRIPT_DONE: {
        // PR #601 v2 AI Review (low): TEXT_DONE に適用した SSOT パターンを横展開。
        // event.item_id / event.transcript (audio transcript の場合は transcript フィールド)
        // を優先使用し、内部 state と乖離した遅延到着でも正しい messageId で finalize する。
        //
        // PR #601 v3 AI Review (low): TEXT_DONE と同じく response_id フォールバックを除去
        // (response レベル ID は item レベルの体系と異なり、`assistant-resp_xxx` を作ると
        // 既存 streaming element と一致せず意図しない新規 bubble を作る危険があるため)。
        const itemId = event.item_id || null;
        const messageId = itemId ? `assistant-${itemId}` : this.currentResponseMessageId;
        const finalText =
          typeof event.transcript === "string"
            ? event.transcript
            : this.currentResponseText;
        if (this.callbacks.onTextResponse && messageId) {
          this.callbacks.onTextResponse(finalText, false, messageId, this.currentResponseMetadata);
        }
        if (!itemId || this.currentResponseItemId === itemId) {
          this.currentResponseText = "";
          this.currentResponseItemId = null;
          this.currentResponseMessageId = null;
        }
        break;
      }

      case SERVER_EVENT_TYPES.RESPONSE_FUNCTION_CALL_ARGUMENTS_DELTA:
        if (event.delta) {
          if (
            !this.currentFunctionCall ||
            this.currentFunctionCall.call_id !== event.call_id
          ) {
            this.currentFunctionCall = {
              call_id: event.call_id,
              name: event.name || "",
              arguments: "",
            };
          }
          this.currentFunctionCall.arguments += event.delta;
        }
        break;

      case SERVER_EVENT_TYPES.RESPONSE_FUNCTION_CALL_ARGUMENTS_DONE:
        if (this.currentFunctionCall) {
          this.currentFunctionCall.arguments =
            event.arguments || this.currentFunctionCall.arguments;
          this.currentFunctionCall.name =
            event.name || this.currentFunctionCall.name;

          this.log(`Function call: ${this.currentFunctionCall.name}`);

          if (this.callbacks.onFunctionCall) {
            this.callbacks.onFunctionCall({
              call_id: event.call_id,
              name: this.currentFunctionCall.name,
              arguments: this.currentFunctionCall.arguments,
            });
          }

          this.currentFunctionCall = null;
        }
        break;

      case SERVER_EVENT_TYPES.ERROR: {
        // 良性エラーコードは無視する:
        //  - response_cancel_not_active: レスポンスが既に完了している場合に発生
        // F7 は suspend 中の onError でセッションを全破棄する(UI 抑制ではない)。
        // 良性エラーを onError に流すと再利用中のセッションが毎回失われる。
        //
        // suspendSession() が送る input_audio_buffer.clear (A-3) が、確定済み
        // ターンが既に消費して空になったバッファに当たった場合のエラーコード名は
        // OpenAI Realtime API 上で確認が取れていない(input_audio_buffer_clear_empty /
        // input_audio_buffer_commit_empty はいずれも未確認・commit は本経路では
        // 送っていないため後者はそもそも起こり得ない)。コード名を推測してホワイト
        // リストに追加するのではなく、送信した clear イベントの event_id との相関で
        // 判定することで、サーバー側のコード名が何であっても suspend 起因のエラーを
        // 確実に無視する。
        const BENIGN_ERROR_CODES = new Set(["response_cancel_not_active"]);
        if (BENIGN_ERROR_CODES.has(event.error?.code)) {
          this.log(`Benign server error ignored: ${event.error.code}`);
          break;
        }
        if (event.error?.event_id && event.error.event_id === this._suspendClearEventId) {
          this.log(`Benign server error ignored (correlates with suspend clear event_id): ${event.error.event_id}`);
          break;
        }
        // dev.73: 音声未再生時の output_audio_buffer.clear へのエラーも同じ手法で
        // event_id 相関により良性判定する (バージイン/resume の clear は「鳴っていたら
        // 止める」意図なので、鳴っていなかった旨のエラーは無視してよい)。
        if (event.error?.event_id && this._outputClearEventIds.has(event.error.event_id)) {
          this._outputClearEventIds.delete(event.error.event_id);
          this.log(`Benign server error ignored (correlates with output audio clear event_id): ${event.error.event_id}`);
          break;
        }
        this.log(`Error: ${JSON.stringify(event.error)}`);
        if (this.callbacks.onError) {
          this.callbacks.onError(
            new Error(event.error?.message || "Unknown error"),
            event.error
          );
        }
        break;
      }

      default:
        break;
    }
  }

  sendEvent(event) {
    if (!this.dataChannel || this.dataChannel.readyState !== "open") {
      this.log("The DataChannel is not available");
      return false;
    }

    try {
      event.event_id = event.event_id || uuidv4();
      this.dataChannel.send(JSON.stringify(event));
      return true;
    } catch (error) {
      this.log(`Event send error: ${error.message}`);
      return false;
    }
  }

  /**
   * サーバー側の音声再生バッファを破棄する (WebRTC 専用の output_audio_buffer.clear)。
   *
   * 0.3.0-dev.73 (バージイン対応): suspend/resume はリモート音声を mute するだけで
   * サーバーからの音声送出は実時間で流れ続けるため、resume(unmute) すると
   * 「suspend 前の応答の残り音声が途中から鳴り、それが終わってから新しい応答が鳴る」
   * 幽霊音声が発生していた。音声だけを捨てる手段がこのイベントで、テキストの
   * delta には一切影響しない (dev.54 の「テキストは全文届く」契約はそのまま)。
   *
   * 音声未再生時に送るとサーバーがエラーを返しうるため、suspend の
   * input_audio_buffer.clear (A-3(b)) と同じく event_id を記録し、ERROR ハンドラで
   * コード名推測ではなく event_id 相関により良性判定して無視する。
   *
   * @returns {boolean} clear イベントが送信されたかどうか
   */
  _clearOutputAudioBuffer() {
    const clearEvent = { type: "output_audio_buffer.clear" };
    const sent = this.sendEvent(clearEvent);
    if (sent && clearEvent.event_id) {
      this._outputClearEventIds.add(clearEvent.event_id);
      // 長寿命セッションでの無制限成長を防ぐ (エラー相関は送信直後にしか起きない)
      while (this._outputClearEventIds.size > 20) {
        const oldest = this._outputClearEventIds.values().next().value;
        this._outputClearEventIds.delete(oldest);
      }
      this.log("Output audio buffer clear requested");
    }
    return sent;
  }

  /**
   * 進行中のレスポンスをキャンセルする
   * @returns {boolean} キャンセルイベントが送信されたかどうか
   */
  cancelCurrentResponse() {
    if (!this.isResponseInProgress) {
      return Promise.resolve(false);
    }

    const cancellingResponseId = this.currentResponseId;
    this.log(`Cancelling the in-flight response: response_id=${cancellingResponseId}`);
    const sent = this.sendEvent({ type: "response.cancel" });
    if (!sent) {
      return Promise.resolve(false);
    }

    // dev.73: OpenAI 推奨ペア — cancel は「生成」を止めるだけで、既にサーバー側
    // 出力バッファへ積まれた音声の送出は止まらない。clear を併送しないと
    // 「割り込んだのに旧応答の声が鳴り続ける」ままになる。
    this._clearOutputAudioBuffer();

    // サーバーの response.done を待つ（最大3秒でタイムアウト）
    return new Promise((resolve) => {
      const timeout = setTimeout(() => {
        this.log("Timed out waiting for the cancellation — continuing anyway");
        this.isResponseInProgress = false;
        this.currentResponseId = null;
        this._cancelResolve = null;
        this._cancellingResponseId = null;
        resolve(true);
      }, 3000);

      this._cancellingResponseId = cancellingResponseId;
      this._cancelResolve = () => {
        clearTimeout(timeout);
        this._cancelResolve = null;
        this._cancellingResponseId = null;
        resolve(true);
      };
    });
  }

  /**
   * 新しいレスポンスを作成する（進行中のレスポンスがあればキャンセル完了を待ってから作成）
   *
   * Issue #555: instructions を直接 sendEvent で送ると isResponseInProgress ガードを
   * バイパスしてしまい、サーバ VAD 自動応答と競合する。greeting 経路も含めて
   * 必ずこのメソッド経由にする。
   *
   * @param {Object} options - オプション設定
   * @param {Object} [options.metadata]     - レスポンスに付与するメタデータ
   * @param {string} [options.instructions] - このレスポンス専用の指示文（session の instructions を上書き）
   * @param {"auto"|"none"|{type:"function",name:string}} [options.tool_choice]
   *   - このレスポンス限定の tool_choice。greeting で "none" を指定すると
   *     構造的に tool 呼び出しを抑止できる (= GET_SYSTEM_DATE 等の一次回答を防ぐ)。
   *   - 注: Chat Completions API の "required" は Realtime API での挙動が
   *     確認できていないため JSDoc 型から除外。必要なら関数指定 ({type,name}) で代替する。
   * @returns {Promise<boolean>} レスポンス作成イベントが送信されたかどうか
   */
  async createResponse(options = {}) {
    if (this.isResponseInProgress) {
      this.log("An in-flight response exists; waiting for the cancellation to complete");
      await this.cancelCurrentResponse();
    }

    const event = { type: "response.create" };
    const responseObj = {};
    if (options.metadata) responseObj.metadata = options.metadata;
    if (options.instructions) responseObj.instructions = options.instructions;
    if (options.tool_choice !== undefined && options.tool_choice !== null) {
      responseObj.tool_choice = options.tool_choice;
    }

    if (Object.keys(responseObj).length > 0) {
      event.response = responseObj;
      const summary = [
        options.instructions && "instructions",
        options.metadata && `metadata=${JSON.stringify(options.metadata)}`,
        options.tool_choice !== undefined && options.tool_choice !== null
          && `tool_choice=${typeof options.tool_choice === "string" ? options.tool_choice : JSON.stringify(options.tool_choice)}`,
      ].filter(Boolean).join("+");
      this.log(`Response created (with ${summary})`);
    }

    return this.sendEvent(event);
  }

  /**
   * @param {string} text
   * @param {Object} [options]
   * @param {boolean} [options.skipTranscript]
   * @param {Object} [options.metadata]
   * @param {"auto"|"none"|{type:"function",name:string}} [options.tool_choice]
   * @param {string} [options.instructions] - このメッセージへの応答専用の指示文
   *   (createResponse の options.instructions と同じ意味。呼び出し側が
   *   自前で conversation.item.create + response.create を分けて叩かず、
   *   1 回の sendTextMessage 呼び出しで済ませられるようにする pass-through)
   */
  async sendTextMessage(text, options = {}) {
    const { skipTranscript = false, metadata = null, tool_choice = null, instructions = null } = options;

    if (!text || !text.trim()) {
      return false;
    }

    // テキスト送信中フラグ（CONVERSATION_ITEM_ADDEDでitem_idを捕捉するため）
    this._pendingTextSend = true;

    const conversationEvent = {
      type: "conversation.item.create",
      item: {
        type: "message",
        role: "user",
        content: [
          {
            type: "input_text",
            text: text.trim(),
          },
        ],
      },
    };

    const sent = this.sendEvent(conversationEvent);
    if (sent) {
      // dev.73 バージイン: ユーザーが新しいメッセージを送った時点で、再生中の旧応答
      // 音声を無条件で打ち切る。response.done は音声再生完了より先に届くため、
      // 「生成は完了済みだが音声はまだ鳴っている」ケースでは下の createResponse の
      // cancel 分岐 (isResponseInProgress) が空振りし、clear だけが唯一の打ち切り手段。
      // 生成中に割り込んだケースは createResponse → cancelCurrentResponse が
      // cancel+clear をペアで送るので、どちらの経路でも音声は即止まる。
      // 注: この clear は sendTextMessage (ユーザー起点) に置く。createResponse に
      // 置くと sendFunctionCallResponse (ツール継続) 経由でツール呼び出し応答
      // 自身の発話まで切ってしまう。
      this._clearOutputAudioBuffer();
      // metadata / tool_choice が指定されている場合は createResponse に渡す
      const responseOptions = {};
      if (metadata) responseOptions.metadata = metadata;
      if (tool_choice !== null && tool_choice !== undefined) {
        responseOptions.tool_choice = tool_choice;
      }
      if (instructions) responseOptions.instructions = instructions;
      await this.createResponse(responseOptions);
      if (!skipTranscript && this.callbacks.onTranscript) {
        this.callbacks.onTranscript(text.trim(), "user");
      }
    }

    return sent;
  }

  async sendFunctionCallResponse(callId, output) {
    const outputEvent = {
      type: "conversation.item.create",
      item: {
        type: "function_call_output",
        call_id: callId,
        output: typeof output === "string" ? output : JSON.stringify(output),
      },
    };

    const sent = this.sendEvent(outputEvent);
    if (sent) {
      this.log(`Function response sent for call_id: ${callId}`);

      // gpt-realtime-2 系互換: tool 呼び出し中も同一 response が active のまま継続する設計の
      // モデル (Reference §4550-4566: "Only one Response can write to the default Conversation
      // at a time") に対して、function_call_output 送信後に明示的に response.create を発火
      // すると "Conversation already has an active response in progress" エラーになる。
      //
      // 旧モデル (gpt-realtime / gpt-realtime-1.5 等): function_call 完了で response.done が
      // 先に発火 → isResponseInProgress=false → 明示的な response.create が必要
      // 新モデル (gpt-realtime-2 系): tool 結果受領まで response.done を保留 →
      // isResponseInProgress=true のまま → server が auto-continue するので発火しない
      //
      // ガード: isResponseInProgress を確認してから判断する。
      // - true  → server auto-continue 待ち (new model)
      // - false → 旧モデル経路。Issue #558 の await + ガード経由で createResponse 発火
      //
      // 呼び出し側 (main.mjs / widget-app.mjs / widget-app-persistent.js) は
      // この関数を await しない fire-and-forget で使うため、rejection は内部で握り潰す。
      if (this.isResponseInProgress) {
        this.log("An existing response is active; waiting for the server-side auto-continue (gpt-realtime-2 family)");
      } else {
        try {
          await this.createResponse();
        } catch (e) {
          this.log(`createResponse inside sendFunctionCallResponse failed: ${e?.message ?? String(e)}`);
        }
      }
    }

    return sent;
  }

  /**
   * セッション設定を更新する (ツール定義とモダリティのみ — §3.2 の重要な整合点)。
   *
   * NS では `/realtime-token` が instructions/voice/turn_detection/transcription を
   * サーバー側で確定するため、このメソッドは意図的に `instructions` を一切
   * 送信しない (二重指定でサーバ確定値を上書きしてしまうため)。turn_detection の
   * 動的切替は別経路の `sendAudioConfigUpdate()` が担当する。
   *
   * @param {Object} [config] - 更新設定 (現状は将来のツール定義追加 (P2) 用に予約)
   * @returns {boolean} イベント送信が成功したかどうか
   */
  updateSession(config = {}) {
    const sessionPayload = { type: "realtime" };

    if (this.sessionConfig.output_modalities) {
      sessionPayload.output_modalities = this.sessionConfig.output_modalities;
    }
    const tools = config.tools || this.sessionConfig.tools;
    if (tools && tools.length > 0) {
      sessionPayload.tools = tools;
    }

    const updateEvent = {
      type: "session.update",
      session: sessionPayload,
    };

    return this.sendEvent(updateEvent);
  }

  /**
   * sessionConfig.audio.input.turn_detection.create_response を切替える。
   * session.update は送信しない — 反映するには updateSession() か
   * sendAudioConfigUpdate() を呼ぶこと。
   *
   * Issue #558: 接続直後は false にして VAD 自動 response.create を抑制し、
   * greeting 完了後に true に戻す運用。
   *
   * @param {boolean} enabled
   * @returns {boolean} 切替対象 (turn_detection) が存在し変更したかどうか
   */
  setAutoResponseFlag(enabled) {
    const turnDetection = this.sessionConfig?.audio?.input?.turn_detection;
    if (!turnDetection) return false;
    turnDetection.create_response = enabled;
    this.log(`turn_detection.create_response = ${enabled}`);
    return true;
  }

  /**
   * 次の SESSION_UPDATED イベントを await する。
   * 並列に複数呼び出された場合は FIFO で全 waiter が次の SESSION_UPDATED で発火する。
   *
   * @param {number} [timeoutMs=2000]
   * @returns {Promise<boolean>} ack 受信なら true、タイムアウトなら false
   */
  waitForSessionUpdated(timeoutMs = 2000) {
    return new Promise((resolve) => {
      let settled = false;
      let resolver;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        const idx = this._sessionUpdatedResolvers.indexOf(resolver);
        if (idx >= 0) this._sessionUpdatedResolvers.splice(idx, 1);
        this.log("waitForSessionUpdated: timed out");
        resolve(false);
      }, timeoutMs);
      resolver = () => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve(true);
      };
      this._sessionUpdatedResolvers.push(resolver);
    });
  }

  /**
   * sessionConfig.audio のみを session.update で再送する。
   * instructions/tools 等の他フィールドには触れない (空文字 instructions による
   * システムプロンプト消失を防ぐ)。Issue #558 で greeting 後の turn_detection 切替に使用。
   *
   * @param {number} [timeoutMs=2000] - SESSION_UPDATED 待ちタイムアウト
   * @returns {Promise<boolean>} ack 受信なら true、タイムアウト or audio 未設定なら false
   */
  async sendAudioConfigUpdate(timeoutMs = 2000) {
    if (!this.sessionConfig?.audio) return false;
    const ackPromise = this.waitForSessionUpdated(timeoutMs);
    const sent = this.sendEvent({
      type: "session.update",
      session: { type: "realtime", audio: this.sessionConfig.audio },
    });
    if (!sent) return false;
    return ackPromise;
  }

  /**
   * DataChannelがopenになるまで待機する
   * @param {number} timeoutMs - タイムアウト時間（ミリ秒、デフォルト5000ms）
   * @returns {Promise<boolean>} DataChannelが開いた場合true、タイムアウトの場合false
   */
  async waitForDataChannel(timeoutMs = 5000) {
    // 既にopenの場合は即座に返す
    if (this.dataChannel && this.dataChannel.readyState === "open") {
      return true;
    }

    if (!this._dataChannelReady) {
      this.log("The DataChannel-ready promise is not initialized");
      return false;
    }

    try {
      await Promise.race([
        this._dataChannelReady,
        new Promise((_, reject) =>
          setTimeout(() => reject(new Error("DataChannel open timeout")), timeoutMs)
        ),
      ]);
      return true;
    } catch (error) {
      this.log(`Timed out waiting for the DataChannel: ${error.message}`);
      return false;
    }
  }

  /**
   * セッション設定を保存する（トークン取得時の設定を保持）
   * @param {Object} config - セッション設定
   */
  setSessionConfig(config) {
    if (config.output_modalities) {
      this.sessionConfig.output_modalities = config.output_modalities;
    }
    if (config.tools) {
      this.sessionConfig.tools = config.tools;
    }
    if (config.audio) {
      this.sessionConfig.audio = config.audio;
    }
    this.log(`Session config saved: output_modalities=${JSON.stringify(this.sessionConfig.output_modalities)}, tools=${this.sessionConfig.tools.length}`);
  }

  /**
   * 確立済みセッションを「一時停止」する。text↔voice 高速切替でセッション
   * (トークン発行→SDP交換→ICE確立) を毎回作り直すと realtime_limit_per_min /
   * OpenAI 側レート制限に抵触するため、テキストモードに切り替えてもセッションは
   * 裏で生かしたまま、マイクだけ物理的に止める。
   *
   * 維持するもの: RTCPeerConnection / DataChannel / リモート音声再生要素
   *   （破棄すると resumeSession() で再利用できず、結局トークン再発行が必要になるため）
   * 止めるもの: マイクの MediaStreamTrack（物理停止 — ブラウザの録音インジケーターを
   *   確実に消すため。enabled=false によるミュートでは録音インジケーターが点いたままになり、
   *   「音声を終えた」と認識している利用者に誤解を与える）
   * ミュートするもの: リモート音声（AI発話）の再生。要素自体は破棄せず
   *   resumeSession() 側で unmute して再利用する。
   *
   * F1 fix (実機調査): 以前はここで応答生成中に response.cancel を送っていたが、
   * 実機検証の結果、DataChannel は suspend 中も生存しており、テキスト応答の
   * 描画（handleRealtimeTextResponse）は mode を見ずに動くため、cancel を送ると
   * 「音声モードを終えた瞬間に AI の返答が途中で切れる」という利用者可視のバグの
   * 直接原因になっていた。生成そのものは止めず、リモート音声の mute だけで
   * 「音は出さないがテキストは最後まで届く」を実現する。
   *
   * @returns {boolean} suspend できた場合 true。未接続時は何もせず false。
   */
  suspendSession() {
    if (!this.isConnected || !this.peerConnection) {
      this.log("[suspend] No active session to suspend - ignoring");
      return false;
    }
    if (this._suspended) {
      this.log("[suspend] Already suspended - ignoring");
      return true;
    }

    // マイクを物理停止（track.stop() で録音インジケーターを消す。sender / PeerConnection /
    // DataChannel には触れない — resumeSession() で replaceTrack() して再利用するため）。
    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach((track) => track.stop());
      this.mediaStream = null;
    }

    // 未確定(未コミット)の入力音声を破棄する。これを残すとサーバー VAD が
    // suspend 後に turn を確定させ「新しい応答」が生成される。確定済みターンの
    // 応答には影響しない(input バッファのみを消す)。
    // event_id を記録しておき、ERROR ハンドラでこの clear に由来するエラーを
    // コード名推測ではなく event_id 相関で確実に無視できるようにする(A-3(b) レビュー修正)。
    const clearEvent = { type: "input_audio_buffer.clear" };
    this.sendEvent(clearEvent);
    this._suspendClearEventId = clearEvent.event_id || null;

    // リモート音声（AIの声）をミュート。要素・GainNode・AudioContextは破棄しない。
    this.setRemoteAudioMuted(true);

    this._suspended = true;
    this.log("[suspend] Session suspended (microphone stopped, remote audio muted)");
    return true;
  }

  /**
   * suspendSession() で止めたセッションを再開する。新しいマイクを取得し、
   * 既存の audio sender の track を replaceTrack() で差し替えるだけで、
   * トークン再発行・SDP再交換・ICE再確立を一切発生させない。
   *
   * @param {string} [deviceId] - 使用するマイクデバイスID（省略時は既定デバイス）
   * @param {Object} [options]
   * @param {boolean} [options.remoteMuted] - resume 後もリモート音声(AI発話)を
   *   ミュートしたままにするか(スピーカーミュート中に resume した場合の意図保持)。
   * @param {boolean} [options.micMuted] - resume で取得した新しいマイクトラックを
   *   最初から enabled=false にするか(マイクミュート中に resume した場合の意図保持)。
   * @returns {Promise<boolean>} 再開できた場合 true。false の場合、呼び出し元は
   *   従来の完全再接続（disconnect() → connect()）にフォールバックすること。
   *   このメソッド自体は throw しない。
   */
  async resumeSession(deviceId = null, { remoteMuted = false, micMuted = false } = {}) {
    if (!this._suspended) {
      this.log("[resume] resumeSession called while not suspended - ignoring");
      return false;
    }
    if (!this.peerConnection) {
      this.log("[resume] No PeerConnection to resume onto");
      return false;
    }
    const pcState = this.peerConnection.connectionState;
    if (pcState === "closed" || pcState === "failed") {
      this.log(`[resume] PeerConnection is not usable (state=${pcState})`);
      return false;
    }

    let newStream;
    try {
      // 既存の getUserMedia constraints の流儀（mono/16kHz/echoCancellation等）に合わせる
      newStream = await this.getMediaStream(deviceId);
    } catch (error) {
      this.log(`[resume] Failed to reacquire the microphone: ${error.message}`);
      return false;
    }

    const newTrack = newStream.getAudioTracks()[0];
    if (!newTrack) {
      newStream.getTracks().forEach((track) => track.stop());
      this.log("[resume] No audio track in the reacquired stream");
      return false;
    }

    // dev.56 A-6: getUserMedia() の await 中にセッションが破棄された(disconnect /
    // 別の switchToTextMode 全破棄フォールバック等)race を検知する。ここで
    // 再検査せずに進むと、既に破棄済みの PeerConnection に対して
    // replaceTrack() を試みてしまう。
    if (!this._suspended || !this.peerConnection ||
        this.peerConnection.connectionState === "closed" || this.peerConnection.connectionState === "failed") {
      newStream.getTracks().forEach((track) => track.stop());
      this.log("[resume] session was torn down during getUserMedia — releasing the new microphone");
      return false;
    }

    // dev.56 A-6: getSenders()〜replaceTrack を try/catch で包む(元々
    // replaceTrack だけが catch されており、sender 特定自体が失敗する
    // 想定外のケースを取りこぼしていた)。「throw しない」契約を守るため、
    // catch でも必ず newStream のトラックを stop してから false を返す。
    try {
      // suspendSession() で track.stop() した後も、RTCRtpSender オブジェクト自体と
      // sender.track（ended状態のまま）への参照は残っている。それを頼りに
      // audio 用の sender を特定する（renegotiation不要のreplaceTrack経路のため）。
      const audioSender = this.peerConnection
        .getSenders()
        .find((sender) => sender.track && sender.track.kind === "audio");
      if (!audioSender) {
        newStream.getTracks().forEach((track) => track.stop());
        this.log("[resume] No existing audio sender found on the PeerConnection - cannot resume without renegotiation");
        return false;
      }
      await audioSender.replaceTrack(newTrack);
    } catch (error) {
      newStream.getTracks().forEach((track) => track.stop());
      this.log(`[resume] replaceTrack failed: ${error.message}`);
      return false;
    }

    // dev.56 A-4: マイクミュート中に resume した場合、新しいトラックも
    // 最初から enabled=false にして「マイクミュート」の意図を保持する。
    // dev.61: この行は意図的に「micMuted が true のときだけ false にする」形の
    // ままにしてある — newTrack は getMediaStream() (2436行) 経由で取得されて
    // おり、_backgroundMuted が true ならその choke point で既に enabled=false
    // 済み。ここを `newTrack.enabled = !micMuted;` に書き換えると、
    // micMuted=false のケースで背景ミュートを無条件に踏み潰して復元してしまう
    // (tests/test-suspend-integrity.js C5 が固定)。
    if (micMuted) newTrack.enabled = false;

    this.mediaStream = newStream;
    // dev.73: unmute より先にサーバー側の音声再生バッファを破棄する。suspend は
    // 音声を mute するだけで送出は実時間で流れ続けるため、これを送らずに unmute
    // すると suspend 前の応答の残り音声が「途中から」鳴り出す (幽霊音声)。
    // テキストは suspend 中に全文届いているので影響しない。
    this._clearOutputAudioBuffer();
    // dev.56 A-4: suspend 時にミュートしたリモート音声を、呼び出し元が
    // 指定した意図(remoteMuted — 例: スピーカーミュート中の resume)へ戻す。
    this.setRemoteAudioMuted(!!remoteMuted);
    this._suspended = false;
    this.log("[resume] Session resumed (microphone reacquired, remote audio unmuted)");
    return true;
  }

  /**
   * @returns {boolean} suspendSession() 済みかどうか
   */
  isSuspended() {
    return this._suspended;
  }

  disconnect() {
    // 正常切断フラグ（oncloseでエラー通知を抑制するため）
    this._intentionalDisconnect = true;

    // suspend 中でも通常のdisconnectと同様に全破棄する（mediaStreamがnullでも安全に
    // 動作するよう、以降の各クリーンアップは既存どおり null チェック済み）。
    this._suspended = false;
    // dev.56 レビュー修正(blocker): _remoteMuted はセッション跨ぎの状態なので、ここで
    // リセットしないと次の音声セッションの audio 要素が muted=true で生まれ、全編
    // 無音になる(suspend(_remoteMuted=true) → 60秒タイマーで全破棄 → 次の冷間接続、で発現)。
    this._remoteMuted = false;
    // suspend 時に送った input_audio_buffer.clear の event_id 相関もセッション跨ぎで
    // 無効(次セッションのエラーを誤って良性判定してしまう)なのでリセットする。
    this._suspendClearEventId = null;
    // dev.73: output_audio_buffer.clear の event_id 相関も同じ理由でリセットする。
    this._outputClearEventIds.clear();
    // dev.61: _backgroundMuted は意図的にここでリセット**しない**(_remoteMuted と
    // 意図的に逆)。これはセッションの状態ではなくタブの状態であり、hidden のまま
    // 自動再接続(disconnect→connect)が走った場合に新セッションが背景ミュートを
    // 素通しにしてしまうのを防ぐ。解除するのは widget-app-persistent.js の
    // _onTabVisible() だけ(tests/test-suspend-integrity.js C6 が固定)。

    // セッションログに終了を記録（非同期、エラーは無視）
    if (sessionLogger.isCreated()) {
      sessionLogger.endSession().catch(() => {});
    }

    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach((track) => track.stop());
      this.mediaStream = null;
    }

    // disconnectタイマーのクリーンアップ
    if (this._disconnectTimeout) {
      clearTimeout(this._disconnectTimeout);
      this._disconnectTimeout = null;
    }

    // RTCDataChannel.close() / RTCPeerConnection.close() は非同期で
    // onclose / connectionstatechange("closed") を発火する。
    // text↔voice 高速切替時、旧 session の close イベントが新 session 接続後に届くと
    // widget 側 cleanupRealtimeSession() が currentSessionId / sentItemIds を clobber し、
    // 「音声接続中」表示のまま新セッションの状態が壊れる現象が起きる。
    // 旧ハンドラを物理的に切り離して新 session への漏れを防ぐ。
    //
    // _dcEventAbort は addEventListener("message", ...) を剥がすための signal で、
    // dc.close() 直前に積まれた in-flight メッセージが close 後の new session で
    // handleMessage() を呼び transcript / session 状態を汚染する race を防ぐ。
    if (this._dcEventAbort) {
      this._dcEventAbort.abort();
      this._dcEventAbort = null;
    }

    if (this.dataChannel) {
      this.dataChannel.onopen = null;
      this.dataChannel.onclose = null;
      this.dataChannel.onerror = null;
      this.dataChannel.close();
      this.dataChannel = null;
    }

    // setupPeerConnection() で addEventListener 登録した
    // iceconnectionstatechange / connectionstatechange を AbortController で剥がす。
    // dataChannel と同じ理由 (高速切替時の stale callback 漏れ防止)。
    if (this._pcEventAbort) {
      this._pcEventAbort.abort();
      this._pcEventAbort = null;
    }

    if (this.peerConnection) {
      // peerConnection は addEventListener で connectionstatechange / iceconnectionstatechange を
      // 登録しているため、上書き不可。connect() で都度作り直す前提で、close 前に
      // インスタンス側コールバックブリッジを無効化する。
      this.peerConnection.onicecandidate = null;
      this.peerConnection.onicegatheringstatechange = null;
      this.peerConnection.ontrack = null;
      this.peerConnection.close();
      this.peerConnection = null;
    }

    if (this.audioElement) {
      this.audioElement.srcObject = null;
      // DOMから削除
      if (this.audioElement.parentNode) {
        this.audioElement.parentNode.removeChild(this.audioElement);
      }
      this.audioElement = null;
    }

    // iOS Safari用AudioContextのクリーンアップ
    if (this.remoteAudioSource) {
      try {
        this.remoteAudioSource.disconnect();
      } catch (e) {
        // 既に切断されている場合は無視
      }
      this.remoteAudioSource = null;
    }
    if (this.remoteGainNode) {
      try {
        this.remoteGainNode.disconnect();
      } catch (e) {
        // 既に切断されている場合は無視
      }
      this.remoteGainNode = null;
    }
    if (this.remoteAudioContext) {
      this.remoteAudioContext.close().catch(() => {});
      this.remoteAudioContext = null;
      this.remoteAudioUnlocked = false;
    }

    // iOS Safari用サイレントメディア要素のクリーンアップ
    this.cleanupSilentMediaElement();

    this.isConnected = false;
    this.currentResponseText = "";
    this.isResponseInProgress = false;
    this.currentResponseId = null;
    
    // メモリリーク対策: 文字起こしデータをクリア
    this.currentTranscription = {};
    this.currentResponseItemId = null;
    this.currentResponseMessageId = null;
    this.currentResponseMetadata = null;
    this.currentFunctionCall = null;
    
    this.log("Disconnected");
  }

  getConnectionState() {
    return this.peerConnection ? this.peerConnection.connectionState : "closed";
  }

  getIsConnected() {
    return this.isConnected;
  }
}
