// Star Chat Viewer フロントエンド
//
// - フレームワークなし。バニラ JS で完結。
// - WebSocket で新着 push を受け、DOM に追記。
// - タブ追加はフレンドタブの隣の "+" ボタンから。
// - バッジ色・表示形式は設定でカスタム可能。

// ─────────── トークン / URL ───────────
// X-3: iOS Safari は viewport の maximum-scale を無視することがあるため、
//   gesturestart/change/end をキャンセルしてピンチズームを抑止する (Web版のみ)。
if (!window.chrome?.webview) {
  ['gesturestart', 'gesturechange', 'gestureend'].forEach(t => {
    document.addEventListener(t, e => e.preventDefault(), { passive: false });
  });
}

// M-1: DOM構築直後、fetchが始まる前に is-web クラスを付与する。
//   boot が 401 等で失敗した場合でも、豆腐タイトルバーやセーフエリア未適用の
//   デスクトップUIが露出するのを防ぐ。
if (!window.chrome?.webview) {
  document.documentElement.classList.add('is-web-html');
  document.body.classList.add('is-web');
}

const pageUrl = new URL(window.location.href);
const TOKEN = pageUrl.searchParams.get('token') || localStorage.getItem('scv_token') || '';
// U-1: URLからのtoken除去を廃止 (LAN限定運用のため、Safariに貼るとtokenが無くて401になる問題を解消)。
//     localStorageへの保存は継続 (tokenなしURLで開いた場合の救済用)。
if (pageUrl.searchParams.get('token')) {
  localStorage.setItem('scv_token', TOKEN);
}
const auth = TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : '';
function withAuth(path, extra) {
  const sep = path.includes('?') ? '&' : '?';
  const tok = TOKEN ? `${sep}token=${encodeURIComponent(TOKEN)}` : '';
  const ex  = extra ? (tok ? `&${extra}` : `${sep}${extra}`) : '';
  return path + tok + ex;
}

const CHANNEL_META = {
  1: { key: 'world',   label: 'ワールド' },
  2: { key: 'channel', label: 'チャンネル' },
  3: { key: 'team',    label: 'パーティ' },
  4: { key: 'guild',   label: 'ギルド' },
  5: { key: 'friend',  label: 'フレンド' },
};

const DEFAULT_COLORS = {
  1: '#8b5cf6',
  2: '#6b7280',
  3: '#1f4a86',
  4: '#1e6b39',
  5: '#ec4899',
};

const state = {
  settings: null,
  builtinTabIds: new Set(['all', 'world', 'channel', 'team', 'guild', 'friend']),
  activeTabId: 'all',
  messages: [],
  seenIds: new Set(),   // WS 再接続時の重複排除用 (指示書 B-1)
  lastSeenId: 0,        // これまでに受信した最大 ID (差分取得用)
  maxKeep: 3000,
  loadingHistory: false,      // N-1: 過去ログ読込中フラグ
  noMoreHistory: false,       // N-1: これ以上遡れない
  pinnedIds: new Set(),       // N-6: ピン留め済みメッセージID
  autoScroll: true,
  newSinceScroll: 0,
  senderFilter: null,
  textFilter: '',
  editingTabId: null,   // モーダルで編集中のタブID (null なら新規追加)
  // 未読管理
  unreadByTab: {},      // { tabId: count }
  active: true,         // ウィンドウがアクティブか (blur/deactivate で false)
  unreadDividerAfterId: null, // 非アクティブになった時点の末尾メッセージID
};

// ─────────── ユーティリティ ───────────
function el(tag, attrs = {}, children = []) {
  const e = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs)) {
    if (k === 'class') e.className = v;
    else if (k === 'style') e.setAttribute('style', v);
    else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
    else e.setAttribute(k, v);
  }
  for (const c of (Array.isArray(children) ? children : [children])) {
    if (c == null) continue;
    if (typeof c === 'string') e.appendChild(document.createTextNode(c));
    else e.appendChild(c);
  }
  return e;
}
function fmtTime(unix) {
  const d = new Date(unix * 1000);
  return d.toLocaleTimeString('ja-JP', { hour12: false });
}
function toDateInput(dateStr) {
  if (!dateStr) return null;
  // 'YYYY-MM-DD' 単体は UTC 深夜として解釈される (JST では前日9時ずれ)。
  // ローカル時刻の 00:00:00 として扱わせるため 'T00:00:00' を付ける (指示書 B-3)。
  return Math.floor(new Date(dateStr + 'T00:00:00').getTime() / 1000);
}
function $(id) { return document.getElementById(id); }
function channelColor(ch) {
  const s = state.settings?.channelColors;
  if (s && s[ch]) return s[ch];
  return DEFAULT_COLORS[ch] || '#666';
}

// ─────────── 起動 ───────────
async function boot() {
  try {
    // M-1: 401 (トークン未渡し) を親切表示する
    const [statusRes, settingsRes] = await Promise.all([
      fetch(withAuth('/api/status')),
      fetch(withAuth('/api/settings')),
    ]);
    if (statusRes.status === 401 || settingsRes.status === 401) {
      showAuthRecovery();
      return;
    }
    if (!statusRes.ok || !settingsRes.ok) {
      showBootError(
        '接続失敗',
        `サーバーに届きません (PC で BPSR Chat Viewer が起動しているか確認してください)。\nHTTP ${statusRes.status}/${settingsRes.status}`,
        true);
      return;
    }
    const [status, settings] = [await statusRes.json(), await settingsRes.json()];
    state.settings = settings;
    state.osDarkMode = !!status?.osDarkMode;    // V-5
    // タブが空 / null なら組込デフォルトを流し込む (旧設定ファイルからの復旧用)
    if (!Array.isArray(state.settings.tabs) || state.settings.tabs.length === 0) {
      state.settings.tabs = [
        { id: 'all',     name: 'すべて',     channels: [1, 2, 3, 4, 5] },
        { id: 'world',   name: 'ワールド',   channels: [1] },
        { id: 'channel', name: 'チャンネル', channels: [2] },
        { id: 'team',    name: 'パーティ',   channels: [3] },
        { id: 'guild',   name: 'ギルド',     channels: [4] },
        { id: 'friend',  name: 'フレンド',   channels: [5] },
      ];
      // サーバー側にも書き戻す
      saveSetting({ tabs: state.settings.tabs });
    }
    // 各タブに name がない (旧スキーマ) 場合はIDから復元
    for (const t of state.settings.tabs) {
      if (!t.name) t.name = ({all:'すべて', world:'ワールド', channel:'チャンネル', area:'チャンネル', team:'パーティ', guild:'ギルド', friend:'フレンド', private:'フレンド'})[t.id] ?? t.id ?? '(無題)';
    }
    if (Array.isArray(settings.builtInTabIds)) state.builtinTabIds = new Set(settings.builtInTabIds);
    applyDisplaySettings();
    reflectToggleButtons();
    renderTabs();
    updateStatusBar(status);
    // N-6: ピン ID 集合を取得
    try {
      const pins = await fetch(withAuth('/api/pins')).then(r => r.json());
      state.pinnedIds = new Set((pins || []).map(p => p.id));
    } catch {}
    setupWebSocket();
    setInterval(refreshStatus, 3000);
  } catch (e) {
    showBootError('接続失敗', `サーバーに届きません: ${e.message}`, true);
    return;
  }
  bindEvents();
}

// P-1: 認証失敗時のスマホ完結復帰 UI (6桁ペアリングコード入力)
function showAuthRecovery() {
  const overlay = document.createElement('div');
  overlay.style.cssText = 'position:fixed;inset:0;background:#151821;color:#e4e6ee;z-index:9999;padding:24px;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;font-family:system-ui,-apple-system,sans-serif';
  overlay.innerHTML = `
    <h1 style="font-size:1.3em;margin:0 0 12px">認証情報がありません</h1>
    <p style="margin:0 0 16px;max-width:480px;line-height:1.6;color:#8a90a3">
      PC の設定画面「スマホ / LAN アクセス」に表示されている <b>6桁の接続コード</b> を入力してください。
    </p>
    <input id="pairInput" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="123456"
      style="font-size:2em;letter-spacing:0.3em;text-align:center;padding:12px;width:220px;background:#1c2030;border:1px solid #2a2f42;border-radius:8px;color:#fff;margin-bottom:12px">
    <button id="pairSubmit" style="background:#6cb2ff;color:#fff;border:none;border-radius:8px;padding:12px 28px;font-size:1em;font-weight:600;cursor:pointer">接続</button>
    <div id="pairMsg" style="margin-top:12px;min-height:1.2em;color:#ff8f8f"></div>
    <details style="margin-top:24px;color:#8a90a3;font-size:.85em;max-width:480px">
      <summary>コードで復帰できない場合</summary>
      <p>以前追加したホーム画面のアイコンは、一度削除して QR を読み直してから追加し直してください。iOS はホーム画面追加時点で URL を焼き込むため、後からトークンを差し替えることができません。</p>
    </details>
  `;
  document.body.appendChild(overlay);
  const input = overlay.querySelector('#pairInput');
  input.focus();
  const submit = async () => {
    const code = input.value.trim();
    if (code.length !== 6) { showPairMsg('6桁の数字を入力してください'); return; }
    try {
      const r = await fetch('/api/pair', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ code }),
      });
      const j = await r.json();
      if (r.ok && j.ok) {
        localStorage.setItem('scv_token', j.token);
        showPairMsg('接続しました。再読み込みします…', '#7dd991');
        setTimeout(() => location.reload(), 700);
      } else if (r.status === 429) {
        showPairMsg(`しばらく待ってから試してください (${j.wait ?? 10}秒)`);
      } else if (j.reason === 'regenerated') {
        showPairMsg('失敗が続いたためコードが再生成されました。PC 画面の新しいコードを確認してください');
      } else if (typeof j.remaining === 'number') {
        showPairMsg(`一致しません (残り ${j.remaining} 回)`);
      } else {
        showPairMsg('接続に失敗しました');
      }
    } catch (e) {
      showPairMsg('サーバーに届きません: ' + e.message);
    }
  };
  overlay.querySelector('#pairSubmit').addEventListener('click', submit);
  input.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); });

  function showPairMsg(text, color) {
    const m = overlay.querySelector('#pairMsg');
    m.textContent = text;
    m.style.color = color || '#ff8f8f';
  }
}

// M-1: boot が失敗したときの分かりやすい画面 (英語のstackや"pattern"エラーを出さない)
function showBootError(title, body, showReload) {
  const overlay = document.createElement('div');
  overlay.style.cssText = 'position:fixed;inset:0;background:var(--bg,#151821);color:var(--text,#e4e6ee);z-index:9999;padding:24px;box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;font-family:system-ui,-apple-system,sans-serif';
  const h = document.createElement('h1'); h.textContent = title; h.style.cssText = 'font-size:1.3em;margin:0 0 12px'; overlay.appendChild(h);
  const p = document.createElement('p'); p.textContent = body; p.style.cssText = 'margin:0 0 20px;max-width:480px;line-height:1.6;color:var(--muted,#8a90a3);white-space:pre-line'; overlay.appendChild(p);
  if (showReload) {
    const btn = document.createElement('button');
    btn.textContent = '再読込';
    btn.style.cssText = 'background:#6cb2ff;color:#fff;border:none;border-radius:8px;padding:12px 28px;font-size:1em;font-weight:600;cursor:pointer';
    btn.addEventListener('click', () => location.reload());
    overlay.appendChild(btn);
  }
  document.body.appendChild(overlay);
}

// X-2: Web版のテーマ/フォントは localStorage (端末ローカル) に保存し、PC 側と独立させる
function webLocalTheme() { return localStorage.getItem('webTheme') || 'system'; }
function webLocalFontSize() { return localStorage.getItem('webFontSize') || 'medium'; }

// WD-UI2: フォントサイズは px 数値 (10〜22)。旧値 (small/medium/large) は変換して互換維持
const FONT_LEGACY = { small: 12, medium: 14, large: 16 };
function fontSizePx(raw) {
  const px = FONT_LEGACY[raw] ?? parseInt(raw, 10);
  return Math.min(22, Math.max(10, isNaN(px) ? 14 : px));
}

// WD-UI2: 選択可能なテーマ一覧 (system 以外)。CSS の body.theme-* と対で管理
const THEMES = ['dark', 'light', 'navy', 'sepia'];

// V-5/X-2: system のとき OS/端末の設定に解決する
function resolveTheme() {
  const isWeb = !window.chrome?.webview;
  const t = isWeb ? webLocalTheme() : (state.settings?.theme || 'system');
  if (THEMES.includes(t)) return t;
  if (isWeb) {
    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
  return state.osDarkMode ? 'dark' : 'light';
}

function applyDisplaySettings() {
  const s = state.settings || {};
  const isWeb = !window.chrome?.webview;
  // WD-P1: 表示形式クラスの差し替えで全行の高さが変わり、スクロール位置が
  //   「ログの真ん中」に取り残される問題への対策。最下部追従中なら適用後に追従し直す。
  const msgsBox = document.getElementById('messages');
  const wasStick = state.autoScroll ||
    (msgsBox && msgsBox.scrollHeight - msgsBox.scrollTop - msgsBox.clientHeight < 80);
  const effTheme = resolveTheme();
  // WD-UI2: フォントサイズは px 直指定 (旧 size-* クラスは廃止。旧設定値は fontSizePx が変換)
  const fsPx = fontSizePx(isWeb ? webLocalFontSize() : (s.fontSize || 'medium'));
  // WD-UI8: 表示形式。Web版(スマホ)は常に2行固定 (CSSのis-web上書き頼みだと
  //   吹き出しのグリッド化ルール等が漏れるため、クラス自体を固定する)。
  //   PC版はオーバーレイ時に専用の表示形式 (未設定なら通常時と同じ) を使える。
  const baseStyle = s.displayStyle || 'compact';
  const effStyle = isWeb ? 'twoLine'
                 : (s.overlayMode ? (s.overlayDisplayStyle || baseStyle) : baseStyle);
  const classes = [`theme-${effTheme}`, `display-${effStyle}`];
  if (s.overlayMode) classes.push('overlay');
  if (isWeb) classes.push('is-web');
  document.body.className = classes.join(' ');
  document.body.style.fontSize = fsPx + 'px';
  // WD-UI10/11: フォント選択 (Windows標準フォント)。既定 = メイリオ (Yuki指定)。
  //   スマホ等で該当フォントが無い場合はフォールバックに落ちるだけなので共通適用でよい。
  document.body.style.fontFamily =
    `"${s.fontFamily || 'Meiryo'}", system-ui, -apple-system, "Segoe UI", sans-serif`;
  // WD-UI11: ウィンドウ外枠 (Formのパディング) の色をページ地色に完全一致させる
  if (window.chrome?.webview) {
    postToHost('setFrameColor', getComputedStyle(document.documentElement).backgroundColor);
  }
  // WD-UI4: 文字の太さゲージ (0〜10)。CSS 側の --sender-bold / --body-bold が参照する
  document.body.style.setProperty('--sender-bold', String(s.senderBoldness ?? 0));
  document.body.style.setProperty('--body-bold', String(s.bodyBoldness ?? 0));
  document.body.style.setProperty('--time-bold', String(s.timeBoldness ?? 0));
  document.documentElement.className = `theme-${effTheme}`;
  // C# 側に通知 (Web 版なら送っても無害)
  postToHost('themeChanged', effTheme);
  // WD-P1: 最下部追従の復元 (レイアウト確定後に2段階で確実に)
  if (msgsBox && wasStick) {
    requestAnimationFrame(() => {
      msgsBox.scrollTop = msgsBox.scrollHeight;
      requestAnimationFrame(() => { msgsBox.scrollTop = msgsBox.scrollHeight; });
    });
  }
}

// V-5: Web版で OS のテーマ変更に追従
if (window.matchMedia) {
  try {
    const mm = window.matchMedia('(prefers-color-scheme: dark)');
    const listener = () => { if (state.settings) applyDisplaySettings(); };
    if (mm.addEventListener) mm.addEventListener('change', listener);
    else if (mm.addListener) mm.addListener(listener);
  } catch {}
}

/** 最前面 / オーバーレイのトグル ON/OFF をタイトルバーボタンに反映 */
function reflectToggleButtons() {
  $('btnPin').classList.toggle('on', !!state.settings.alwaysOnTop);
  $('btnOverlay').classList.toggle('on', !!state.settings.overlayMode);
  // title 属性も現在状態を示すよう更新
  $('btnPin').title = state.settings.alwaysOnTop ? '最前面表示: ON (クリックでOFF)' : '最前面表示: OFF (クリックでON)';
  $('btnOverlay').title = state.settings.overlayMode ? 'オーバーレイモード: ON (クリックでOFF)' : 'オーバーレイモード: OFF (クリックでON)';
}

// ─────────── C# WebView2 ホストとの通信 ───────────
function postToHost(action, value) {
  const wv = window.chrome?.webview;
  if (!wv) return;
  const payload = value !== undefined ? { action, value } : { action };
  wv.postMessage(payload);
}

// C# 側からのメッセージ受信 (Form の Activated / Deactivate / トレイからのオーバーレイ切替)
if (window.chrome?.webview) {
  window.chrome.webview.addEventListener('message', (ev) => {
    const d = ev.data;
    if (!d || !d.type) return;
    if (d.type === 'activate')   onWindowActivate();
    if (d.type === 'deactivate') onWindowDeactivate();
    if (d.type === 'overlayChanged' && state.settings) {
      state.settings.overlayMode = !!d.value;
      applyDisplaySettings();
      reflectToggleButtons();
    }
    if (d.type === 'jumpTo' && d.value) jumpToMessage(d.value);   // E-3: Windows通知からのジャンプ
    if (d.type === 'askClose') showCloseConfirm();                // V-6: FormClosing 経由
    if (d.type === 'importFilePicked' && d.value) doImport(d.value);   // W-2
    // WD-UI20b: アクティブ化でC#側がバッジを消すが、バッジは「未読合計」仕様 (Yuki指示)
    //   なので未読合計を再送して維持する (タブを開いてそのタブの未読が消えた分だけ減る)。
    if (d.type === 'taskbarBadgeCleared') syncTaskbarBadge();
  });
}
// Web版 (ブラウザで開いた場合) のフォールバック
window.addEventListener('focus', onWindowActivate);
window.addEventListener('blur',  onWindowDeactivate);

function onWindowActivate() {
  state.active = true;
  // アクティブになったら現在タブの未読はクリア
  clearUnreadForActiveTab();
}
function onWindowDeactivate() {
  state.active = false;
  // 非アクティブになった時点の末尾メッセージ ID を覚える。
  // 次に来るメッセージが「新着」区切り線の直下になる。
  // 前回サイクルの区切り線があれば消す (直近の "離席" 位置だけを表示する)。
  document.querySelectorAll('.msg.unread-divider').forEach(n => n.classList.remove('unread-divider'));
  if (state.messages.length > 0) {
    state.unreadDividerAfterId = state.messages[state.messages.length - 1].id;
  } else {
    state.unreadDividerAfterId = 0;
  }
}

function bindEvents() {
  // タイトルバー
  // 最前面などの操作ボタンはクリック後にフォーカスを外す。
  //   フォーカスが残ると WebView2/OS がボタンに黒い枠 (フォーカスリング) を描き、
  //   ウィンドウの再アクティブ化のたびに "謎のハイライト" として再表示されるため。
  document.querySelectorAll('#titlebar .win-btn').forEach(b => {
    b.addEventListener('click', () => { try { b.blur(); } catch {} });
  });
  $('btnMinimize').addEventListener('click', () => postToHost('minimize'));
  // V-6: × ボタン。closeBehavior="ask" のときはこの画面側でモーダルを出す。
  $('btnClose').addEventListener('click', () => {
    const b = state.settings?.closeBehavior || 'ask';
    if (b === 'ask') showCloseConfirm();
    else postToHost('close');   // C# 側で分岐 (tray/exit)
  });
  $('closeCancel').addEventListener('click', () => hide('closeConfirmView'));
  $('closeTray').addEventListener('click', () => resolveClose('tray'));
  $('closeExit').addEventListener('click', () => resolveClose('exit'));
  $('btnPin').addEventListener('click', () => {
    const next = !state.settings.alwaysOnTop;
    state.settings.alwaysOnTop = next;
    saveSetting({ alwaysOnTop: next });
    postToHost('setAlwaysOnTop', next);
    reflectToggleButtons();
  });
  $('btnOverlay').addEventListener('click', () => toggleOverlay());
  $('overlayHandleUnlock').addEventListener('click', () => { if (state.settings.overlayMode) toggleOverlay(); });
  $('overlayHandleMove').addEventListener('click', () => {
    document.body.classList.toggle('move-mode');
    $('overlayHandleMove').classList.toggle('on', document.body.classList.contains('move-mode'));
  });

  $('btnSearch').addEventListener('click', () => $('searchView').classList.remove('hidden'));
  $('btnSettings').addEventListener('click', openSettings);
  // Y-2: Web版のタブバー右端 歯車
  $('tabSettingsBtn')?.addEventListener('click', openSettings);
  $('searchClose').addEventListener('click', () => hide('searchView'));
  $('settingsClose').addEventListener('click', () => hide('settingsView'));
  $('searchGo').addEventListener('click', doSearch);
  // WD-UI11: 検索画面のCSV出力は撤去 (エクスポート機能に統合済み)
  // WD-UI11b: キーワード欄で Enter でも検索できるようにする
  $('searchQ').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
  $('btnOpenPurge').addEventListener('click', openPurgeDialog);
  $('btnOpenExport')?.addEventListener('click', openExportDialog);
  $('btnImport')?.addEventListener('click', () => {
    if (!window.chrome?.webview) { appAlert('インポートは PC 版でのみ実行できます'); return; }
    postToHost('pickImportFile');
  });
  // P-2: ピン解除確認トグルはピン一覧モーダル側に移動
  $('cfgPinConfirmHeader')?.addEventListener('change', e => {
    state.settings.pinConfirm = e.target.checked;
    saveSetting({ pinConfirm: e.target.checked });
  });

  $('filterInput').addEventListener('input', (e) => { state.textFilter = e.target.value.toLowerCase(); refilterVisible(); });
  $('filterClear').addEventListener('click', () => { $('filterInput').value = ''; state.textFilter = ''; refilterVisible(); });
  $('jumpToBottom').addEventListener('click', () => scrollToBottom(true));

  const msgs = $('messages');
  msgs.addEventListener('scroll', async () => {
    const nearBottom = msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 30;
    state.autoScroll = nearBottom;
    if (nearBottom) {
      state.newSinceScroll = 0;
      $('jumpToBottom').classList.add('hidden');
    } else {
      // WD-P1: 新着が無くても、上にスクロール中は「最新へ」ボタンを常時表示 (Yuki要望)
      updateJumpBtnLabel();
      $('jumpToBottom').classList.remove('hidden');
    }
    // N-1: 最上部近くまでスクロールしたら過去ログを追加読込
    if (msgs.scrollTop < 80 && !state.loadingHistory && !state.noMoreHistory) {
      await loadOlderHistory();
    }
  });

  // WD-P1: モード切替等でウィンドウサイズが変わったとき、最下部追従中なら追従を維持する
  //   (行の折返しが変わり scrollTop がそのままだと「ログの真ん中」に取り残されるため)
  window.addEventListener('resize', () => {
    if (state.autoScroll) requestAnimationFrame(() => scrollToBottom());
  });

  // 設定
  $('cfgTheme').addEventListener('change', e => saveSetting({ theme: e.target.value }));
  // WD-UI2: フォントサイズはスライダー (10〜22px)。input=ライブ反映 / change=保存
  const bindFontSlider = (rangeId, labelId, commit) => {
    const upd = (raw, doCommit) => {
      const n = fontSizePx(raw);
      $(rangeId).style.setProperty('--fill', ((n - 10) / 12 * 100) + '%');
      $(labelId).textContent = `${n}px`;
      commit(n, doCommit);
    };
    $(rangeId).addEventListener('input',  e => upd(e.target.value, false));
    $(rangeId).addEventListener('change', e => upd(e.target.value, true));
  };
  bindFontSlider('cfgFontSize', 'cfgFontSizeLabel', (n, doCommit) => {
    state.settings.fontSize = String(n);
    applyDisplaySettings();
    if (doCommit) saveSetting({ fontSize: String(n) });
  });
  if (document.getElementById('cfgWebFontSize')) {
    bindFontSlider('cfgWebFontSize', 'cfgWebFontSizeLabel', (n) => {
      localStorage.setItem('webFontSize', String(n));
      applyDisplaySettings();
    });
  }
  // WD-UI4: 文字の太さゲージ (0=標準〜10)。名前とチャット本文で別々
  const bindBoldSlider = (rangeId, labelId, saveKey) => {
    const upd = (raw, commit) => {
      const n = Math.min(10, Math.max(0, parseInt(raw, 10) || 0));
      $(rangeId).style.setProperty('--fill', (n * 10) + '%');
      $(labelId).textContent = n === 0 ? '標準' : `+${n}`;
      state.settings[saveKey] = n;
      applyDisplaySettings();
      if (commit) saveSetting({ [saveKey]: n });
    };
    $(rangeId).addEventListener('input',  e => upd(e.target.value, false));
    $(rangeId).addEventListener('change', e => upd(e.target.value, true));
  };
  bindBoldSlider('cfgSenderBold', 'cfgSenderBoldLabel', 'senderBoldness');
  bindBoldSlider('cfgBodyBold', 'cfgBodyBoldLabel', 'bodyBoldness');
  bindBoldSlider('cfgTimeBold', 'cfgTimeBoldLabel', 'timeBoldness');
  $('cfgFontFamily')?.addEventListener('change', e => saveSetting({ fontFamily: e.target.value }));   // WD-UI10
  $('cfgDisplayStyle').addEventListener('change', e => saveSetting({ displayStyle: e.target.value }));
  // WD-UI8: オーバーレイ時の表示形式 (空 = 通常時と同じ)
  $('cfgOverlayDisplayStyle')?.addEventListener('change', e => saveSetting({ overlayDisplayStyle: e.target.value }));
  $('cfgAlwaysOnTop').addEventListener('change', e => {
    state.settings.alwaysOnTop = e.target.checked;
    saveSetting({ alwaysOnTop: e.target.checked });
    postToHost('setAlwaysOnTop', e.target.checked);
    reflectToggleButtons();
  });
  // U-9: 0-100 表示のスライダー、20 未満入力は 20 に丸め、trackのfillも更新
  const bindOpacitySlider = (rangeId, labelId, apply, saveKey) => {
    const upd = (raw, commit) => {
      let n = parseInt(raw, 10);
      if (isNaN(n)) n = 100;
      const eff = Math.max(20, n);
      $(rangeId).style.setProperty('--fill', n + '%');
      $(labelId).textContent = `${n}%${n < 20 ? ' (最低20%)' : ''}`;
      apply(eff / 100);
      if (commit) saveSetting({ [saveKey]: eff / 100 });
    };
    $(rangeId).addEventListener('input',  e => upd(e.target.value, false));
    $(rangeId).addEventListener('change', e => upd(e.target.value, true));
  };
  bindOpacitySlider('cfgOpacity', 'cfgOpacityLabel',
    v => { if (!state.settings.overlayMode) postToHost('setOpacity', v); },
    'windowOpacity');
  bindOpacitySlider('cfgOverlayOpacity', 'cfgOverlayOpacityLabel',
    v => { if (state.settings.overlayMode) postToHost('setOpacity', v); },
    'overlayOpacity');
  $('cfgOverlayMode').addEventListener('change', e => {
    state.settings.overlayMode = e.target.checked;
    saveSetting({ overlayMode: e.target.checked });
    postToHost('setOverlay', e.target.checked);
    applyDisplaySettings();
    reflectToggleButtons();
  });
  $('cfgCloseBehavior')?.addEventListener('change', e => saveSetting({ closeBehavior: e.target.value }));
  $('cfgTaskbarBadge')?.addEventListener('change', e => saveSetting({ taskbarBadge: e.target.checked }));

  // LAN公開トグル: 変更後は再起動が必要
  // Z-1: HTTPS 機能は撤去済み (旧 cfgEnableHttps ハンドラ廃止)
  $('cfgAllowLan').addEventListener('change', async e => {
    state.settings.allowLan = e.target.checked;
    // QR警告も更新
    $('qrImg').style.filter = e.target.checked ? '' : 'grayscale(1) opacity(.4)';
    $('qrDisabledHint').style.display = e.target.checked ? 'none' : '';
    // 初回 ON 時はファイアウォール規則追加を提案 (再起動確認より先に)
    if (e.target.checked) {
      if (await appConfirm('ファイアウォール受信規則を追加しますか?\n(スマホからPCへ接続するために必要。管理者権限のダイアログが出ます)')) {
        postToHost('requestFirewallRule');
      }
    }
    // saveSetting 経由で P-6 の再起動確認ダイアログが自動的に出る
    await saveSetting({ allowLan: e.target.checked });
  });
  $('btnRestart').addEventListener('click', async () => {
    if (await appConfirm('アプリを再起動します。よろしいですか?')) postToHost('restart');
  });
  $('btnCopyUrl').addEventListener('click', () => copyUrlToClipboard());
  $('btnRegenPair').addEventListener('click', async () => {
    const r = await fetch(withAuth('/api/pairing-code/regenerate'), { method: 'POST' });
    if (r.ok) { const j = await r.json(); $('pairCodeValue').textContent = j.code; }
  });

  // P-4: 保持日数はグリッドで各チャンネル別に設定
  // SavePrivateChat 廃止 (M-4)
  $('cfgMyCharId').addEventListener('change', e => {
    const uid = parseInt(e.target.value || '0', 10);
    saveSetting({ myCharId: uid });
    state.settings.myCharId = uid;
    refreshSelfHighlight();
  });
  $('muteAddBtn').addEventListener('click', () => {
    const uid = parseInt($('muteAddUid').value || '0', 10);
    if (uid > 0) { addMute(uid, ''); $('muteAddUid').value = ''; renderMuteList(); }
  });
  // WD-UI17: ルールUIは廃止。タブ設定モーダル内の通知フィールドを束ねる
  $('tabSoundPreview')?.addEventListener('click', (e) => { e.preventDefault(); playSound($('tabSound').value); });
  // ハイライトはマッチ語必須: マッチ欄が空の間はハイライト系を無効化
  const syncTabHighlightEnabled = () => {
    const hasPattern = $('tabNotifyPattern').value.trim().length > 0;
    $('tabUseHighlight').disabled = !hasPattern;
    $('tabHighlight').disabled = !hasPattern;
    if (!hasPattern) $('tabUseHighlight').checked = false;
  };
  $('tabNotifyPattern')?.addEventListener('input', syncTabHighlightEnabled);
  window._syncTabHighlightEnabled = syncTabHighlightEnabled;
  // WD-UI19: タブ通知の一括解除 (Yuki要望)。フォームを未設定状態に戻す (保存で確定)
  $('tabNotifyClear')?.addEventListener('click', (e) => {
    e.preventDefault();
    $('tabNotifyPattern').value = '';
    $('tabUseHighlight').checked = false;
    $('tabSound').value = '';
    $('tabToast').checked = false;
    $('tabFlash').checked = false;
    $('tabCooldown').value = 5;
    syncTabHighlightEnabled();
    condRefresh('tabNotifyPattern');   // WD-UI21: チップ表示も未設定に戻す
  });
  // WD-UI21: 条件フィールドにかんたん入力を装着
  initCondBuilder('tabContains');
  initCondBuilder('tabDoesNotContain');
  initCondBuilder('tabNotifyPattern');
  $('cfgNotifVol').addEventListener('input', e => {
    const v = parseInt(e.target.value, 10) / 100;
    $('cfgNotifVolLabel').textContent = `${e.target.value}%`;
    state.settings.notificationVolume = v;
  });
  $('cfgNotifVol').addEventListener('change', e => saveSetting({ notificationVolume: parseInt(e.target.value, 10) / 100 }));
  $('cfgStall').addEventListener('change', e => saveSetting({ captureStallMinutes: parseInt(e.target.value || '3', 10) }));
  $('cfgAutoStart').addEventListener('change', e => saveSetting({ autoStart: e.target.checked }));
  $('cfgPreventSleep').addEventListener('change', e => { saveSetting({ preventSleep: e.target.checked }); applyPreventSleep(e.target.checked); });
  // X-2: Web版のみ / localStorage 保存
  $('cfgWebTheme')?.addEventListener('change', e => {
    localStorage.setItem('webTheme', e.target.value);
    applyDisplaySettings();
  });
  // WD-UI2: cfgWebFontSize はスライダー化し bindFontSlider (上) で束ねる
  $('pinsClose').addEventListener('click', () => hide('pinsView'));
  $('btnPins').addEventListener('click', openPinsList);
  $('btnResetColors').addEventListener('click', () => saveSetting({ channelColors: { ...DEFAULT_COLORS } }).then(() => { renderColorGrid(); rerenderAllMessages(); renderTabs(); }));

  // タブ編集モーダル
  $('tabEditClose').addEventListener('click', () => hide('tabEditView'));
  $('tabEditCancel').addEventListener('click', () => hide('tabEditView'));
  $('tabEditSave').addEventListener('click', saveTabEdit);
  $('tabEditDelete').addEventListener('click', deleteTabEdit);
}

// ミュートリスト表示
function renderMuteList() {
  const box = $('muteList');
  box.innerHTML = '';
  const list = state.settings.mutedUsers || [];
  if (list.length === 0) { box.appendChild(el('div', { class: 'hint' }, '(なし)')); return; }
  for (const u of list) {
    const row = el('div', { class: 'mute-row' });
    row.appendChild(el('span', { class: 'mute-name' }, u.name || '(名前不明)'));
    row.appendChild(el('span', { class: 'mute-uid' }, `UID: ${u.uid}`));
    const rm = el('button', { class: 'btn small' }, '解除');
    rm.addEventListener('click', () => removeMute(u.uid));
    row.appendChild(rm);
    box.appendChild(row);
  }
}

// WD-UI17: ルール一覧・ルール編集モーダルは廃止 (通知はタブ右クリックの設定から)

// N-6: ピン留め切替
async function togglePin(messageId, node) {
  const isPinned = state.pinnedIds.has(messageId);
  if (isPinned) {
    // M-3: 確認は1行に。設定 pinConfirm=false ならスキップ。
    const needConfirm = state.settings?.pinConfirm !== false;
    if (needConfirm && !(await appConfirm('このピンを外しますか?'))) return;
    await fetch(withAuth(`/api/pins/${messageId}`), { method: 'DELETE' });
    state.pinnedIds.delete(messageId);
    syncPinUI(messageId, false);
    // WD-UI13: ピン一覧を開いたまま解除した場合、その行を即時に一覧から消す (Yuki報告)
    if (!$('pinsView').classList.contains('hidden')) {
      document.querySelector(`#pinsList .msg[data-id="${messageId}"]`)?.remove();
      if (!$('pinsList').querySelector('.msg')) {
        $('pinsList').appendChild(el('div', { class: 'hint' }, 'ピンはまだありません'));
      }
    }
  } else {
    await fetch(withAuth(`/api/pins/${messageId}`), { method: 'PUT' });
    state.pinnedIds.add(messageId);
    syncPinUI(messageId, true);
  }
}

// M-2: どこに表示されているノードでも .pinned 状態を揃える。
//   チャット欄・検索結果・ピン一覧 (モーダル内) すべての同一メッセージノードを更新。
function syncPinUI(messageId, pinned) {
  const nodes = document.querySelectorAll(`.msg[data-id="${messageId}"]`);
  nodes.forEach(n => n.classList.toggle('pinned', !!pinned));
}

async function openPinsList() {
  // P-2: ヘッダーの「解除時に確認」トグルを現在の設定に同期
  const cb = $('cfgPinConfirmHeader');
  if (cb) cb.checked = state.settings?.pinConfirm !== false;
  const box = $('pinsList');
  box.innerHTML = '';
  try {
    const rows = await fetch(withAuth('/api/pins')).then(r => r.json());
    if (!rows || rows.length === 0) { box.appendChild(el('div', { class: 'hint' }, 'ピンはまだありません')); }
    else {
      for (const m of rows) {
        // ピン一覧は自分がピンした発言が見えないと不便なのでミュート無視で描画 (E-2)
        const node = renderMsg(m, { live: false, ignoreMute: true });
        if (!node) continue;
        // P-3/WD-UI11: 「表示位置へ移動」ボタンを行の一番左に配置 (時刻の左)
        const jump = makeJumpButton(m.id);
        node.classList.add('pinned-row');
        node.insertBefore(jump, node.firstChild);
        box.appendChild(node);
      }
    }
  } catch (e) { box.appendChild(el('div', { class: 'hint danger' }, '読み込み失敗: ' + e.message)); }
  $('pinsView').classList.remove('hidden');
}

async function jumpToMessage(id) {
  hide('pinsView');
  hide('searchView');   // WD-UI11: 検索結果からのジャンプにも対応
  let node = document.querySelector(`#messages .msg[data-id="${id}"]`);
  // WD-UI18b: まだ読み込んでいない古いログへのジャンプは、自動で上限なしに遡って読み込む。
  //   探索中は画面を動かさず「読み込み中」バナーを表示し、ユーザーはいつでもキャンセル可能。
  if (!node) {
    let cancelled = false;
    const startCount = state.messages.length;
    const banner = el('div', { id: 'jumpLoading' });
    const text = el('span', {}, '過去ログを読み込み中…');
    const cancelBtn = el('button', { class: 'btn small' }, 'キャンセル');
    cancelBtn.addEventListener('click', () => { cancelled = true; });
    banner.appendChild(text);
    banner.appendChild(cancelBtn);
    document.body.appendChild(banner);
    try {
      while (!node && !state.noMoreHistory && !cancelled) {
        if (state.loadingHistory) {
          await new Promise(r => setTimeout(r, 100));   // スクロール起因の読込と衝突しないよう待つ
        } else {
          await loadOlderHistory();
        }
        text.textContent = `過去ログを読み込み中… (${state.messages.length - startCount} 件遡りました)`;
        node = document.querySelector(`#messages .msg[data-id="${id}"]`);
      }
    } finally {
      banner.remove();
    }
    if (cancelled) return;   // その場に留まる (読み込んだ分は残るが害はない)
  }
  if (!node) {
    // 検索・ピンはDBを直接照会するため通常ここには来ない (表示までの間に削除された競合ケースのみ)
    appAlert('該当メッセージが見つかりませんでした。表示までの間に削除された可能性があります。');
    return;
  }
  // WD-UI18c: ジャンプ先は最下部とは限らないので最下部追従を切る。
  //   これをしないと、直後のタブ切替→refilterVisible 内の scrollToBottom (WD-P1d) が
  //   最下部へ引き戻し、対象が画面外になってハイライトが見えなくなる (Yuki報告)。
  state.autoScroll = false;
  // WD-UI11/11b: 現在のタブ/絞り込みで非表示なら、そのメッセージのチャンネルの
  //   タブへ自動切替してから移動 (単一チャンネルタブ優先。見つからなければ「すべて」)。
  if (node.classList.contains('hidden')) {
    state.senderFilter = null;
    const ch = parseInt(node.getAttribute('data-channel') || '0', 10);
    const tabs = state.settings.tabs || [];
    const target =
      tabs.find(t => t.id !== 'all' && (t.channels || []).length === 1 && t.channels.includes(ch) && !t.contains && !t.doesNotContain) ||
      tabs.find(t => t.id !== 'all' && (t.channels || []).includes(ch) && !t.contains && !t.doesNotContain);
    state.activeTabId = target ? target.id : 'all';
    renderTabs();
    refilterVisible();
    // タブのキーワード条件等でまだ隠れている場合は「すべて」へフォールバック
    if (node.classList.contains('hidden')) {
      state.activeTabId = 'all';
      renderTabs();
      refilterVisible();
    }
  }
  // WD-UI18b: スクロールアニメーションはせず対象へ直接ジャンプし、着地後に点滅を開始する
  //   (smooth だと遠い対象への移動中に点滅が終わってしまう)
  node.scrollIntoView({ block: 'center', behavior: 'auto' });
  requestAnimationFrame(() => {
    node.classList.add('flash');
    setTimeout(() => node.classList.remove('flash'), 2500);
  });
}

/* WD-UI11: 「表示位置へ移動」ボタン (ピン一覧・検索結果 共通)。
   アイコンは従来デザイン (曲がり矢印) を維持 (照準案は不評だったため戻した・11b) */
function makeJumpButton(id) {
  const jump = el('button', { class: 'btn small jump-to-btn', title: '表示位置へ移動' });
  jump.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>';
  jump.addEventListener('click', () => jumpToMessage(id));
  return jump;
}

// U-5: スリープ抑止 (NoSleep 方式 = 無音動画ループ)
let _noSleepVideo = null;
function applyPreventSleep(on) {
  if (on) {
    if (_noSleepVideo) return;
    // ダミーの微小動画 (base64 の最小 MP4)
    const v = document.createElement('video');
    v.setAttribute('playsinline', ''); v.setAttribute('muted', ''); v.setAttribute('loop', '');
    v.muted = true; v.playsInline = true;
    v.style.position = 'fixed'; v.style.width = '1px'; v.style.height = '1px'; v.style.opacity = '0';
    // 5フレームの黒 MP4 (Base64、20KB程度)。ライセンス問題を避けるためインライン生成
    v.src = 'data:video/mp4;base64,AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAACL21kYXQAAAAA';
    document.body.appendChild(v);
    v.play().catch(() => {});
    _noSleepVideo = v;
  } else if (_noSleepVideo) {
    _noSleepVideo.pause();
    _noSleepVideo.remove();
    _noSleepVideo = null;
  }
}

// W-2: エクスポートダイアログ
function openExportDialog() {
  if (!window.chrome?.webview) { appAlert('エクスポートは PC 版でのみ実行できます'); return; }
  $('exportRange').value = 'all';
  $('exportFormat').value = 'csv';
  $('exportDateLabel').style.display = 'none';
  $('exportView').classList.remove('hidden');
}
function computeExportBefore() {
  const sel = $('exportRange').value;
  const now = Math.floor(Date.now() / 1000);
  if (sel === 'week')   return now - 7 * 86400;
  if (sel === 'month')  return now - 30 * 86400;
  if (sel === '3month') return now - 90 * 86400;
  if (sel === 'all')    return null;
  if (sel === 'custom') {
    const d = $('exportDate').value;
    return d ? Math.floor(new Date(d + 'T00:00:00').getTime() / 1000) : null;
  }
  return null;
}
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('exportRange')?.addEventListener('change', () => {
    $('exportDateLabel').style.display = $('exportRange').value === 'custom' ? '' : 'none';
  });
  document.getElementById('exportClose')?.addEventListener('click', () => hide('exportView'));
  document.getElementById('exportCancel')?.addEventListener('click', () => hide('exportView'));
  document.getElementById('exportRun')?.addEventListener('click', executeExport);
});
function executeExport() {
  const before = computeExportBefore();
  const channels = [...$('exportChannels').querySelectorAll('input:checked')].map(cb => cb.value).join(',');
  const format = $('exportFormat').value;
  const q = new URLSearchParams();
  if (before != null) q.set('before', String(before));
  if (channels) q.set('channels', channels);
  q.set('format', format);
  // 別タブで開いてダウンロード発火
  window.open(withAuth('/api/export', q.toString()));
  hide('exportView');
}
async function doImport(path) {
  const r = await fetch(withAuth('/api/import'), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ path }),
  });
  if (r.status === 403) { await appAlert('インポートは PC 版でのみ実行できます'); return; }
  const j = await r.json();
  if (j.errors === -1) {
    await appAlert('この CSV は本ツールでエクスポートされたものではないようです。ヘッダーが一致しません。');
    return;
  }
  await appAlert(`インポート完了\n追加: ${j.added} 件 / スキップ (重複): ${j.skipped} 件` + (j.errors > 0 ? ` / エラー: ${j.errors} 件` : ''));
  location.reload();
}

// V-6: 閉じる確認モーダルの表示と選択反映
function showCloseConfirm() {
  const cb = $('closeConfirmRemember');
  if (cb) cb.checked = false;
  $('closeConfirmView').classList.remove('hidden');
}
async function resolveClose(action) {
  // action: 'tray' | 'exit'
  const remember = $('closeConfirmRemember')?.checked;
  if (remember) {
    await saveSetting({ closeBehavior: action });
  }
  hide('closeConfirmView');
  postToHost('closeConfirmed', action);
}

// オーバーレイモードの切替 (タイトルバーの □ ボタンと復帰ハンドル ✕ ボタンから共通で呼ぶ)
function toggleOverlay() {
  const next = !state.settings.overlayMode;
  state.settings.overlayMode = next;
  saveSetting({ overlayMode: next });
  postToHost('setOverlay', next);
  applyDisplaySettings();
  reflectToggleButtons();
}

// ─────────── タブ ───────────
// U-4: 背景色から適切な文字色を白/黒で自動選択
function pickTextColor(bgHex) {
  const m = /^#([0-9a-f]{6})$/i.exec(bgHex || '');
  if (!m) return '#fff';
  const n = parseInt(m[1], 16);
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  const l = 0.299 * r + 0.587 * g + 0.114 * b;
  return l > 160 ? '#000' : '#fff';
}

// R-1: DnD 中は renderTabs を抑止するフラグ
let _tabReordering = false;

function renderTabs() {
  if (_tabReordering) return; // R-1: DnD 中は再描画しない
  const nav = $('tabs');
  nav.innerHTML = '';
  const isApp = !!window.chrome?.webview;   // R-1: DnD はアプリ版のみ
  for (const tab of state.settings.tabs) {
    const isCustom = !state.builtinTabIds.has(tab.id);
    const isActive = tab.id === state.activeTabId;
    const btn = el('button', {
      class: 'tab' + (isActive ? ' active' : '') + (isCustom ? ' custom' : ''),
      'data-tab-id': tab.id,
    }, tab.name);

    // U-4: 単一チャンネルのタブは選択中のみバッジ色
    if (isActive && Array.isArray(tab.channels) && tab.channels.length === 1) {
      const c = channelColor(tab.channels[0]);
      btn.style.background = c;
      btn.style.borderColor = c;
      btn.style.color = pickTextColor(c);
    }

    btn.addEventListener('click', () => {
      // pointerdown ハンドラーがドラッグ判定した場合はここに来ない (キャプチャで抑止)
      state.activeTabId = tab.id;
      state.senderFilter = null;
      state.unreadByTab[tab.id] = 0;
      renderTabs();
      refilterVisible();
      syncTaskbarBadge();   // WD-UI20b: 開いたタブの未読分だけバッジを減らす
    });
    // WD-UI18: タブ設定は右クリックで開く (歯車アイコンは廃止・Yuki指示)。
    //   組込タブは通知設定のみ編集可 (openTabEditor 側で制御)。
    if (isApp) {
      btn.addEventListener('contextmenu', (e) => { e.preventDefault(); openTabEditor(tab.id); });
    }
    // R-1: PointerEvent ベースの DnD (アプリ版のみ)
    if (isApp) attachTabDnd(btn, tab.id);
    // 未読バッジ (Y-4: 「すべて」は対象外)
    if (tab.id !== 'all') {
      const unread = state.unreadByTab[tab.id] || 0;
      if (unread > 0 && !isActive) {
        btn.appendChild(el('span', { class: 'unread-badge' }, unread > 99 ? '99+' : String(unread)));
      }
    }
    nav.appendChild(btn);
  }

  // + ボタン (フレンドタブの隣)
  const addBtn = el('button', { class: 'tab-add', title: 'タブを追加' }, '+');
  addBtn.addEventListener('click', () => openTabEditor(null));
  nav.appendChild(addBtn);

  // 送信者フィルタ表示 (E-4: ✕を SVG に)
  if (state.senderFilter) {
    const label = `送信者 (id=${state.senderFilter}) のみ`;
    const btn = el('button', { class: 'tab active' }, label);
    const close = el('span', { class: 'tab-close-icon' });
    close.innerHTML = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
    btn.appendChild(close);
    btn.addEventListener('click', () => { state.senderFilter = null; refilterVisible(); renderTabs(); });
    nav.appendChild(btn);
  }
}

// R-1: PointerEvent ベースのタブ DnD。
//   pointerdown → 8px 以上動いたらドラッグ開始 → 全体で move 追従 → up でドロップ位置を確定。
//   ドラッグ中は _tabReordering=true にして renderTabs を抑止する。
function attachTabDnd(btn, tabId) {
  let startX = 0, startY = 0, dragging = false;
  const onDown = (e) => {
    if (e.button !== undefined && e.button !== 0) return; // 左クリックのみ
    if (state.settings?.overlayMode) return;              // WD-UI12b: オーバーレイ中はタブ並べ替え無効 (仕様)
    // バッジ上のイベントは無視 (クリックで通す)
    if (e.target.closest('.unread-badge')) return;
    startX = e.clientX; startY = e.clientY;
    dragging = false;
    window.addEventListener('pointermove', onMove);
    window.addEventListener('pointerup', onUp, { once: true });
  };
  const onMove = (e) => {
    if (!dragging) {
      if (Math.hypot(e.clientX - startX, e.clientY - startY) < 8) return;
      dragging = true;
      _tabReordering = true;
      btn.classList.add('dragging');
      // 以降の click を抑止
      btn.addEventListener('click', suppressClick, { capture: true, once: true });
    }
    updateDropIndicator(e.clientX);
  };
  const onUp = (e) => {
    window.removeEventListener('pointermove', onMove);
    if (!dragging) return;
    dragging = false;
    _tabReordering = false;
    btn.classList.remove('dragging');
    const idx = computeDropIndex(e.clientX);
    clearDropIndicator();
    reorderTabsToIndex(tabId, idx);
  };
  btn.addEventListener('pointerdown', onDown);
}

function suppressClick(e) { e.stopPropagation(); e.preventDefault(); }

/* WD-UI12: 挿入位置ベースの DnD (Yuki指摘の改善)。
   カーソルX座標から「どのタブ間の隙間に入るか」を求める。
   各タブの中央より左なら「そのタブの前」、全タブの中央を過ぎたら「末尾」。
   これにより先頭 (最初のタブの左半分) と末尾 (最後のタブの右半分〜右側の余白)
   にも直感的にドロップできる。 */
function tabButtons() {
  return [...$('tabs').querySelectorAll('.tab[data-tab-id]')];   // 送信者フィルタチップ等は除外
}
function computeDropIndex(clientX) {
  const btns = tabButtons();
  for (let i = 0; i < btns.length; i++) {
    const r = btns[i].getBoundingClientRect();
    if (clientX < r.left + r.width / 2) return i;   // i 番目の前に挿入
  }
  return btns.length;                                // 末尾に挿入
}
function updateDropIndicator(clientX) {
  clearDropIndicator();
  const btns = tabButtons();
  if (!btns.length) return;
  const idx = computeDropIndex(clientX);
  // ハイライトは「入る隙間」を指す: 先頭なら最初のタブの左辺、それ以外は左隣タブの右辺
  if (idx === 0) btns[0].classList.add('drag-over-left');
  else btns[idx - 1].classList.add('drag-over-right');
}
function clearDropIndicator() {
  document.querySelectorAll('#tabs .tab.drag-over-left, #tabs .tab.drag-over-right')
    .forEach(n => n.classList.remove('drag-over-left', 'drag-over-right'));
}

/* draggedId を「settings.tabs 上の挿入位置 domIndex」へ移動する */
function reorderTabsToIndex(draggedId, domIndex) {
  const tabs = state.settings.tabs;
  const from = tabs.findIndex(t => t.id === draggedId);
  if (from < 0) { renderTabs(); return; }
  let to = domIndex;
  if (to > from) to--;               // 自分を抜いた分のインデックス補正
  to = Math.max(0, Math.min(to, tabs.length - 1));
  if (to === from) { renderTabs(); return; }
  const [moved] = tabs.splice(from, 1);
  tabs.splice(to, 0, moved);
  saveSetting({ tabs: tabs });
  renderTabs();
}

// R-1: 未読バッジだけを差分更新する (renderTabs を毎受信で走らせない)
function updateUnreadBadges() {
  if (_tabReordering) return;
  for (const btn of $('tabs').querySelectorAll('.tab')) {
    const id = btn.getAttribute('data-tab-id');
    if (!id) continue;
    if (id === 'all') { btn.querySelector('.unread-badge')?.remove(); continue; }   // Y-4
    const unread = state.unreadByTab[id] || 0;
    let badge = btn.querySelector('.unread-badge');
    const isActive = btn.classList.contains('active');
    if (unread > 0 && !isActive) {
      if (!badge) { badge = el('span', { class: 'unread-badge' }); btn.appendChild(badge); }
      badge.textContent = unread > 99 ? '99+' : String(unread);
    } else if (badge) {
      badge.remove();
    }
  }
}

function activeTab() {
  return state.settings.tabs.find(t => t.id === state.activeTabId) ?? state.settings.tabs[0];
}

function tabMatchesMsg(tab, msg) {
  if (!tab.channels.includes(msg.channel)) return false;
  if (tab.contains) {
    try { if (!new RegExp(tab.contains).test(msg.text)) return false; } catch {}
  }
  if (tab.doesNotContain) {
    try { if (new RegExp(tab.doesNotContain).test(msg.text)) return false; } catch {}
  }
  return true;
}

function isVisible(msg) {
  if (!tabMatchesMsg(activeTab(), msg)) return false;
  if (state.senderFilter && msg.senderId !== state.senderFilter) return false;
  if (state.textFilter && !(msg.text.toLowerCase().includes(state.textFilter) || msg.senderName.toLowerCase().includes(state.textFilter))) return false;
  return true;
}

function clearUnreadForActiveTab() {
  const id = state.activeTabId;
  if (state.unreadByTab[id]) {
    state.unreadByTab[id] = 0;
    renderTabs();
    syncTaskbarBadge();   // WD-UI20b: 開いたタブの未読分だけバッジを減らす
  }
}

function clearUnreadDivider() {
  state.unreadDividerAfterId = null;
  document.querySelectorAll('.msg.unread-divider').forEach(n => n.classList.remove('unread-divider'));
}

// ─────────── メッセージ描画 (統一構造) ───────────
// <div class="msg" data-channel=N>
//   <div class="msg-meta">
//     <span class="time">HH:mm:ss</span>
//     <span class="badge" style="background:...">ラベル</span>
//     <span class="sender">名前</span>
//   </div>
//   <div class="msg-body">本文</div>
// </div>
/**
 * メッセージノードを描画する。
 * @param {object} msg
 * @param {object} opts
 *   - live:true (WebSocket type=='msg' 経由の**リアルタイム受信のみ**) だと
 *     ルールの音・Windows通知・オーバーレイフラッシュ・タブ音を発火。
 *     hello初期送信/差分取得/履歴読込/再描画/検索/ピン一覧では live=false にすること。
 *   - ignoreMute:true でミュートユーザーも描画 (検索・ピン一覧用)
 */
function renderMsg(msg, opts = {}) {
  const meta = CHANNEL_META[msg.channel];
  if (!meta) return null;

  // N-5: ミュートは表示除外 (DBには残す)。opts.ignoreMuteで無視可能。
  if (!opts.ignoreMute && isMuted(msg.senderId)) return null;

  const senderEl = el('span', { class: 'sender' }, msg.senderName || '?');
  senderEl.addEventListener('click', (e) => {
    e.stopPropagation();
    openSenderMenu(e.clientX, e.clientY, msg.senderId, msg.senderName || '?');
  });

  const badge = el('span', {
    class: 'badge',
    style: `background:${channelColor(msg.channel)};`,
  }, meta.label);

  const time = el('span', { class: 'time' }, fmtTime(msg.ts));

  const target = msg.targetId ? ` → id ${msg.targetId}` : '';
  const body = el('div', { class: 'msg-body' }, msg.text + target);

  const metaEl = el('div', { class: 'msg-meta' }, [time, badge, senderEl]);

  const node = el('div', {
    class: 'msg' + (msg.ephemeral ? ' ephemeral' : ''),
    'data-id': msg.id,
    'data-channel': msg.channel,
    'data-sender-id': msg.senderId,
  }, [metaEl, body]);

  // N-4: 自分の発言強調
  if (state.settings?.myCharId && msg.senderId === state.settings.myCharId) {
    node.classList.add('self');
  }

  // N-3: ルール適用。ハイライトは常に、音/Windows通知/フラッシュは live のみ (R-3/R-4)
  applyRuleToMsg(node, msg, !!opts.live);

  // N-6/R-5/R-7: ピンボタン。
  //   - ephemeral (id=0) はピン不可
  //   - 付与操作はアプリ版のみ (Web版は閲覧のみ)。サーバー側でも loopback 判定で強制
  //   - ピン済み表示は "右端に絶対配置した1個" に統一 (pin-mark は廃止)
  const isApp = !!window.chrome?.webview;
  if (msg.id && msg.id > 0 && isApp) {
    const pinBtn = el('button', { class: 'pin-btn', title: 'ピン留め' });
    pinBtn.innerHTML = SVG_PIN;
    pinBtn.addEventListener('click', (e) => { e.stopPropagation(); togglePin(msg.id, node); });
    node.appendChild(pinBtn);
  }
  if (state.pinnedIds && state.pinnedIds.has(msg.id)) {
    node.classList.add('pinned');
    // Web版 (ピンボタン非表示) では代わりに小さな SVG マークを右端に絶対配置で置く
    if (!isApp) {
      const mark = el('span', { class: 'pin-mark' });
      mark.innerHTML = SVG_PIN;
      node.appendChild(mark);
    }
  }

  return node;
}

// R-6/R-7: ピン SVG (currentColor)
const SVG_PIN = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>';

/** N-5: 送信者名クリックで小型メニューを開く */
function openSenderMenu(x, y, uid, name) {
  const menu = $('senderMenu');
  menu.style.left = Math.min(x, window.innerWidth - 200) + 'px';
  menu.style.top  = Math.min(y, window.innerHeight - 180) + 'px';
  menu.classList.remove('hidden');
  const handler = (ev) => {
    const act = ev.target.getAttribute?.('data-act');
    if (!act) return;
    menu.classList.add('hidden');
    menu.removeEventListener('click', handler);
    if (act === 'only') { state.senderFilter = uid; renderTabs(); refilterVisible(); }
    else if (act === 'mute') { addMute(uid, name); }
    else if (act === 'copyUid') { navigator.clipboard?.writeText(String(uid)).catch(() => {}); }
    else if (act === 'setSelf') { saveSetting({ myCharId: uid }); state.settings.myCharId = uid; refreshSelfHighlight(); appAlert(`自身のUIDを ${uid} に設定しました`); }
  };
  menu.addEventListener('click', handler);
  // 外側クリックで閉じる
  setTimeout(() => document.addEventListener('click', function once() { menu.classList.add('hidden'); menu.removeEventListener('click', handler); document.removeEventListener('click', once); }), 0);
}

function isMuted(uid) {
  return (state.settings?.mutedUsers || []).some(u => u.uid === uid);
}
async function addMute(uid, name) {
  state.settings.mutedUsers = state.settings.mutedUsers || [];
  if (state.settings.mutedUsers.some(u => u.uid === uid)) return;
  state.settings.mutedUsers.push({ uid, name, mutedAt: Date.now() });
  await saveSetting({ mutedUsers: state.settings.mutedUsers });
  // 既存 DOM の該当行を消す
  document.querySelectorAll(`.msg[data-sender-id="${uid}"]`).forEach(n => n.remove());
}
async function removeMute(uid) {
  state.settings.mutedUsers = (state.settings.mutedUsers || []).filter(u => u.uid !== uid);
  await saveSetting({ mutedUsers: state.settings.mutedUsers });
  renderMuteList();
}
function refreshSelfHighlight() {
  document.querySelectorAll('.msg').forEach(n => {
    const sid = Number(n.getAttribute('data-sender-id'));
    n.classList.toggle('self', !!state.settings.myCharId && sid === state.settings.myCharId);
  });
}

// ─────────── WD-UI17: タブ通知 (旧 N-3 ルールを廃止しタブに統合) ───────────
//   各タブの通知設定: notifyPattern (空欄ならタブの全チャットが対象) / highlight
//   (notifyPattern 必須) / soundId / toast / overlayFlash / soundCooldownSec。
//   ハイライトは live に関係なく常時適用、発火系 (音/Windows通知/フラッシュ) は
//   live のみ (R-3/R-4: 表示≠通知)。クールダウンはタブ単位。

const tabLastFireAt = {};  // { tabId: unixMs }

// WD-UI20b: タスクバーバッジ = 全タブの未読バッジ合計 (Yuki指示)。
//   累計方式 (アクティブ化でリセット) は廃止。タブを開いてそのタブの
//   未読が消えると、その分だけバッジも減る。
function syncTaskbarBadge() {
  if (!window.chrome?.webview) return; // アプリ版のみ (Web版では意味なし)
  let total = 0;
  for (const k in state.unreadByTab) total += state.unreadByTab[k] || 0;
  postToHost('notifyCount', total);
}

function applyRuleToMsg(node, msg, live) {
  let highlightColor = null, sound = null, toastTab = null, flash = false, fired = false;
  for (const t of (state.settings?.tabs || [])) {
    if (!tabMatchesMsg(t, msg)) continue;
    // マッチ語 (本文または送信者名)。空欄ならタブの全チャットが対象
    let matched = true;
    if (t.notifyPattern) {
      try {
        const re = new RegExp(t.notifyPattern);
        // WD-UI22: プレイヤー名 (senderName) はマッチ対象から除外 (Yuki指示)。
        //   自分の名前で「呼ばれたチャット」を探すと自分の発言まで拾われてしまうため。
        //   特定プレイヤーを対象にしたい場合は UID をマッチ単語に指定する (完全一致で判定)。
        matched = re.test(msg.text) || new RegExp(`^(?:${t.notifyPattern})$`).test(String(msg.senderId ?? ''));
      } catch { matched = false; }
    }
    if (!matched) continue;

    // ハイライト (マッチ語必須の仕様。先勝ち)
    if (t.notifyPattern && t.highlight && !highlightColor) highlightColor = t.highlight;

    if (!live) continue;
    const now = Date.now();
    const cd = (t.soundCooldownSec ?? 5) * 1000;
    if (cd > 0 && tabLastFireAt[t.id] && now - tabLastFireAt[t.id] < cd) continue;
    tabLastFireAt[t.id] = now;
    if (t.soundId && !sound) sound = t.soundId;      // 音は先勝ちで1回だけ
    if (t.toast && !toastTab) toastTab = t;          // Windows通知も1回だけ
    if (t.overlayFlash) flash = true;
    fired = true;
  }

  if (highlightColor) {
    node.classList.add('rule-highlight');
    node.style.background = hexToRgba(highlightColor, 0.25);
    node.style.borderLeft = `3px solid ${highlightColor}`;
  }
  if (!live) return;
  if (sound) playSound(sound);
  if (toastTab) postToHost('toast', { title: toastTab.name, body: `${msg.senderName}: ${msg.text}`, messageId: msg.id });
  if (flash && state.settings?.overlayMode) {
    node.classList.add('flash');
    setTimeout(() => node.classList.remove('flash'), 2000);
    showMiniBanner(`${msg.senderName}`);
  }
  // WD-UI20: 通知発火でのタスクバーバッジ加算は廃止 (Yuki指示)。
  //   「すべて」タブを開いたまま放置するとバッジが貯まり続けるため、
  //   バッジはタブ未読と連動する方式に変更 (appendMsg 側)。
}

function hexToRgba(hex, a) {
  const m = /^#([0-9a-f]{6})$/i.exec(hex);
  if (!m) return hex;
  const n = parseInt(m[1], 16);
  return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;
}

function showMiniBanner(text) {
  const b = el('div', { id: 'miniBanner' }, text);
  document.body.appendChild(b);
  setTimeout(() => b.remove(), 1600);
}

// Web Audio API による内蔵音合成 (音源ファイル同梱禁止 = 著作権リスクなし)
let audioCtx = null;
function ensureAudio() {
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  if (audioCtx.state === 'suspended') audioCtx.resume();
  return audioCtx;
}

/** 短音生成: freq(Hz), type(sine/square/triangle/sawtooth), dur(ms), vol(0-1) */
function beep(freq, type, dur, vol = 0.3) {
  const ctx = ensureAudio();
  const t0 = ctx.currentTime;
  const osc = ctx.createOscillator();
  const g = ctx.createGain();
  osc.type = type; osc.frequency.value = freq;
  const master = (state.settings?.notificationVolume ?? 0.5);
  g.gain.setValueAtTime(0, t0);
  g.gain.linearRampToValueAtTime(vol * master, t0 + 0.005);
  g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur / 1000);
  osc.connect(g); g.connect(ctx.destination);
  osc.start(t0); osc.stop(t0 + dur / 1000 + 0.02);
}

function playSound(id) {
  try {
    switch (id) {
      case 'ping':      beep(880, 'sine', 120); break;
      case 'pong':      beep(440, 'sine', 180); break;
      case 'chime2':    beep(660, 'sine', 120); setTimeout(() => beep(880, 'sine', 180), 130); break;
      case 'chime3':    beep(523, 'sine', 110); setTimeout(() => beep(659, 'sine', 110), 120); setTimeout(() => beep(784, 'sine', 200), 240); break;
      case 'bell':      beep(1000, 'triangle', 400, 0.35); setTimeout(() => beep(2000, 'triangle', 300, 0.15), 20); break;
      case 'pop':       beep(300, 'square', 60, 0.4); break;
      case 'sweepUp':   sweep(300, 900, 'sine', 250); break;
      case 'sweepDown': sweep(900, 300, 'sine', 250); break;
      case 'xylo':      beep(1200, 'triangle', 80); setTimeout(() => beep(1400, 'triangle', 80), 90); break;
      case 'sonar':     beep(1200, 'sine', 500, 0.35); break;
      case 'buzz':      beep(150, 'sawtooth', 300, 0.3); break;
      case 'sparkle':   beep(1400, 'triangle', 60); setTimeout(() => beep(1800, 'triangle', 60), 70); setTimeout(() => beep(2200, 'triangle', 80), 140); break;
    }
  } catch { /* AudioContext 未有効化など */ }
}
function sweep(from, to, type, dur) {
  const ctx = ensureAudio();
  const t0 = ctx.currentTime;
  const osc = ctx.createOscillator();
  const g = ctx.createGain();
  osc.type = type; osc.frequency.setValueAtTime(from, t0);
  osc.frequency.linearRampToValueAtTime(to, t0 + dur / 1000);
  const master = (state.settings?.notificationVolume ?? 0.5);
  g.gain.setValueAtTime(0, t0);
  g.gain.linearRampToValueAtTime(0.3 * master, t0 + 0.02);
  g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur / 1000);
  osc.connect(g); g.connect(ctx.destination);
  osc.start(t0); osc.stop(t0 + dur / 1000 + 0.02);
}

// スマホでは初回タップで AudioContext を resume する必要がある
document.addEventListener('click', () => { try { ensureAudio(); } catch {} }, { once: true });
document.addEventListener('touchstart', () => { try { ensureAudio(); } catch {} }, { once: true });

function appendMsg(msg, opts = {}) {
  // 重複排除 (指示書 B-1): 保存済みメッセージ (id > 0) は Set で管理。
  //   ephemeral (id = 0) は判定対象外 (毎回別物として扱う)。
  if (msg.id && msg.id > 0) {
    if (state.seenIds.has(msg.id)) return;
    state.seenIds.add(msg.id);
    if (msg.id > state.lastSeenId) state.lastSeenId = msg.id;
  }

  state.messages.push(msg);
  if (state.messages.length > state.maxKeep) {
    const toRemove = state.messages.length - state.maxKeep;
    const removed = state.messages.splice(0, toRemove);
    for (const r of removed) if (r.id) state.seenIds.delete(r.id);
    const box = $('messages');
    for (let i = 0; i < toRemove && box.firstChild; i++) box.removeChild(box.firstChild);
  }
  const node = renderMsg(msg, { live: !!opts.live });
  if (!node) return;
  if (!isVisible(msg)) node.classList.add('hidden');

  // 未読区切り線: 非アクティブ中、unreadDividerAfterId 直後の最初のメッセージに印を付ける
  if (state.unreadDividerAfterId !== null && msg.id > state.unreadDividerAfterId) {
    // 既に他のノードに divider がついていなければ、このノードに付ける
    if (!$('messages').querySelector('.msg.unread-divider')) {
      node.classList.add('unread-divider');
    }
  }

  $('messages').appendChild(node);
  // 追記時も再計算一本化で対応 (E-1)。負荷は最新末尾からの1回スキャンで軽い。
  reinsertDateDividers();

  // 未読カウント (非アクティブタブ、かつ現在タブが msg にマッチしない場合のみ)
  const activeMatchesMsg = tabMatchesMsg(activeTab(), msg);
  if (!activeMatchesMsg) {
    let unreadAdded = false;
    for (const t of state.settings.tabs) {
      if (t.id === state.activeTabId) continue;
      if (t.id === 'all') continue;   // Y-4: 「すべて」タブは母集団なので未読バッジ対象外
      if (tabMatchesMsg(t, msg)) {
        state.unreadByTab[t.id] = (state.unreadByTab[t.id] || 0) + 1;
        unreadAdded = true;
      }
    }
    updateUnreadBadges();   // R-1: 全再構築せず差分更新
    // WD-UI20/20b: タスクバーバッジはタブ未読の合計と連動 (Yuki指示)。
    //   「すべて」タブ表示中は未読が付かないため、オーバーレイ放置で貯まらない。
    if (unreadAdded) syncTaskbarBadge();
  }

  if (state.autoScroll) {
    scrollToBottom(false);
  } else if (isVisible(msg)) {
    state.newSinceScroll++;
    updateJumpBtnLabel();
    $('jumpToBottom').classList.remove('hidden');
  }
}

/* WD-P1: 「↓ 新着 n 件 / ↓ 最新へ」ボタンのラベルを状態に応じて更新 */
function updateJumpBtnLabel() {
  const btn = $('jumpToBottom');
  btn.textContent = state.newSinceScroll > 0 ? `↓ 新着 ${state.newSinceScroll} 件` : '↓ 最新へ';
}

// N-1: 過去ログを before_id で追加読込、スクロール位置を維持
async function loadOlderHistory() {
  state.loadingHistory = true;
  const msgs = $('messages');
  const oldestId = state.messages.reduce((min, m) => m.id && m.id > 0 && (min === 0 || m.id < min) ? m.id : min, 0);
  if (!oldestId) { state.loadingHistory = false; return; }
  const spinner = el('div', { class: 'history-spinner' }, '過去ログを読み込み中…');
  msgs.insertBefore(spinner, msgs.firstChild);
  const heightBefore = msgs.scrollHeight;
  const topBefore = msgs.scrollTop;
  try {
    const rows = await fetch(withAuth('/api/messages', 'before_id=' + oldestId + '&limit=100')).then(r => r.json());
    spinner.remove();
    if (!rows || rows.length === 0) {
      state.noMoreHistory = true;
      msgs.insertBefore(el('div', { class: 'history-end' }, 'これ以上の履歴はありません'), msgs.firstChild);
    } else {
      // 表示上限を動的に緩める (過去ログ読み込み分は削られないように)
      state.maxKeep = Math.max(state.maxKeep, state.messages.length + rows.length + 500);
      // 先頭に古い順で挿入
      const frag = document.createDocumentFragment();
      for (const m of rows) {
        if (m.id && state.seenIds.has(m.id)) continue;
        if (m.id) state.seenIds.add(m.id);
        state.messages.unshift(m);
        const node = renderMsg(m, { live: false });   // R-3/R-4
        if (node) {
          if (!isVisible(m)) node.classList.add('hidden');
          frag.appendChild(node);
        }
      }
      msgs.insertBefore(frag, msgs.firstChild);
      // スクロール位置維持: 挿入分の高さだけ下にずらす
      msgs.scrollTop = topBefore + (msgs.scrollHeight - heightBefore);
    }
    reinsertDateDividers();  // N-2: 日付区切り線を再計算
  } catch {
    spinner.remove();
  } finally {
    state.loadingHistory = false;
  }
}

// E-1: 日付区切り線は再計算一本化 (個別挿入ロジックは廃止)。
//   表示中 (hidden でない) メッセージのみを基準に、日付が変わる直前に区切りを挿入。
//   全ての描画変更後 (受信/フィルタ/履歴読込/再描画) の末尾で呼ぶ。
function reinsertDateDividers() {
  const msgs = $('messages');
  msgs.querySelectorAll('.date-divider').forEach(n => n.remove());
  let prevDay = null;
  const msgMap = new Map();
  for (const m of state.messages) msgMap.set(String(m.id) + '#' + m.channel, m);
  for (const node of Array.from(msgs.children)) {
    if (!node.classList.contains('msg')) continue;
    if (node.classList.contains('hidden')) continue;  // 非表示は日付判定から除外
    const key = node.getAttribute('data-id') + '#' + node.getAttribute('data-channel');
    const m = msgMap.get(key);
    if (!m) continue;
    const d = new Date(m.ts * 1000);
    const day = d.toDateString();
    if (day !== prevDay) {
      const wd = ['日','月','火','水','木','金','土'][d.getDay()];
      const label = `${d.getMonth() + 1}月${d.getDate()}日(${wd})`;
      msgs.insertBefore(el('div', { class: 'date-divider' }, label), node);
      prevDay = day;
    }
  }
}

function scrollToBottom() {
  const box = $('messages');
  box.scrollTop = box.scrollHeight;
  state.newSinceScroll = 0;
  state.autoScroll = true;
  $('jumpToBottom').classList.add('hidden');
  // WD-P1d: content-visibility の行高見積り (30px) と実寸の差で最下部から微妙に
  //   浮くことがあるため、描画確定後にもう一度最下部へ合わせる (2段階)。
  requestAnimationFrame(() => {
    if (!state.autoScroll) return;
    box.scrollTop = box.scrollHeight;
    requestAnimationFrame(() => { if (state.autoScroll) box.scrollTop = box.scrollHeight; });
  });
}

function refilterVisible() {
  const map = new Map();
  for (const m of state.messages) map.set(String(m.id) + '#' + m.channel, m);
  const box = $('messages');
  const nodes = box.children;
  for (let i = 0; i < nodes.length; i++) {
    if (!nodes[i].classList.contains('msg')) continue;
    const id = nodes[i].getAttribute('data-id');
    const ch = Number(nodes[i].getAttribute('data-channel'));
    const msg = map.get(id + '#' + ch);
    if (!msg) continue;
    nodes[i].classList.toggle('hidden', !isVisible(msg));
  }
  reinsertDateDividers();  // E-1: フィルタ変更後も日付区切りを再計算
  if (state.autoScroll) scrollToBottom();
}

function rerenderAllMessages() {
  const box = $('messages');
  box.innerHTML = '';
  for (const m of state.messages) {
    const node = renderMsg(m, { live: false });   // R-3/R-4
    if (!node) continue;
    if (!isVisible(m)) node.classList.add('hidden');
    box.appendChild(node);
  }
  reinsertDateDividers();   // E-1
  scrollToBottom();
}

// ─────────── WebSocket ───────────
let ws;
let reconnectDelay = 1000;
function setupWebSocket() {
  const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const url = `${proto}//${window.location.host}/ws${auth}`;
  ws = new WebSocket(url);
  ws.addEventListener('open', () => {
    reconnectDelay = 1000;
    $('statusDot').className = 'dot ok';
    $('wsBanner').classList.add('hidden');
  });
  ws.addEventListener('message', async (ev) => {
    let payload;
    try { payload = JSON.parse(ev.data); } catch { return; }
    if (payload.type === 'hello') {
      // 初回接続 or 再接続の初期送信。live:false (通知は発火させない)
      for (const m of payload.initial || []) appendMsg(m, { live: false });
      if (state.lastSeenId > 0) {
        try {
          const gap = await fetch(withAuth('/api/messages', 'after_id=' + state.lastSeenId + '&limit=500')).then(r => r.json());
          for (const m of gap || []) appendMsg(m, { live: false });
        } catch {}
      }
      scrollToBottom();
      reinsertDateDividers();  // E-1
    } else if (payload.type === 'msg') {
      // ← ここだけが本当のリアルタイム受信
      appendMsg(payload, { live: true });
    }
  });
  ws.addEventListener('close', () => {
    $('statusDot').className = 'dot warn';
    $('statusText').textContent = '接続断: 再接続中...';
    $('wsBanner').classList.remove('hidden');   // U-5: 切断バナー表示
    setTimeout(setupWebSocket, reconnectDelay);
    reconnectDelay = Math.min(reconnectDelay * 2, 15000);
  });
  ws.addEventListener('error', () => { try { ws.close(); } catch {} });
}

// ─────────── ステータス ───────────
async function refreshStatus() {
  try {
    const s = await fetch(withAuth('/api/status')).then(r => r.json());
    updateStatusBar(s);
    const dump = $('statusDump'); if (dump) dump.textContent = JSON.stringify(s, null, 2);
  } catch {}
}
function updateStatusBar(s) {
  // WD-UI: 下部ステータスバーは「ゲーム検出」+ 異常時警告のみの簡素表示 (Yuki指定)。
  //        受信数・WS・取得方式などの詳細は設定モーダル内 (webStatusText) にのみ出す。
  const parts = [];
  parts.push(s.gameProcessDetected ? 'ゲーム: 検出済み' : 'ゲーム: 未検出');
  if (s.captureStalled) parts.push(`キャプチャ停止の可能性 (${s.captureStallMinutes}分以上受信なし)`);
  $('statusText').textContent = parts.join(' | ');
  // W-3: 設定モーダル内には詳細を表示 (縦並びで見やすく)
  const details = [...parts];
  details.push(`受信 ${s.chatMessagesEmitted}`);
  details.push(`WS ${s.wsClients}`);
  if (s.deviceFriendlyName) details.push(`取得方式: ${s.deviceFriendlyName}`);
  const webStatus = $('webStatusText');
  if (webStatus) webStatus.textContent = details.join('\n');
  const dot = $('statusDot');
  if (dot.className.includes('err')) return;
  if (s.captureStalled) dot.className = 'dot warn';
  else dot.className = s.gameProcessDetected ? 'dot ok' : 'dot warn';
}

// ─────────── 検索 ───────────
function hide(id) { $(id).classList.add('hidden'); }
async function doSearch() {
  const q  = $('searchQ').value.trim();
  const ch = $('searchChannel').value;
  const from = toDateInput($('searchFrom').value);
  const to   = toDateInput($('searchTo').value);
  const params = new URLSearchParams();
  if (q) params.set('q', q);
  if (ch) params.set('channel', ch);
  if (from) params.set('from', from);
  if (to)   params.set('to', to + 86399);
  if ($('searchBodyOnly')?.checked) params.set('bodyOnly', 'true');   // WD-UI16
  params.set('limit', '200');
  const rows = await fetch(withAuth('/api/search', params.toString())).then(r => r.json());
  const box = $('searchResults');
  box.innerHTML = '';
  if (!rows.length) { box.appendChild(el('div', { class: 'hint' }, '該当なし')); return; }
  const ignoreMute = $('searchIgnoreMute')?.checked === true;
  for (const m of rows) {
    const node = renderMsg(m, { live: false, ignoreMute });
    if (!node) continue;
    // WD-UI11: ピン一覧と同じ「表示位置へ移動」ボタンを検索結果にも付ける
    const jump = makeJumpButton(m.id);
    node.classList.add('pinned-row');
    node.insertBefore(jump, node.firstChild);
    box.appendChild(node);
  }
}
async function doExport() {
  const q  = $('searchQ').value.trim();
  const ch = $('searchChannel').value;
  const from = toDateInput($('searchFrom').value);
  const to   = toDateInput($('searchTo').value);
  const params = new URLSearchParams();
  if (q) params.set('q', q);
  if (ch) params.set('channel', ch);
  if (from) params.set('from', from);
  if (to)   params.set('to', to + 86399);
  window.open(withAuth('/api/export', params.toString()));
}

// ─────────── 設定 ───────────
async function openSettings() {
  $('cfgTheme').value = state.settings.theme;
  if ($('cfgFontFamily')) {
    // WD-UI10/11b: 未知の値や未設定でも空欄表示にならないよう、必ずいずれかの選択肢に合わせる
    $('cfgFontFamily').value = state.settings.fontFamily || '';
    if ($('cfgFontFamily').selectedIndex < 0) $('cfgFontFamily').selectedIndex = 0;
  }
  // WD-UI2: フォントサイズスライダー初期化 (旧 small/medium/large は px に変換)
  const fsInit = fontSizePx(state.settings.fontSize);
  $('cfgFontSize').value = String(fsInit);
  $('cfgFontSize').style.setProperty('--fill', ((fsInit - 10) / 12 * 100) + '%');
  $('cfgFontSizeLabel').textContent = `${fsInit}px`;
  // WD-UI4: 太さゲージ初期化
  const initBold = (rangeId, labelId, v) => {
    const n = Math.min(10, Math.max(0, v));
    $(rangeId).value = String(n);
    $(rangeId).style.setProperty('--fill', (n * 10) + '%');
    $(labelId).textContent = n === 0 ? '標準' : `+${n}`;
  };
  initBold('cfgSenderBold', 'cfgSenderBoldLabel', state.settings.senderBoldness ?? 0);
  initBold('cfgBodyBold', 'cfgBodyBoldLabel', state.settings.bodyBoldness ?? 0);
  initBold('cfgTimeBold', 'cfgTimeBoldLabel', state.settings.timeBoldness ?? 0);
  $('cfgDisplayStyle').value = state.settings.displayStyle || 'compact';
  if ($('cfgOverlayDisplayStyle')) $('cfgOverlayDisplayStyle').value = state.settings.overlayDisplayStyle || '';
  $('cfgAllowLan').checked = !!state.settings.allowLan;
  $('cfgAlwaysOnTop').checked = !!state.settings.alwaysOnTop;
  const opacityPct = Math.round((state.settings.windowOpacity ?? 1) * 100);
  $('cfgOpacity').value = String(opacityPct);
  $('cfgOpacity').style.setProperty('--fill', opacityPct + '%');
  $('cfgOpacityLabel').textContent = `${opacityPct}%`;
  const ovOpacityPct = Math.round((state.settings.overlayOpacity ?? 0.85) * 100);
  $('cfgOverlayOpacity').value = String(ovOpacityPct);
  $('cfgOverlayOpacity').style.setProperty('--fill', ovOpacityPct + '%');
  $('cfgOverlayOpacityLabel').textContent = `${ovOpacityPct}%`;
  $('cfgOverlayMode').checked = !!state.settings.overlayMode;
  if ($('cfgCloseBehavior')) $('cfgCloseBehavior').value = state.settings.closeBehavior || 'ask';
  if ($('cfgTaskbarBadge')) $('cfgTaskbarBadge').checked = state.settings.taskbarBadge !== false;
  renderRetentionGrid();
  // pinConfirm はピン一覧側で操作 (P-2)

  renderColorGrid();
  renderMuteList();
  // WD-UI17: ルール一覧は廃止 (通知はタブ右クリックから)
  $('cfgMyCharId').value = state.settings.myCharId || 0;
  const nv = Math.round((state.settings.notificationVolume ?? 0.5) * 100);
  $('cfgNotifVol').value = nv; $('cfgNotifVolLabel').textContent = nv + '%';
  $('cfgStall').value = state.settings.captureStallMinutes || 3;
  $('cfgAutoStart').checked = !!state.settings.autoStart;
  $('cfgPreventSleep').checked = !!state.settings.preventSleep;
  // X-2: Web版設定 (localStorage 由来)
  if ($('cfgWebTheme')) $('cfgWebTheme').value = webLocalTheme();
  if ($('cfgWebFontSize')) {
    // WD-UI2: Web版もスライダー化 (旧 small/medium/large は px に変換)
    const wfs = fontSizePx(webLocalFontSize());
    $('cfgWebFontSize').value = String(wfs);
    $('cfgWebFontSize').style.setProperty('--fill', ((wfs - 10) / 12 * 100) + '%');
    const lbl = $('cfgWebFontSizeLabel');
    if (lbl) lbl.textContent = `${wfs}px`;
  }

  try {
    const qrInfo = await fetch(withAuth('/api/qr-url')).then(r => r.json());
    $('qrUrl').textContent = qrInfo.url;
    $('qrImg').src = withAuth('/api/qr') + '&_=' + Date.now();
  } catch {}
  // P-1: ペアリングコード (PC 版のみ)
  if (window.chrome?.webview) {
    try {
      const r = await fetch(withAuth('/api/pairing-code'));
      if (r.ok) {
        const j = await r.json();
        $('pairCodeArea').style.display = '';
        $('pairCodeValue').textContent = j.code;
      }
    } catch {}
  }
  // LAN公開OFFのときは QR に警告
  const disabled = !state.settings.allowLan;
  $('qrImg').style.filter = disabled ? 'grayscale(1) opacity(.4)' : '';
  $('qrDisabledHint').style.display = disabled ? '' : 'none';

  $('settingsView').classList.remove('hidden');
}

// P-4: 保持日数グリッド (チャンネル別)
function renderRetentionGrid() {
  const box = $('cfgRetentionGrid');
  if (!box) return;
  box.innerHTML = '';
  const dict = state.settings.retentionDaysByChannel || {};
  for (const [ch, meta] of Object.entries(CHANNEL_META)) {
    const row = el('div', { class: 'ret-row' });
    row.appendChild(el('span', { class: 'ret-badge', style: `background:${channelColor(ch)}` }));
    row.appendChild(el('span', { class: 'ret-name' }, meta.label));
    const input = el('input', { class: 'ret-days', type: 'number', min: '0', step: '1' });
    input.value = String(dict[ch] ?? 0);
    input.addEventListener('change', () => {
      const v = parseInt(input.value || '0', 10);
      state.settings.retentionDaysByChannel = state.settings.retentionDaysByChannel || {};
      state.settings.retentionDaysByChannel[ch] = v;
      saveSetting({ retentionDaysByChannel: state.settings.retentionDaysByChannel });
    });
    row.appendChild(input);
    row.appendChild(el('span', { style: 'color:var(--muted); font-size:.85em' }, '日'));
    box.appendChild(row);
  }
}

function renderColorGrid() {
  const box = $('cfgColors');
  box.innerHTML = '';
  for (const [ch, meta] of Object.entries(CHANNEL_META)) {
    const row = el('div', { class: 'color-row' });
    const color = channelColor(ch);
    const input = el('input', { type: 'color', value: color });
    input.addEventListener('input', (e) => {
      const colors = { ...(state.settings.channelColors || {}), [ch]: e.target.value };
      state.settings.channelColors = colors;
      rerenderAllMessages();
    });
    input.addEventListener('change', (e) => {
      saveSetting({ channelColors: state.settings.channelColors });
    });
    row.appendChild(input);
    row.appendChild(el('span', { class: 'swatch-label' }, meta.label));
    box.appendChild(row);
  }
}

// P-6: 再起動が必要な設定 (現在は LAN公開のみ。Z-1 で HTTPS 廃止)
const REQUIRES_RESTART_KEYS = new Set(['allowLan', 'port']);

async function saveSetting(patch) {
  Object.assign(state.settings, patch);
  await fetch(withAuth('/api/settings'), {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(patch),
  });
  applyDisplaySettings();

  // P-6: 再起動が必要な項目が含まれていたら、即時に再起動ダイアログ
  const needRestart = Object.keys(patch).some(k => REQUIRES_RESTART_KEYS.has(k));
  if (needRestart && window.chrome?.webview) {
    $('lanRestartHint').style.display = '';
    // 誤操作連続時に何度も出すのを避けるため、直近1秒以内の再表示は抑止
    const now = Date.now();
    if (!state._lastRestartPromptAt || now - state._lastRestartPromptAt > 1000) {
      state._lastRestartPromptAt = now;
      if (await appConfirm('変更を反映するには再起動が必要です。今すぐ再起動しますか?')) {
        postToHost('restart');
      }
    }
  }
}
async function copyUrlToClipboard() {
  const text = $('qrUrl').textContent;
  if (!text || text === '-') return;
  try {
    await navigator.clipboard.writeText(text);
    $('btnCopyUrl').textContent = 'コピー完了';
    setTimeout(() => { $('btnCopyUrl').textContent = 'URLをコピー'; }, 1500);
    return;
  } catch {}
  // http でクリップボード API が使えない環境向けフォールバック
  const ta = document.createElement('textarea');
  ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
  document.body.appendChild(ta); ta.select();
  try { document.execCommand('copy'); $('btnCopyUrl').textContent = 'コピー完了'; setTimeout(() => { $('btnCopyUrl').textContent = 'URLをコピー'; }, 1500); }
  catch { appAlert('コピーに失敗しました。URL欄を長押しして手動でコピーしてください。'); }
  document.body.removeChild(ta);
}

// deletePrivateAll は M-4 で廃止。履歴削除は openPurgeDialog / /api/messages に統合 (M-5)。

function computePurgeBefore() {
  const sel = $('purgeRange').value;
  const now = Math.floor(Date.now() / 1000);
  if (sel === 'week')    return now - 7 * 86400;
  if (sel === 'month')   return now - 30 * 86400;
  if (sel === '3month')  return now - 90 * 86400;
  if (sel === 'all')     return null;
  if (sel === 'custom') {
    const d = $('purgeDate').value;
    if (!d) return null;
    return Math.floor(new Date(d + 'T00:00:00').getTime() / 1000);
  }
  return null;
}

function getPurgeChannels() {
  const chs = [...$('purgeChannels').querySelectorAll('input:checked')].map(cb => cb.value);
  return chs.join(',');
}

async function refreshPurgePreview() {
  const before = computePurgeBefore();
  const includePinned = $('purgeIncludePinned').checked;
  const channels = getPurgeChannels();
  const q = new URLSearchParams();
  if (before != null) q.set('before', String(before));
  q.set('includePinned', includePinned ? 'true' : 'false');
  if (channels) q.set('channels', channels);
  try {
    const r = await fetch(withAuth('/api/messages/count', q.toString()));
    if (r.status === 403) { $('purgePreview').textContent = 'この機能は PC 版でのみ実行できます'; return; }
    const j = await r.json();
    $('purgePreview').textContent = `${j.count} 件を削除します`;
  } catch (e) {
    $('purgePreview').textContent = '件数取得に失敗: ' + e.message;
  }
}

function openPurgeDialog() {
  if (!window.chrome?.webview) {
    appAlert('履歴削除は PC 版でのみ実行できます');
    return;
  }
  $('purgeRange').value = 'week';
  $('purgeIncludePinned').checked = false;
  $('purgeDateLabel').style.display = 'none';
  $('purgeView').classList.remove('hidden');
  refreshPurgePreview();
}

document.addEventListener('DOMContentLoaded', () => {
  const rangeSel = document.getElementById('purgeRange');
  if (!rangeSel) return;
  rangeSel.addEventListener('change', () => {
    $('purgeDateLabel').style.display = $('purgeRange').value === 'custom' ? '' : 'none';
    refreshPurgePreview();
  });
  document.getElementById('purgeDate')?.addEventListener('change', refreshPurgePreview);
  document.getElementById('purgeIncludePinned')?.addEventListener('change', refreshPurgePreview);
  document.getElementById('purgeChannels')?.querySelectorAll('input').forEach(cb => cb.addEventListener('change', refreshPurgePreview));
  document.getElementById('purgeCancel')?.addEventListener('click', () => hide('purgeView'));
  document.getElementById('purgeClose')?.addEventListener('click', () => hide('purgeView'));
  document.getElementById('purgeExecute')?.addEventListener('click', executePurge);
});

async function executePurge() {
  const before = computePurgeBefore();
  const includePinned = $('purgeIncludePinned').checked;
  const channels = getPurgeChannels();
  const q = new URLSearchParams();
  if (before != null) q.set('before', String(before));
  q.set('includePinned', includePinned ? 'true' : 'false');
  if (channels) q.set('channels', channels);
  // 件数再取得して最終確認
  let count = 0;
  try {
    const j = await fetch(withAuth('/api/messages/count', q.toString())).then(r => r.json());
    count = j.count || 0;
  } catch {}
  if (count === 0) { await appAlert('対象がありません'); return; }
  if (!(await appConfirm(`${count} 件を削除します。よろしいですか?(元に戻せません)`))) return;
  const r = await fetch(withAuth('/api/messages', q.toString()), { method: 'DELETE' });
  if (r.status === 403) { await appAlert('この機能は PC 版でのみ実行できます'); return; }
  const j = await r.json().catch(() => ({}));
  if (!r.ok) {
    await appAlert(`削除に失敗しました: ${j?.error || r.status}`);
    return;
  }
  await appAlert(`${j.deleted} 件を削除しました。画面を再読込します。`);
  location.reload();
}

// ─────────── WD-UI21: 条件のかんたん入力 (単語チップ + AND/OR) ───────────
//   正規表現が分かりにくいというYuki要望。単語を追加して AND/OR で結合する方式を既定にする。
//   内部では従来どおり正規表現 1 本に変換して保存する (a|b / (?=.*a)(?=.*b)) ため、
//   保存形式・判定ロジックは不変。開き直すと正規表現を解析してチップに復元し、
//   復元できない複雑な正規表現は「正規表現で直接編集」の表示になる。
const _condState = {};
const _condEscape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const _condUnescape = (s) => s.replace(/\\(.)/g, '$1');

function _condParse(re) {
  if (!re) return { words: [], mode: 'or' };
  if (/^(?:\(\?=\.\*(?:\\.|[^\\)])*\))+$/.test(re)) {   // AND 形式
    const segs = [...re.matchAll(/\(\?=\.\*((?:\\.|[^\\)])*)\)/g)].map(m => m[1]);
    const words = segs.map(_condUnescape);
    return words.every((w, i) => _condEscape(w) === segs[i]) ? { words, mode: 'and' } : null;
  }
  // OR 形式 (単語1個を含む)。エスケープ済み \| を壊さないよう手動で分割する
  const parts = [];
  let cur = '';
  for (let i = 0; i < re.length; i++) {
    const ch = re[i];
    if (ch === '\\' && i + 1 < re.length) { cur += ch + re[++i]; continue; }
    if (ch === '|') { parts.push(cur); cur = ''; continue; }
    cur += ch;
  }
  parts.push(cur);
  const words = parts.map(_condUnescape);
  return parts.every((p, i) => p.length > 0 && _condEscape(words[i]) === p) ? { words, mode: 'or' } : null;
}

function _condCompile(st) {
  if (!st.words.length) return '';
  return st.mode === 'and'
    ? st.words.map(w => `(?=.*${_condEscape(w)})`).join('')
    : st.words.map(_condEscape).join('|');
}

function initCondBuilder(inputId) {
  const input = $(inputId);
  if (!input) return;
  const st = _condState[inputId] = { words: [], mode: 'or', simple: true };
  const wordIn = el('input', { type: 'text', class: 'cond-word', placeholder: '単語を入力して追加' });
  const addBtn = el('button', { class: 'btn small', type: 'button' }, '追加');
  const andBtn = el('button', { class: 'btn small', type: 'button', title: '指定した単語をすべて含む' }, 'AND');
  const orBtn  = el('button', { class: 'btn small', type: 'button', title: '指定した単語のどれかを含む' }, 'OR');
  const row = el('div', { class: 'cond-simple' }, [wordIn, addBtn, el('span', { class: 'cond-mode' }, [andBtn, orBtn])]);
  const chips = el('div', { class: 'cond-chips' });
  const switchBtn = el('button', { class: 'cond-switch', type: 'button' });
  const wrap = el('div', { class: 'cond-builder' }, [row, chips, switchBtn]);
  input.insertAdjacentElement('afterend', wrap);

  const sync = () => {
    input.value = _condCompile(st);
    input.dispatchEvent(new Event('input'));  // 既存連動 (ハイライト有効化など) を発火
  };
  const render = () => {
    row.style.display = st.simple ? '' : 'none';
    chips.style.display = (st.simple && st.words.length) ? '' : 'none';
    input.style.display = st.simple ? 'none' : '';
    switchBtn.textContent = st.simple ? '正規表現で直接編集する' : 'かんたん入力に戻す';
    andBtn.classList.toggle('on', st.mode === 'and');
    orBtn.classList.toggle('on', st.mode === 'or');
    chips.replaceChildren(...st.words.map((w, i) => {
      const x = el('button', { type: 'button', title: 'この単語を削除' }, '×');
      x.addEventListener('click', (e) => { e.preventDefault(); st.words.splice(i, 1); sync(); render(); });
      const chip = el('span', { class: 'cond-chip' }, w);
      chip.appendChild(x);
      return chip;
    }));
  };
  const addWord = () => {
    const w = wordIn.value.trim();
    if (!w) return;
    if (!st.words.includes(w)) st.words.push(w);
    wordIn.value = '';
    sync(); render();
  };
  addBtn.addEventListener('click', (e) => { e.preventDefault(); addWord(); });
  wordIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addWord(); } });
  andBtn.addEventListener('click', (e) => { e.preventDefault(); st.mode = 'and'; sync(); render(); });
  orBtn.addEventListener('click', (e) => { e.preventDefault(); st.mode = 'or'; sync(); render(); });
  switchBtn.addEventListener('click', async (e) => {
    e.preventDefault();
    if (st.simple) {
      st.simple = false;
    } else {
      const parsed = _condParse(input.value.trim());
      if (!parsed) { await appAlert('この正規表現はかんたん入力に変換できないため、正規表現のまま編集してください'); return; }
      Object.assign(st, parsed, { simple: true });
    }
    render();
  });
  st.refresh = () => {
    const parsed = _condParse(input.value.trim());
    if (parsed) Object.assign(st, parsed, { simple: true });
    else st.simple = false;
    wordIn.value = '';
    render();
  };
  render();
}
function condRefresh(inputId) { _condState[inputId]?.refresh?.(); }

// ─────────── タブ編集モーダル ───────────
function openTabEditor(tabId) {
  state.editingTabId = tabId;
  const tab = tabId ? state.settings.tabs.find(t => t.id === tabId) : null;
  const isBuiltin = !!tabId && state.builtinTabIds.has(tabId);
  $('tabEditTitle').textContent = tab
    ? (isBuiltin ? `タブの通知設定: ${tab.name}` : `タブを編集: ${tab.name}`)
    : 'タブを追加';
  $('tabName').value = tab?.name || '';
  $('tabContains').value = tab?.contains || '';
  $('tabDoesNotContain').value = tab?.doesNotContain || '';
  const channels = tab?.channels || [];
  for (const cb of $('tabChannels').querySelectorAll('input[type=checkbox]')) {
    cb.checked = channels.includes(parseInt(cb.value, 10));
    cb.disabled = isBuiltin;   // WD-UI17: 組込タブは対象チャンネル変更不可
  }
  // WD-UI17/17b: 組込タブは基本フィールドを丸ごと非表示 (通知セクションだけ見せる)
  $('tabName').disabled = isBuiltin;
  $('tabContains').disabled = isBuiltin;
  $('tabDoesNotContain').disabled = isBuiltin;
  $('tabBaseFields').style.display = isBuiltin ? 'none' : '';
  $('tabNotifyHeading').style.marginTop = isBuiltin ? '0' : '12px';
  // WD-UI17: 通知フィールド
  $('tabNotifyPattern').value = tab?.notifyPattern || '';
  $('tabUseHighlight').checked = !!tab?.highlight;
  $('tabHighlight').value = tab?.highlight || '#ffd57a';
  $('tabSound').value = tab?.soundId || '';
  $('tabToast').checked = !!tab?.toast;
  $('tabFlash').checked = !!tab?.overlayFlash;
  $('tabCooldown').value = tab?.soundCooldownSec ?? 5;
  if (window._syncTabHighlightEnabled) window._syncTabHighlightEnabled();
  // WD-UI21: かんたん入力チップを現在値から復元
  condRefresh('tabContains');
  condRefresh('tabDoesNotContain');
  condRefresh('tabNotifyPattern');
  // 削除はカスタムタブのみ
  $('tabEditDelete').style.display = (tabId && !isBuiltin) ? '' : 'none';
  $('tabEditView').classList.remove('hidden');
}

async function saveTabEdit() {
  const isBuiltin = !!state.editingTabId && state.builtinTabIds.has(state.editingTabId);

  // WD-UI17: 通知フィールドの読み取り + 検証 (ハイライトはマッチ語必須)
  const notifyPattern = $('tabNotifyPattern').value.trim();
  const useHighlight = $('tabUseHighlight').checked;
  if (useHighlight && !notifyPattern) { await appAlert('行をハイライトするには「マッチ」の入力が必要です'); return; }
  if (notifyPattern) {
    try { new RegExp(notifyPattern); }
    catch { await appAlert('マッチの正規表現が不正です'); return; }
  }
  const applyNotify = (tab) => {
    tab.notifyPattern = notifyPattern || undefined;
    tab.highlight = (useHighlight && notifyPattern) ? $('tabHighlight').value : undefined;
    tab.soundId = $('tabSound').value || undefined;
    tab.toast = $('tabToast').checked || undefined;
    tab.overlayFlash = $('tabFlash').checked || undefined;
    tab.soundCooldownSec = parseInt($('tabCooldown').value || '5', 10);
  };

  if (isBuiltin) {
    // 組込タブ: 通知設定のみ更新 (名前・チャンネル・条件は不変)
    const tab = state.settings.tabs.find(t => t.id === state.editingTabId);
    if (tab) applyNotify(tab);
  } else {
    const name = $('tabName').value.trim();
    if (!name) { await appAlert('タブ名を入力してください'); return; }
    const channels = [...$('tabChannels').querySelectorAll('input[type=checkbox]:checked')].map(cb => parseInt(cb.value, 10));
    if (channels.length === 0) { await appAlert('少なくとも1つチャンネルを選んでください'); return; }
    const contains = $('tabContains').value.trim();
    const doesNotContain = $('tabDoesNotContain').value.trim();

    if (state.editingTabId) {
      const tab = state.settings.tabs.find(t => t.id === state.editingTabId);
      if (tab) {
        tab.name = name;
        tab.channels = channels;
        tab.contains = contains || undefined;
        tab.doesNotContain = doesNotContain || undefined;
        applyNotify(tab);
      }
    } else {
      const tab = {
        id: 'tab_' + Date.now(),
        name, channels,
        contains: contains || undefined,
        doesNotContain: doesNotContain || undefined,
      };
      applyNotify(tab);
      state.settings.tabs.push(tab);
    }
  }
  await saveSetting({ tabs: state.settings.tabs });
  hide('tabEditView');
  renderTabs();
  refilterVisible();
}

async function deleteTabEdit() {
  if (!state.editingTabId) return;
  if (!(await appConfirm('このタブを削除しますか?'))) return;
  if (state.builtinTabIds.has(state.editingTabId)) { await appAlert('組込タブは削除できません'); return; }
  state.settings.tabs = state.settings.tabs.filter(t => t.id !== state.editingTabId);
  if (state.activeTabId === state.editingTabId) state.activeTabId = 'all';
  await saveSetting({ tabs: state.settings.tabs });
  hide('tabEditView');
  renderTabs();
  refilterVisible();
}

// ─────────── WD-UI: confirm/alert 代替のアプリ内ダイアログ ───────────
// ネイティブの window.confirm / window.alert は WebView2/ブラウザが
// 「127.0.0.1:26580 の内容」のようにオリジンをヘッダー表示して不格好なため、
// アプリ内モーダル (#appDialogView) で置き換える。
let _appDialogResolve = null;
function appDialog(msg, { okOnly = false } = {}) {
  return new Promise(resolve => {
    // 万一表示中に再度呼ばれたら前のを false で解決してから出す
    if (_appDialogResolve) { try { _appDialogResolve(false); } catch {} }
    _appDialogResolve = resolve;
    $('appDialogMsg').textContent = msg;
    $('appDialogCancel').style.display = okOnly ? 'none' : '';
    $('appDialogView').classList.remove('hidden');
    $('appDialogOk').focus();
  });
}
function appAlert(msg)   { return appDialog(msg, { okOnly: true }); }
function appConfirm(msg) { return appDialog(msg); }
function _appDialogDone(v) {
  $('appDialogView').classList.add('hidden');
  const r = _appDialogResolve; _appDialogResolve = null;
  if (r) r(v);
}
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('appDialogOk')?.addEventListener('click', () => _appDialogDone(true));
  document.getElementById('appDialogCancel')?.addEventListener('click', () => _appDialogDone(false));
  // Esc でキャンセル扱い
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && !$('appDialogView').classList.contains('hidden')) _appDialogDone(false);
  });
});

// ─────────── WD-UI14: カスタムツールチップ ───────────
// title 属性のネイティブツールチップは、最前面 (TopMost) ウィンドウでは
// ウィンドウの後ろに描画されて隠れてしまうため、ページ内に自前描画する。
// 仕組み: [title] 要素にホバーしたら title を data-tip に退避 (ネイティブ表示を抑止) し、
//         要素の直下に固定表示。以後 title が再設定されても同様に処理される。
function initAppTooltip() {
  const tip = document.createElement('div');
  tip.id = 'appTooltip';
  document.body.appendChild(tip);
  let showTimer = null;
  // WD-UI14d: 下に回したときの被りを避けるため、最新のカーソル位置を保持する。
  let mx = 0, my = 0;
  const CURSOR_H = 24; // 矢印カーソルが hotspot から下方向に占める高さ (実測 ~20px + 余白)
  document.addEventListener('mousemove', (e) => { mx = e.clientX; my = e.clientY; }, true);
  // WD-UI14f: 表示中のツールチップが取り残される問題対策 (Yuki報告)。
  //   アンカー要素が再描画で消える・mouseout を取りこぼす等に備え、
  //   表示中は「アンカーが生きていてカーソルがその上にあるか」を定期検査する。
  let anchorElm = null, watchTimer = null;
  const hideTip = () => {
    clearTimeout(showTimer); clearInterval(watchTimer);
    watchTimer = null; anchorElm = null;
    tip.style.display = 'none';
  };
  document.addEventListener('mouseover', (e) => {
    const elm = e.target.closest?.('[title], [data-tip]');
    if (!elm) return;
    if (elm.hasAttribute('title')) {
      elm.dataset.tip = elm.getAttribute('title');
      elm.removeAttribute('title');
    }
    const text = elm.dataset.tip;
    if (!text) return;
    clearTimeout(showTimer);
    showTimer = setTimeout(() => {
      if (!elm.isConnected) return; // WD-UI14f: 表示待ちの間に再描画で消えた
      anchorElm = elm;
      tip.textContent = text;
      tip.style.display = 'block';
      const r = elm.getBoundingClientRect();
      const tw = tip.offsetWidth, th = tip.offsetHeight;
      let x = Math.min(Math.max(4, r.left + r.width / 2 - tw / 2), window.innerWidth - tw - 4);
      // WD-UI14c: 既定は要素の「上」に出す (下だとマウスカーソルと重なって読めない・Yuki報告)。
      //   上に入りきらない場合のみ下へ回す。
      let y = r.top - th - 8;
      if (y < 4) {
        // WD-UI14e: 上に入らない最上部要素 (タイトルバー・右上ボタン等) は
        //   カーソルの「右下」(入らなければ左下) に出す。縦だけずらす方式は
        //   カーソル実寸 (DPIスケール・カーソルサイズ設定で変動) の見積りに依存して
        //   被りが再発した (Yuki再々報告) ため、横にも必ずずらす。
        x = (mx + 20 + tw <= window.innerWidth - 4) ? mx + 20 : Math.max(4, mx - tw - 8);
        y = Math.min(my + CURSOR_H, window.innerHeight - th - 4);
      }
      tip.style.left = x + 'px';
      tip.style.top = y + 'px';
      // WD-UI14f: 生存監視
      clearInterval(watchTimer);
      watchTimer = setInterval(() => {
        if (!anchorElm || !anchorElm.isConnected) { hideTip(); return; }
        const rr = anchorElm.getBoundingClientRect();
        if (mx < rr.left - 2 || mx > rr.right + 2 || my < rr.top - 2 || my > rr.bottom + 2) hideTip();
      }, 300);
    }, 350);
  });
  document.addEventListener('mouseout', (e) => {
    // WD-UI14f: relatedTarget が無い = ウィンドウ外へ出た。どの要素からでも隠す
    if (!e.relatedTarget || e.target.closest?.('[data-tip]')) hideTip();
  });
  document.addEventListener('scroll', hideTip, true); // WD-UI14f: スクロールでずれたまま残るのを防ぐ
  document.addEventListener('mousedown', hideTip, true);
  window.addEventListener('blur', hideTip);
}
initAppTooltip();

boot();
