'use strict';

/* ============================================================
   Pich2Pich Web — offline-first P2P (WebRTC manual signaling),
   real-time voice (WebRTC audio / standard Opus, cross-platform),
   LINE-like chat (group + direct, persistent + session separators),
   offline map (PMTiles) and PWA.

   Two transports feed one mesh:
     - WebRTC DataChannels (offline, same LAN, connect via codes)
     - Optional WebSocket relay (online / cross-platform w/ Android)
   ============================================================ */

const MAP_ONLINE_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const $ = (id) => document.getElementById(id);
const uuid = () => (crypto.randomUUID ? crypto.randomUUID()
  : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
      const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }));

const store = {
  get: (k, d) => { const v = localStorage.getItem(k); return v === null ? d : v; },
  set: (k, v) => localStorage.setItem(k, v),
  getJSON: (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } },
  setJSON: (k, v) => localStorage.setItem(k, JSON.stringify(v))
};

const codeOf = (sid) => (sid || '').replace(/-/g, '').slice(0, 6).toUpperCase();

const state = {
  id: store.get('p2p_id', null) || (() => { const id = uuid(); store.set('p2p_id', id); return id; })(),
  name: store.get('p2p_name', '我的瀏覽器'),
  relayUrl: store.get('p2p_relay_url', ''),
  relayRoom: store.get('p2p_relay_room', 'team1'),
  manualFloor: parseInt(store.get('p2p_floor', '1'), 10),
  alarmThreshold: parseInt(store.get('p2p_alarm_th', '100'), 10),
  manualDistOverride: store.get('p2p_manual_dist', '0') === '1',
  manualDistM: parseInt(store.get('p2p_manual_dist_m', '50'), 10),
  distOffsetM: parseInt(store.get('p2p_dist_offset', '0'), 10),
  lockedPeer: null,
  lockedPeers: store.getJSON('p2p_locked_peers', []) || [],
  route: store.getJSON('p2p_route', []),
  contactName: store.get('p2p_contact_name', ''),
  contactPhone: store.get('p2p_contact_phone', ''),
  devThreshold: parseInt(store.get('p2p_dev_th', '60'), 10),
  guardEnabled: store.get('p2p_guard', '0') === '1',
  pmtilesVector: store.get('p2p_pmtiles_vector', '0') === '1',
  mapSrc: store.get('p2p_mapsrc', '') || (typeof navigator !== 'undefined' && navigator.onLine && location.protocol === 'https:' ? 'online' : 'blank'),
  mapFollowSelf: true,
  pmtilesPath: store.get('p2p_pmtiles', 'map/region.pmtiles'),
  turnUrl: store.get('p2p_turn_url', ''),
  turnUser: store.get('p2p_turn_user', ''),
  turnCred: store.get('p2p_turn_cred', ''),
  routingUrl: store.get('p2p_routing', 'https://router.project-osrm.org'),
  sosText: store.get('p2p_sos_text', '我需要救援！請協助我！'),
  sosVoiceBlob: store.get('p2p_sos_voice', ''),
  sosVoiceMime: store.get('p2p_sos_voice_mime', 'audio/webm'),
  medCard: store.getJSON('p2p_medcard', { blood: '', allergies: '', notes: '', phone: '' }),
  checkinEnabled: store.get('p2p_checkin', '0') === '1',
  checkinMinutes: parseInt(store.get('p2p_checkin_min', '15'), 10),
  autoReconnect: store.get('p2p_auto_reconnect', '1') !== '0',
  voiceNotifyMode: store.get('p2p_voice_notify', '') || (store.get('p2p_vibrate', '1') === '1' ? 'vibrate' : 'off'),
  keepScreenOn: store.get('p2p_keep_screen', '0') === '1',
  geoMode: store.get('p2p_geo_mode', 'normal'),
  meshTtl: parseInt(store.get('p2p_mesh_ttl', '6'), 10),
  meshBridge: store.get('p2p_mesh_bridge', '0') === '1',
  connMode: store.get('p2p_conn_mode', 'auto'),
  markerColor: store.get('p2p_marker_color', '#25d366'),
  markerNick: store.get('p2p_marker_nick', ''),
  recentPeers: store.getJSON('p2p_recent_peers', []),
  chatInRoom: false,
  lastKnown: store.getJSON('p2p_last_known', {}),
  rallyPoints: store.getJSON('p2p_rally', []),
  peerTracks: store.getJSON('p2p_peer_tracks', {}),
  showPeerTracks: store.get('p2p_show_tracks', '1') !== '0',
  fixedAppUrl: store.get('p2p_fixed_app_url', ''),
  fixedRelayUrl: store.get('p2p_fixed_relay', ''),
  offlineMode: typeof navigator !== 'undefined' ? !navigator.onLine : false,
  offlineFirst: store.get('p2p_offline_first', '0') === '1',
  mixedTeam: store.get('p2p_mixed_team', '0') === '1',
  auxRelay: store.get('p2p_aux_relay', '0') === '1',
  autoRelay: store.get('p2p_auto_relay', '1') !== '0',
  audioRoute: store.get('p2p_audio_route', 'speaker'),
  selfTracks: store.getJSON('p2p_self_track', []),
  heading: null,
  // runtime
  self: null,
  peers: {},                                   // sid -> {name, lat, lon, floor, ts}
  contacts: store.getJSON('p2p_contacts', {}), // sid -> {sid, code, name}
  convs: store.getJSON('p2p_convs', { group: [] }),
  unread: store.getJSON('p2p_unread', {}),
  activeConv: 'group',
  chatLinkLost: null,
  seen: new Set(),
  editingRoute: false
};

/* ===================== MAP ===================== */
const STALE_MS = 30000;
let relayPausedForP2p = false;

function hasOpenRtc() {
  return rtc.conns.some((c) => c.dc && c.dc.readyState === 'open');
}
const PEER_TRACK_MS = 30 * 60 * 1000;
const PEER_TRACK_PALETTE = ['#ef6c00', '#42a5f5', '#ab47bc', '#ffb74d', '#26c6da', '#66bb6a'];
let map, mapInited = false, selfMarker = null;
const peerMarkers = {};
const rallyMarkers = [];
let rallyFlashMarker = null;
let routeMarkers = [];
let pmProtocol = null;

function offlineStyle(url) {
  return {
    version: 8,
    sources: { off: { type: 'raster', url: 'pmtiles://' + url, tileSize: 256 } },
    layers: [
      { id: 'bg', type: 'background', paint: { 'background-color': '#0a160f' } },
      { id: 'off', type: 'raster', source: 'off' }
    ]
  };
}

function vectorOfflineStyle(url) {
  const src = 'pm';
  if (window.basemaps && typeof window.basemaps.layers === 'function') {
    return {
      version: 8,
      glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
      sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/light',
      sources: {
        [src]: {
          type: 'vector',
          url: 'pmtiles://' + url,
          attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
        }
      },
      layers: window.basemaps.layers(src, window.basemaps.namedFlavor('light'), { lang: 'zh' })
    };
  }
  return {
    version: 8,
    sources: { [src]: { type: 'vector', url: 'pmtiles://' + url } },
    layers: [
      { id: 'bg', type: 'background', paint: { 'background-color': '#e8e4df' } },
      { id: 'water', type: 'fill', source: src, 'source-layer': 'water', paint: { 'fill-color': '#99c8f0' } },
      { id: 'earth', type: 'fill', source: src, 'source-layer': 'earth', paint: { 'fill-color': '#e8e4df' } },
      { id: 'roads', type: 'line', source: src, 'source-layer': 'roads', paint: { 'line-color': '#888', 'line-width': 1 } }
    ]
  };
}

function blankStyle() {
  return {
    version: 8,
    sources: {},
    layers: [
      { id: 'bg', type: 'background', paint: { 'background-color': '#0a160f' } },
      { id: 'grid', type: 'background', paint: {
        'background-pattern': undefined,
        'background-color': '#0a160f'
      } }
    ]
  };
}
function gridStyle() {
  // 可見格線底圖（無需網路），讓使用者知道地圖區域存在
  return {
    version: 8,
    sources: {
      grid: {
        type: 'geojson',
        data: {
          type: 'FeatureCollection',
          features: [-180, -90, 0, 90, 180].flatMap((lng) =>
            [-80, -40, 0, 40, 80].map((lat) => ({
              type: 'Feature',
              geometry: { type: 'Point', coordinates: [lng, lat] },
              properties: {}
            }))
          )
        }
      }
    },
    layers: [
      { id: 'bg', type: 'background', paint: { 'background-color': '#0a160f' } },
      { id: 'grid-dots', type: 'circle', source: 'grid', paint: {
        'circle-radius': 1.2, 'circle-color': '#2c5a47', 'circle-opacity': 0.55
      } }
    ]
  };
}
function currentStyle() {
  if (state.mapSrc === 'blank' || state.mapSrc === 'grid') return gridStyle();
  if (state.mapSrc === 'offline' && state.pmtilesPath) {
    return state.pmtilesVector ? vectorOfflineStyle(state.pmtilesPath) : offlineStyle(state.pmtilesPath);
  }
  return MAP_ONLINE_STYLE;
}

function ensureMap() {
  if (mapInited && map) {
    map.resize();
    return;
  }
  if (!window.maplibregl) { showMapHint('地圖程式庫未載入'); return; }
  if (window.pmtiles) {
    pmProtocol = new pmtiles.Protocol();
    maplibregl.addProtocol('pmtiles', pmProtocol.tile);
  }
  map = new maplibregl.Map({ container: 'map', style: currentStyle(), center: [121.5654, 25.0330], zoom: 9, attributionControl: false });
  map.on('error', () => {
    if (state.mapSrc === 'online') {
      state.mapSrc = 'blank';
      store.set('p2p_mapsrc', 'blank');
      map.setStyle(gridStyle());
      showMapHint('街道圖失敗，已改格線深綠色底');
    }
  });
  map.addControl(new maplibregl.NavigationControl(), 'top-left');
  map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }), 'top-left');
  map.on('dragstart', () => { state.mapFollowSelf = false; });
  map.on('zoomstart', (e) => {
    if (e && e.originalEvent) state.mapFollowSelf = false;
  });
  map.on('load', () => {
    ensureRouteLayer();
    if (typeof updateGeofenceMapLayer === 'function') updateGeofenceMapLayer();
    if (typeof refreshGeofenceCenterMarker === 'function') refreshGeofenceCenterMarker();
    if (typeof bindGeofenceMapPick === 'function') bindGeofenceMapPick();
    if (typeof wireGeofenceMapClick === 'function') wireGeofenceMapClick();
    map.resize();
    loadLastKnownPeers(); renderPeerList();
    if (state.self) setSelfOnMap(state.self.lat, state.self.lon);
    redrawPeerTracks();
    updateMapHint();
  });
  map.on('style.load', () => { ensureRouteLayer(); map.resize(); });
  mapInited = true;
  updateMapHint();
  requestAnimationFrame(() => { if (map) map.resize(); });
  setTimeout(() => { if (map) map.resize(); }, 200);
}

function updateMapHint() {
  if (!map) return;
  if (state.mapSrc === 'online') showMapHint('街道地圖');
  else if (state.mapSrc === 'offline') showMapHint('離線地圖');
  else showMapHint('格線底圖 · 按📍定位');
}

function initMap() { ensureMap(); }

function ensureRouteLayer() {
  if (!map.getSource('route')) {
    map.addSource('route', { type: 'geojson', data: emptyFC() });
    map.addLayer({ id: 'route-line', type: 'line', source: 'route', paint: { 'line-color': '#1565C0', 'line-width': 4 } });
  }
  ensurePeerTrackLayer();
  map.off('click', onMapClick); map.on('click', onMapClick);
  redrawRoute(); redrawRally(); redrawPeerTracks();
}

function trackColor(sid) {
  let h = 0;
  for (let i = 0; i < sid.length; i++) h = (h * 31 + sid.charCodeAt(i)) >>> 0;
  return PEER_TRACK_PALETTE[h % PEER_TRACK_PALETTE.length];
}

function ensurePeerTrackLayer() {
  if (!map || !map.getStyle()) return;
  if (!map.getSource('peer-tracks')) {
    map.addSource('peer-tracks', { type: 'geojson', data: emptyFC() });
    map.addLayer({
      id: 'peer-tracks-line', type: 'line', source: 'peer-tracks',
      paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.72 }
    }, map.getLayer('route-line') ? 'route-line' : undefined);
  }
}

function recordPeerTrack(sid, lat, lon, ts) {
  if (!sid || sid === state.id || lat == null || lon == null) return;
  if (!state.peerTracks[sid]) state.peerTracks[sid] = [];
  const t = ts || Date.now();
  const arr = state.peerTracks[sid];
  const last = arr[arr.length - 1];
  if (last && distanceM(last.lat, last.lon, lat, lon) < 3) return;
  arr.push({ lat, lon, ts: t });
  const cut = Date.now() - PEER_TRACK_MS;
  state.peerTracks[sid] = arr.filter(p => p.ts >= cut);
  store.setJSON('p2p_peer_tracks', state.peerTracks);
  if (state.showPeerTracks) redrawPeerTracks();
}

function prunePeerTracks() {
  const cut = Date.now() - PEER_TRACK_MS;
  let changed = false;
  Object.keys(state.peerTracks).forEach(sid => {
    const next = state.peerTracks[sid].filter(p => p.ts >= cut);
    if (next.length !== state.peerTracks[sid].length) { state.peerTracks[sid] = next; changed = true; }
    if (!next.length) { delete state.peerTracks[sid]; changed = true; }
  });
  if (changed) { store.setJSON('p2p_peer_tracks', state.peerTracks); redrawPeerTracks(); }
}

function redrawPeerTracks() {
  if (!map || !map.getSource('peer-tracks')) return;
  if (!state.showPeerTracks) {
    map.getSource('peer-tracks').setData(emptyFC());
    return;
  }
  const features = [];
  Object.keys(state.peerTracks).forEach(sid => {
    const pts = state.peerTracks[sid];
    if (pts.length < 2) return;
    features.push({
      type: 'Feature',
      properties: { color: trackColor(sid), sid },
      geometry: { type: 'LineString', coordinates: pts.map(p => [p.lon, p.lat]) }
    });
  });
  map.getSource('peer-tracks').setData({ type: 'FeatureCollection', features });
}
function onMapClick(e) {
  const pt = { lat: e.lngLat.lat, lon: e.lngLat.lng };
  if (abMode) {
    if (abMode === 1) { abA = pt; abMode = 2; $('abStatus').textContent = '已選起點 A，請在地圖點終點 B'; return; }
    if (abMode === 2) { abB = pt; abMode = 0; planRoute(abA, abB); return; }
  }
  if (rallyPickMode) { rallyPickMode = false; setRallyPoint(pt.lat, pt.lon, '集合點'); showMapHint('已設定集合點'); return; }
  if (state.editingRoute) { state.route.push(pt); persistRoute(); redrawRoute(); }
}

/* A→B real road routing (OSRM online; offline falls back to straight A→B) */
let abMode = 0, abA = null, abB = null, rallyPickMode = false;
function startAB() { abMode = 1; abA = abB = null; state.editingRoute = false; $('editRouteBtn').textContent = '手動編輯'; $('abStatus').textContent = '請在地圖點起點 A'; }
function abFromHere() { if (!state.self) { alert('尚無定位'); return; } abA = { ...state.self }; abMode = 2; $('abStatus').textContent = '起點=目前位置，請在地圖點終點 B'; }
async function planRoute(a, b) {
  $('abStatus').textContent = '沿道路規劃中…';
  try {
    const base = (state.routingUrl || 'https://router.project-osrm.org').replace(/\/$/, '');
    const url = `${base}/route/v1/driving/${a.lon},${a.lat};${b.lon},${b.lat}?overview=full&geometries=geojson`;
    const j = await (await fetch(url)).json();
    if (j.routes && j.routes[0]) {
      state.route = j.routes[0].geometry.coordinates.map(c => ({ lat: c[1], lon: c[0] }));
      persistRoute(); redrawRoute();
      $('abStatus').textContent = `已沿實際道路規劃，約 ${(j.routes[0].distance / 1000).toFixed(2)} 公里（${state.route.length} 點）`;
      return;
    }
    throw new Error('no route');
  } catch (err) {
    state.route = [a, b]; persistRoute(); redrawRoute();
    $('abStatus').textContent = '無法連線路網服務（離線），已用 A→B 直線。';
  }
}

/* QR display + camera scanning */
function qrLib() {
  if (typeof window !== 'undefined' && window.qrcode) return window.qrcode;
  try { if (typeof qrcode !== 'undefined') return qrcode; } catch {}
  return null;
}
window.qrLib = qrLib;
function renderQrCanvas(qr, cellSize, margin) {
  const modules = qr.getModuleCount();
  if (!cellSize) {
    if (modules > 50) cellSize = 5;
    else if (modules > 35) cellSize = 6;
    else cellSize = 7;
  }
  margin = margin == null ? 4 : margin;
  const px = modules * cellSize + margin * 2;
  const cvs = document.createElement('canvas');
  cvs.width = px;
  cvs.height = px;
  const ctx = cvs.getContext('2d');
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, px, px);
  ctx.fillStyle = '#000000';
  for (let r = 0; r < modules; r++) {
    for (let c = 0; c < modules; c++) {
      if (qr.isDark(r, c)) ctx.fillRect(margin + c * cellSize, margin + r * cellSize, cellSize, cellSize);
    }
  }
  cvs.className = 'qr-canvas';
  return cvs;
}
window.renderQrCanvas = renderQrCanvas;
let lastQrPayload = '';
function showQrLoading(title, hint) {
  $('qrTitle').textContent = title || '產生中…';
  $('qrImg').innerHTML = '<div class="qr-loading">⏳</div>';
  const h = $('qrHint');
  if (h) h.textContent = hint || '建立 WebRTC 連線碼中…';
  const ta = $('qrCodeText');
  if (ta) ta.value = '';
  $('qrModal').classList.remove('hidden');
}
function qrScanHint(len) {
  if (len <= 450) return `共 ${len} 字 · 可掃 QR 或複製貼上`;
  if (len <= 950) return `共 ${len} 字 · 可試掃 QR（光線足、距離近），或複製貼上`;
  return `共 ${len} 字 · QR 可能太密，請複製下方代碼貼給對方`;
}
function showQR(title, text) {
  text = (text || '').trim();
  if (!text || text === '產生中…' || text.startsWith('失敗')) return alert('請先產生代碼');
  lastQrPayload = text;
  const ta = $('qrCodeText');
  if (ta) ta.value = text;
  const run = () => {
    const Qr = qrLib();
    if (!Qr) return alert('QR 程式庫未載入，請重新整理');
    const hint = $('qrHint');
    const el = $('qrImg');
    try {
      if (text.length > 1800) {
        el.innerHTML = '<div class="qr-no-img">代碼較長，無法產生 QR<br>請用下方「複製代碼」</div>';
      } else {
        const ec = text.length > 700 ? 'L' : 'M';
        const qr = Qr(0, ec);
        qr.addData(text);
        qr.make();
        el.innerHTML = '';
        el.appendChild(renderQrCanvas(qr));
      }
      $('qrTitle').textContent = title;
      if (hint) hint.textContent = qrScanHint(text.length);
      $('qrModal').classList.remove('hidden');
    } catch (e) {
      el.innerHTML = '<div class="qr-no-img">QR 產生失敗<br>請用下方代碼複製貼上</div>';
      if (hint) hint.textContent = qrScanHint(text.length);
      $('qrModal').classList.remove('hidden');
    }
  };
  if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
  else run();
}
function showMyCodeQR() {
  showQR('我的6碼：' + myCode(), 'P2P:' + myCode());
}

async function connectFromScannedCode(code) {
  code = (code || '').trim();
  if (!code) throw new Error('空的 QR 內容');
  if (typeof joinTeamFromInvite === 'function') {
    try {
      if (code.includes('TEAM') || code.includes('TEAM_INVITE')) {
        await joinTeamFromInvite(code);
        showPage('walkie');
        return 'team';
      }
      const dec = typeof decodeSignal === 'function' ? await decodeSignal(code) : null;
      if (dec && dec.t === 'TEAM') {
        await joinTeamFromInvite(code);
        showPage('walkie');
        return 'team';
      }
    } catch {}
  }
  if (code.startsWith('P2P:')) {
    const peer = code.slice(4).trim().toUpperCase();
    if (peer && peer !== myCode()) {
      if (typeof shouldConnectRelay === 'function' && !shouldConnectRelay()) {
        setDialStatus('無網路：請掃「邀請 QR」或到對講頁貼配對碼（6碼需中繼）', 'err');
        alert('無網路時不能用6碼自動連線。\n\n請對方按「產生我的邀請 QR」，你再掃描；或到對講頁→貼上配對碼。');
        return 'need-offer';
      }
      noteRecentPeer(peer);
      dial(peer);
      return 'dial';
    }
  }
  if (rtc.pendingOffer) {
    $('answerIn').value = code;
    await acceptAnswer(code);
    setDialStatus('掃描連線完成', 'ok');
    updateRelayStatusLine();
    renderRtcPeers();
    showPage('walkie');
    return 'answer';
  }
  $('offerIn').value = code;
  const ans = await makeAnswer(code);
  $('answerOut').value = ans;
  showQR('回應碼（給邀請方）', ans);
  setDialStatus('第2步：請把回應碼複製給邀請方，對方再貼上配對碼', 'busy');
  if (typeof isOfflineMode === 'function' && isOfflineMode()) {
    alert('第 1 輪完成！\n\n請按「複製代碼」把回應碼傳給對方（藍牙/快速分享）。\n\n對方（邀請方）再按「貼上配對碼」貼上，才會真正連線。');
  }
  renderRtcPeers();
  return 'offer';
}

let scanStream = null, scanRAF = 0, scanBusy = false, scanLastDetect = 0;
let scanDetector = null;
let scanCanvas = null;
let scanCtx = null;
let scanDigitalZoom = 1.5;
let scanPassIdx = 0;
let scanLastRaw = '';
let scanLastRawAt = 0;
let scanHitCount = 0;

function isPairingCode(raw) {
  const s = (raw || '').trim();
  if (/^P2P:[A-Z0-9]{4,10}$/i.test(s)) return true;
  if (/^[CR][A-Za-z0-9_-]{16,}/.test(s)) return true;
  return false;
}

function noteScanConfirm(raw) {
  if (!isPairingCode(raw)) return false;
  const now = Date.now();
  if (raw === scanLastRaw && now - scanLastRawAt < 1500) scanHitCount++;
  else { scanLastRaw = raw; scanHitCount = 1; }
  scanLastRawAt = now;
  if (/^P2P:/i.test(raw)) return scanHitCount >= 2;
  return scanHitCount >= 1;
}

function resetScanConfirm() {
  scanLastRaw = '';
  scanHitCount = 0;
}

function enhanceImageDataForQr(img) {
  const d = img.data;
  let lo = 255, hi = 0;
  for (let i = 0; i < d.length; i += 4) {
    const g = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114) | 0;
    if (g < lo) lo = g;
    if (g > hi) hi = g;
  }
  const span = Math.max(1, hi - lo);
  const out = new Uint8ClampedArray(d.length);
  for (let i = 0; i < d.length; i += 4) {
    const g = (d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114);
    const v = ((g - lo) * 255 / span) | 0;
    out[i] = out[i + 1] = out[i + 2] = v;
    out[i + 3] = 255;
  }
  return new ImageData(out, img.width, img.height);
}

function jsQrDecode(imgData) {
  const jsqr = jsQrFn();
  if (!jsqr) return null;
  const opts = { inversionAttempts: 'attemptBoth' };
  let r = jsqr(imgData.data, imgData.width, imgData.height, opts);
  if (r) return r.data;
  r = jsqr(enhanceImageDataForQr(imgData).data, imgData.width, imgData.height, opts);
  return r ? r.data : null;
}

function canUseCameraScan() {
  return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}

function jsQrFn() {
  if (typeof jsQR === 'function') return jsQR;
  if (typeof window.jsQR === 'function') return window.jsQR;
  return null;
}

function initScanDetector() {
  scanDetector = null;
  if ('BarcodeDetector' in window) {
    try { scanDetector = new BarcodeDetector({ formats: ['qr_code'] }); } catch {}
  }
  return !!scanDetector || !!jsQrFn();
}

function setScanStatus(msg) {
  const el = $('scanStatus');
  if (el) el.textContent = msg || '';
}

function hdScanVideoConstraints(deviceId) {
  const video = {
    width: { ideal: 3840, min: 1920 },
    height: { ideal: 2160, min: 1080 },
    frameRate: { ideal: 30, max: 30 }
  };
  if (deviceId) video.deviceId = { exact: deviceId };
  else video.facingMode = { ideal: 'environment' };
  return { audio: false, video };
}

async function boostScanTrack(track) {
  if (!track || !track.applyConstraints) return;
  try {
    const cap = typeof track.getCapabilities === 'function' ? track.getCapabilities() : {};
    const adv = [];
    if (cap.focusMode && cap.focusMode.includes('continuous')) adv.push({ focusMode: 'continuous' });
    const wMax = cap.width && cap.width.max ? Math.min(cap.width.max, 3840) : 1920;
    const hMax = cap.height && cap.height.max ? Math.min(cap.height.max, 2160) : 1080;
    await track.applyConstraints({
      width: { ideal: wMax, min: Math.min(wMax, 1920) },
      height: { ideal: hMax, min: Math.min(hMax, 1080) },
      ...(adv.length ? { advanced: adv } : {})
    });
  } catch {
    try {
      await track.applyConstraints({
        width: { ideal: 1920 },
        height: { ideal: 1080 },
        advanced: [{ focusMode: 'continuous' }]
      });
    } catch {}
  }
}

function ensureScanCanvas(w, h) {
  if (!scanCanvas) {
    scanCanvas = document.createElement('canvas');
    scanCtx = scanCanvas.getContext('2d', { willReadFrequently: true });
  }
  scanCanvas.width = w;
  scanCanvas.height = h;
  scanCtx.imageSmoothingEnabled = false;
  return scanCanvas;
}

async function decodeQrRegion(v, sx, sy, sw, sh, upscale) {
  if (!sw || !sh) return null;
  upscale = upscale || 1;
  const dw = Math.min(Math.max(Math.round(sw * upscale), sw), 2560);
  const dh = Math.min(Math.max(Math.round(sh * upscale), sh), 2560);
  ensureScanCanvas(dw, dh);
  scanCtx.imageSmoothingEnabled = false;
  if (upscale > 1.01) {
    const tmp = document.createElement('canvas');
    tmp.width = sw;
    tmp.height = sh;
    const tctx = tmp.getContext('2d');
    tctx.imageSmoothingEnabled = false;
    tctx.drawImage(v, sx, sy, sw, sh, 0, 0, sw, sh);
    scanCtx.drawImage(tmp, 0, 0, sw, sh, 0, 0, dw, dh);
  } else {
    scanCtx.drawImage(v, sx, sy, sw, sh, 0, 0, dw, dh);
  }
  if (scanDetector) {
    try {
      if (typeof createImageBitmap === 'function') {
        const bmp = await createImageBitmap(scanCanvas);
        const codes = await scanDetector.detect(bmp);
        if (codes && codes.length) return codes[0].rawValue;
      }
      const codes = await scanDetector.detect(scanCanvas);
      if (codes && codes.length) return codes[0].rawValue;
    } catch {}
  }
  const img = scanCtx.getImageData(0, 0, dw, dh);
  return jsQrDecode(img);
}

async function acquireCameraStream() {
  const tries = [
    () => navigator.mediaDevices.getUserMedia(hdScanVideoConstraints()),
    () => navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920, min: 1280 }, height: { ideal: 1080, min: 720 } } }),
    () => navigator.mediaDevices.getUserMedia({ audio: false, video: { facingMode: 'environment' } }),
    () => navigator.mediaDevices.getUserMedia({ audio: false, video: true })
  ];
  let err;
  for (const fn of tries) {
    try { return await fn(); }
    catch (e) { err = e; }
  }
  try {
    const devices = await navigator.mediaDevices.enumerateDevices();
    const cams = devices.filter(d => d.kind === 'videoinput');
    const back = cams.find(d => /back|rear|後|environment/i.test(d.label || ''));
    const pick = back || cams[cams.length - 1];
    if (pick && pick.deviceId) {
      return await navigator.mediaDevices.getUserMedia(hdScanVideoConstraints(pick.deviceId));
    }
  } catch (e) { err = e; }
  throw err || new Error('無法開啟相機');
}

function beginCameraRequest() {
  return acquireCameraStream();
}

async function acquireAndroidCameraStream() {
  return acquireCameraStream();
}

function waitVideoReady(v, ms) {
  return new Promise((resolve) => {
    if (v.videoWidth > 0 && v.videoHeight > 0) return resolve();
    const done = () => { v.removeEventListener('loadedmetadata', done); resolve(); };
    v.addEventListener('loadedmetadata', done);
    setTimeout(resolve, ms || 3000);
  });
}

let scanTorchOn = false;
let scanVideoTrack = null;

async function attachScanStream(stream) {
  const v = $('scanVideo');
  if (!v) throw new Error('掃描介面未就緒');
  scanVideoTrack = stream.getVideoTracks && stream.getVideoTracks()[0];
  if (scanVideoTrack) await boostScanTrack(scanVideoTrack);
  v.setAttribute('playsinline', 'true');
  v.setAttribute('webkit-playsinline', 'true');
  v.muted = true;
  v.autoplay = true;
  v.srcObject = stream;
  await waitVideoReady(v);
  try { await v.play(); } catch {}
  if (!v.videoWidth) await new Promise(r => setTimeout(r, 500));
  const res = scanVideoTrack && scanVideoTrack.getSettings ? scanVideoTrack.getSettings() : {};
  const w = v.videoWidth || res.width || '?';
  const h = v.videoHeight || res.height || '?';
  setScanStatus(`對準框內 QR（${w}×${h}）· 越近越清楚`);
  setupScanTorchBtn(scanVideoTrack);
}

function setupScanTorchBtn(track) {
  const btn = $('scanTorchBtn');
  if (!btn) return;
  scanTorchOn = false;
  let canTorch = false;
  try {
    const cap = track && track.getCapabilities ? track.getCapabilities() : {};
    canTorch = !!(cap.torch || (cap.advanced || []).some(a => 'torch' in a));
  } catch {}
  btn.classList.toggle('hidden', !canTorch);
  btn.textContent = '🔦 開啟手電筒';
  btn.onclick = async () => {
    if (!track) return;
    scanTorchOn = !scanTorchOn;
    try {
      await track.applyConstraints({ advanced: [{ torch: scanTorchOn }] });
      btn.textContent = scanTorchOn ? '🔦 關閉手電筒' : '🔦 開啟手電筒';
    } catch {
      btn.classList.add('hidden');
    }
  };
}

function scanRegionsForFrame(vw, vh) {
  const z = scanDigitalZoom;
  const regions = [];
  const cx = vw * 0.5;
  const cy = vh * 0.5;
  const bw = vw / z;
  const bh = vh / z;
  regions.push({ x: Math.max(0, cx - bw / 2), y: Math.max(0, cy - bh / 2), w: bw, h: bh, upscale: z });
  [0.32, 0.45, 0.6, 0.78, 1].forEach((frac) => {
    const w = vw * frac;
    const h = vh * frac;
    regions.push({
      x: (vw - w) / 2,
      y: (vh - h) / 2,
      w,
      h,
      upscale: frac < 0.5 ? 2 : (frac < 0.7 ? 1.5 : 1),
    });
  });
  regions.push({ x: 0, y: 0, w: vw, h: vh, upscale: 1 });
  return regions;
}

async function detectQrFromVideo(v) {
  const vw = v.videoWidth;
  const vh = v.videoHeight;
  if (!vw || !vh) return null;
  const regions = scanRegionsForFrame(vw, vh);
  const start = scanPassIdx % regions.length;
  scanPassIdx++;
  for (let i = 0; i < regions.length; i++) {
    const r = regions[(start + i) % regions.length];
    const raw = await decodeQrRegion(v, Math.round(r.x), Math.round(r.y), Math.round(r.w), Math.round(r.h), r.upscale);
    if (raw && noteScanConfirm(raw)) return raw;
  }
  return null;
}

async function decodeQrFromImageSource(src) {
  const w = src.videoWidth || src.naturalWidth || src.width;
  const h = src.videoHeight || src.naturalHeight || src.height;
  if (!w || !h) return null;
  const scales = [1, 1.25, 1.5, 0.85, 0.7];
  for (const sc of scales) {
    const cw = Math.min(Math.round(w * sc), 3200);
    const ch = Math.min(Math.round(h * sc), 3200);
    ensureScanCanvas(cw, ch);
    scanCtx.imageSmoothingEnabled = sc > 1;
    scanCtx.drawImage(src, 0, 0, w, h, 0, 0, cw, ch);
    if (scanDetector) {
      try {
        const bmp = typeof createImageBitmap === 'function' ? await createImageBitmap(scanCanvas) : scanCanvas;
        const codes = await scanDetector.detect(bmp);
        if (codes && codes.length && isPairingCode(codes[0].rawValue)) return codes[0].rawValue;
      } catch {}
    }
    const img = scanCtx.getImageData(0, 0, cw, ch);
    const raw = jsQrDecode(img);
    if (raw && isPairingCode(raw)) return raw;
  }
  return null;
}

async function handleScannedRaw(raw) {
  if (!isPairingCode(raw)) {
    setScanStatus('辨識到非配對碼，請對準 Pich2Pich QR');
    return;
  }
  closeScanner();
  scanBusy = true;
  const off = typeof needsOfflinePairing === 'function' && needsOfflinePairing();
  setDialStatus(off ? '區網配對中…（不需外網）' : '掃描成功，連線中…', 'busy');
  try {
    await connectFromScannedCode(raw);
  } catch (e) {
    setDialStatus('掃描連線失敗：' + e.message, 'err');
    alert('連線失敗：' + e.message);
  } finally {
    scanBusy = false;
  }
}

function cameraErrorMessage(e) {
  const n = e && e.name;
  if (n === 'NotAllowedError' || n === 'PermissionDeniedError') {
    return '相機權限被拒絕。\n\nAndroid Chrome：網址列左側 🔒 → 權限 → 相機 → 允許，然後重新整理再試。';
  }
  if (n === 'NotFoundError' || n === 'DevicesNotFoundError') return '找不到相機，可改選相簿中的 QR 照片';
  if (n === 'NotReadableError') return '相機被其他 App 佔用，請關閉相機 App 後重試';
  if (n === 'SecurityError' || n === 'NotSupportedError') return '此頁面無法使用相機（需 HTTPS 安全連線）';
  return '開啟相機失敗：' + ((e && e.message) || e || '未知錯誤');
}

function startScanLoop(v) {
  const interval = /Android/i.test(navigator.userAgent) ? 240 : 180;
  const tick = async (t) => {
    if (!scanStream) return;
    if (t - scanLastDetect < interval) { scanRAF = requestAnimationFrame(tick); return; }
    scanLastDetect = t;
    if (!v.videoWidth) { scanRAF = requestAnimationFrame(tick); return; }
    try {
      const raw = await detectQrFromVideo(v);
      if (raw) { await handleScannedRaw(raw); return; }
    } catch {}
    scanRAF = requestAnimationFrame(tick);
  };
  resetScanConfirm();
  scanPassIdx = 0;
  scanRAF = requestAnimationFrame(tick);
}

function setScanDigitalZoom(z) {
  scanDigitalZoom = z;
  document.querySelectorAll('.scan-zoom-btn').forEach((b) => {
    b.classList.toggle('active', Number(b.dataset.zoom) === z);
  });
  const labels = { 1: '廣角', 1.5: '標準', 2.5: '近距放大' };
  setScanStatus(`模式：${labels[z] || z} · 對準框內 QR`);
}

function bindScanZoomButtons() {
  const map = [
    ['scanZoomWide', 1],
    ['scanZoomNormal', 1.5],
    ['scanZoomNear', 2.5],
  ];
  map.forEach(([id, z]) => {
    const btn = $(id);
    if (!btn) return;
    btn.dataset.zoom = String(z);
    btn.onclick = () => setScanDigitalZoom(z);
  });
  setScanDigitalZoom(scanDigitalZoom);
}

function startScanOnUserGesture() {
  if (scanBusy) return;
  if (!canUseCameraScan()) {
    if (typeof openPasteCodeModal === 'function') return openPasteCodeModal('auto');
    return alert('此裝置不支援相機掃描，請貼上配對碼');
  }
  if (!initScanDetector()) {
    if (typeof openPasteCodeModal === 'function') return openPasteCodeModal('auto');
    return alert('無法辨識 QR，請貼上配對碼');
  }
  if (scanStream) closeScanner();
  resetScanConfirm();
  setScanStatus('正在開啟相機…請點「允許」');
  $('scanModal').classList.remove('hidden');
  const streamPromise = beginCameraRequest();
  finishOpenScanner(streamPromise);
}

async function finishOpenScanner(streamPromise) {
  try {
    scanStream = await streamPromise;
    const v = $('scanVideo');
    await attachScanStream(scanStream);
    setScanStatus($('scanStatus').textContent || '對準 QR 碼，自動辨識');
    startScanLoop(v);
  } catch (e) {
    closeScanner();
    alert(cameraErrorMessage(e));
    if (typeof openPasteCodeModal === 'function' && confirm('改用手動貼上配對碼？')) openPasteCodeModal('auto');
  }
}

function bindScanTrigger(el) {
  if (!el || el._scanBound) return;
  el._scanBound = true;
  const go = (e) => {
    e.preventDefault();
    e.stopPropagation();
    startScanOnUserGesture();
  };
  el.addEventListener('click', go, { passive: false });
}

async function openScanner(targetId, after) {
  if (!canUseCameraScan() || !initScanDetector()) {
    return alert('此瀏覽器不支援掃描，請用相機 App 掃 QR 後貼上。');
  }
  try {
    $('scanModal').classList.remove('hidden');
    setScanStatus('對準 QR 碼');
    scanStream = await beginCameraRequest();
    const v = $('scanVideo');
    await attachScanStream(scanStream);
    const tick = async () => {
      if (!scanStream) return;
      try {
        const raw = await detectQrFromVideo(v);
        if (raw) {
          closeScanner();
          if (targetId) $(targetId).value = raw;
          if (after) {
            try { await after(raw); } catch (e) { alert('失敗：' + e.message); }
          }
          return;
        }
      } catch {}
      scanRAF = requestAnimationFrame(tick);
    };
    scanRAF = requestAnimationFrame(tick);
  } catch (e) { alert(cameraErrorMessage(e)); closeScanner(); }
}

async function scanQrFromPhotoFile(file) {
  if (!file) return;
  setScanStatus('辨識照片中…');
  const url = URL.createObjectURL(file);
  try {
    const img = new Image();
    await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = url; });
    const raw = await decodeQrFromImageSource(img);
    if (!raw) {
      alert('照片中找不到 QR 碼，請換一張或改貼配對碼');
      setScanStatus('對準 QR 碼，自動辨識');
      return;
    }
    await handleScannedRaw(raw);
  } catch (e) {
    alert('讀取照片失敗：' + (e.message || e));
    setScanStatus('對準 QR 碼，自動辨識');
  } finally {
    URL.revokeObjectURL(url);
  }
}

function closeScanner() {
  if (scanRAF) cancelAnimationFrame(scanRAF);
  scanRAF = 0;
  if (scanVideoTrack && scanTorchOn) {
    try { scanVideoTrack.applyConstraints({ advanced: [{ torch: false }] }); } catch {}
  }
  scanTorchOn = false;
  scanVideoTrack = null;
  if (scanStream) { scanStream.getTracks().forEach(t => t.stop()); scanStream = null; }
  const v = $('scanVideo');
  if (v) v.srcObject = null;
  const torch = $('scanTorchBtn');
  if (torch) torch.classList.add('hidden');
  $('scanModal').classList.add('hidden');
  setScanStatus('對準 QR 碼，自動辨識');
}
window.openScannerAuto = startScanOnUserGesture;

async function applyMapSource(file) {
  try {
    if (state.mapSrc === 'offline' && file && window.pmtiles) {
      const pm = new pmtiles.PMTiles(new pmtiles.FileSource(file));
      pmProtocol.add(pm);
      state.pmtilesPath = file.name;
      store.set('p2p_pmtiles', state.pmtilesPath);
    }
    map.setStyle(currentStyle());
    showMapHint(state.mapSrc === 'offline' ? '離線地圖模式：' + state.pmtilesPath : '線上街道地圖');
  } catch (err) { showMapHint('套用失敗：' + err.message); }
}

async function applyPmtilesBlob(blob, showHint) {
  if (!blob || !window.pmtiles) throw new Error('無法載入離線圖');
  if (typeof ensureMap === 'function') ensureMap();
  if (!map) throw new Error('地圖未就緒');
  const file = blob instanceof File ? blob : new File([blob], 'local.pmtiles', { type: 'application/octet-stream' });
  const pm = new pmtiles.PMTiles(new pmtiles.FileSource(file));
  if (pmProtocol) pmProtocol.add(pm);
  let vector = false;
  try {
    const h = await pm.getHeader();
    vector = h.tileType === 1;
  } catch {}
  state.pmtilesVector = vector;
  store.set('p2p_pmtiles_vector', vector ? '1' : '0');
  state.pmtilesPath = 'local.pmtiles';
  state.mapSrc = 'offline';
  store.set('p2p_pmtiles', state.pmtilesPath);
  store.set('p2p_mapsrc', 'offline');
  store.set('p2p_pmtiles_cached', '1');
  const off = $('mapOffline');
  if (off) off.checked = true;
  map.setStyle(currentStyle());
  const kind = vector ? '向量 Protomaps' : '光柵 PMTiles';
  if (showHint !== false) showMapHint(`已切換離線 ${kind}（斷網可用）`);
}
window.applyPmtilesBlob = applyPmtilesBlob;

const emptyFC = () => ({ type: 'FeatureCollection', features: [] });
function showMapHint(t) { const el = $('mapHint'); el.textContent = t; el.classList.remove('hidden'); clearTimeout(showMapHint._t); showMapHint._t = setTimeout(() => el.classList.add('hidden'), 4000); }

function setSelfOnMap(lat, lon) {
  if (!map) return;
  const ll = [lon, lat];
  const color = (typeof ROLE_COLORS !== 'undefined' && ROLE_COLORS[state.teamRole]) || state.markerColor || '#25d366';
  const nick = state.roleDisplayName || state.markerNick || '我';
  const label = nick.slice(0, 2);
  if (!selfMarker) {
    selfMarker = new maplibregl.Marker({ element: markerEl(color, label) }).setLngLat(ll).addTo(map);
    if ($('page-map') && !$('page-map').classList.contains('hidden')) {
      // First fix: center without forcing street overzoom (offline tiles often max ~12–14)
      const z = Math.min(Math.max(map.getZoom(), 12), map.getMaxZoom ? map.getMaxZoom() : 14);
      map.easeTo({ center: ll, zoom: z });
    }
  } else {
    selfMarker.setLngLat(ll);
    const el = selfMarker.getElement();
    if (el) { el.style.background = color; el.textContent = label; }
    if (state.mapFollowSelf) map.easeTo({ center: ll }); // pan only
  }
}
function setPeerOnMap(sid, p) {
  if (p.lat == null || p.lon == null) return;
  const stale = Date.now() - (p.ts || 0) > STALE_MS;
  const ll = [p.lon, p.lat];
  const tag = stale ? ' (最後已知)' : '';
  const roleTag = (p.roleName || (typeof ROLE_LABELS !== 'undefined' && ROLE_LABELS[p.role]) || '').trim();
  const label = (roleTag || p.name) + (p.floor != null ? ` ${p.floor}F` : '') + tag;
  const color = stale ? '#888' : ((typeof peerMarkerColor === 'function' ? peerMarkerColor(p) : null) || '#ef6c00');
  const txt = stale ? '?' : ((typeof ROLE_LABELS !== 'undefined' && p.role && ROLE_LABELS[p.role]) ? ROLE_LABELS[p.role][0] : '人');
  if (!peerMarkers[sid]) {
    peerMarkers[sid] = new maplibregl.Marker({ element: markerEl(color, txt, stale) }).setLngLat(ll).setPopup(new maplibregl.Popup().setHTML(`<b>${escapeHtml(p.name)}</b>${roleTag ? ' · ' + escapeHtml(roleTag) : ''}${tag}<br>${stale ? '最後更新：' + new Date(p.ts).toLocaleString('zh-TW') : ''}`)).addTo(map);
  } else {
    peerMarkers[sid].setLngLat(ll);
    const el = peerMarkers[sid].getElement();
    if (el) { el.style.background = color; el.textContent = txt; el.style.borderStyle = stale ? 'dashed' : 'solid'; }
    peerMarkers[sid].getPopup().setHTML(`<b>${escapeHtml(p.name)}</b>${roleTag ? ' · ' + escapeHtml(roleTag) : ''}${tag}<br>${stale ? '最後更新：' + new Date(p.ts).toLocaleString('zh-TW') : ''}`);
  }
}
function markerEl(color, txt, stale) {
  const el = document.createElement('div');
  el.style.cssText = `width:26px;height:26px;border-radius:50%;background:${color};color:#05291c;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)${stale ? ';border-style:dashed' : ''}`;
  el.textContent = txt; return el;
}
function redrawRoute() {
  if (!map || !map.getSource('route')) return;
  const coords = state.route.map(p => [p.lon, p.lat]);
  map.getSource('route').setData(coords.length >= 2 ? { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords } }] } : emptyFC());
  routeMarkers.forEach(m => m.remove());
  routeMarkers = state.route.map((p, i) => new maplibregl.Marker({ element: markerEl('#1565C0', String(i + 1)) }).setLngLat([p.lon, p.lat]).addTo(map));
  $('routeCount').textContent = state.route.length;
}

/* ===================== WebRTC (offline P2P, manual signaling) ===================== */
const rtc = {
  conns: [],            // { id, pc, dc, audioTx, sid, name, audioEl }
  pendingOffer: null,   // pc waiting for an answer
  micStream: null, micTrack: null
};
function lanRtcMode() {
  if (navigator.onLine === false) return true;
  if (typeof needsOfflinePairing === 'function' && needsOfflinePairing()) return true;
  return false;
}
function iceServers() {
  if (lanRtcMode()) return [];
  const list = [{ urls: 'stun:stun.l.google.com:19302' }];
  if (state.turnUrl) {
    list.push({ urls: state.turnUrl, username: state.turnUser, credential: state.turnCred });
  } else if (location.protocol === 'https:') {
    list.push({ urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' });
    list.push({ urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' });
    list.push({ urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' });
  }
  return list;
}

function setDialStatus(msg, kind) {
  const el = $('walkieDialStatus');
  if (!el) return;
  el.textContent = msg || '';
  el.className = 'dial-status small' + (kind ? ' ' + kind : '');
}
function refreshMyCodeUI() {
  const code = myCode();
  ['myCode', 'myCodeWalkie'].forEach((id) => { const el = $(id); if (el) el.textContent = code; });
  const badge = $('myCodeBadge');
  if (badge) badge.textContent = code;
  const nameEl = $('walkieDeviceName');
  if (nameEl) nameEl.textContent = '本機：' + (state.name || '我的瀏覽器');
}
function updateRelayStatusLine() {
  const on = ws && ws.readyState === WebSocket.OPEN;
  const connecting = typeof relayConnecting !== 'undefined' && relayConnecting;
  const p2p = hasMeshLink() && !on;
  const code = myCode();
  const connected = `中繼：已連線 · 我的6碼 ${code}（房間 ${state.relayRoom}）`;
  const offlineP2p = `近距 P2P 已連線 · 我的6碼 ${code}（無需網路）`;
  const relayBusy = `正在連中繼… · 我的6碼 ${code}`;
  const modeOff = `WiFi 已連 · 中繼未連 · 我的6碼 ${code} · 按「重新連中繼」`;
  const noNet = `無行動數據 · 請連同一熱點後「貼上配對碼」`;
  const disconnected = `尚未連線 · 我的6碼 ${code} · 輸入6碼或掃 QR 配對`;
  const off = typeof needsOfflinePairing === 'function' ? needsOfflinePairing() : (typeof isOfflineMode === 'function' && isOfflineMode());
  const noBrowserNet = navigator.onLine === false;
  const text = on ? connected : (p2p ? offlineP2p : (connecting ? relayBusy : (off ? (noBrowserNet ? noNet : modeOff) : disconnected)));
  const el = $('walkieRelayLine');
  if (el) {
    el.textContent = text;
    el.style.color = (on || p2p) ? '#81c784' : (connecting ? '#ffb74d' : '#ef9a9a');
  }
  refreshMyCodeUI();
}

function hasNearLink() {
  if (ws && ws.readyState === WebSocket.OPEN) return true;
  if (rtc.conns.some(c => c.dc && c.dc.readyState === 'open')) return true;
  return false;
}

function hasLoraLink() {
  return typeof window.meshBleLinked === 'function' && window.meshBleLinked();
}

function isLoraWireType(type) {
  return ['TEXT', 'TELEMETRY', 'ACK', 'RALLY', 'RALLY_CALL', 'SOS'].includes(type);
}

function hasMeshLink() {
  if (hasNearLink()) return true;
  // Match APK: LoRa only counts when auto-fallback is on (BLE linked alone ≠ deliverable path).
  if (state.loraAutoFallback && hasLoraLink()) return true;
  return false;
}

window.hasNearLink = hasNearLink;
window.hasLoraLink = hasLoraLink;
window.isLoraWireType = isLoraWireType;

function rtcAudioReady() {
  return rtc.conns.some(c => c.pc && c.pc.connectionState === 'connected');
}

function useLiveRtcVoice() {
  return rtcAudioReady();
}

function choosePttVoiceMode() {
  if (useLiveRtcVoice()) return 'webrtc';
  if (state.mixedTeam || state.teamId) {
    if (hasMeshLink()) return 'relay';
  }
  if (ws && ws.readyState === WebSocket.OPEN) return 'relay';
  return null;
}

async function applyPttVoiceMode(mode) {
  if (mode === 'relay') {
    if (rtc.micTrack) rtc.micTrack.enabled = false;
    await startPttStream();
    pttVoiceMode = 'relay';
    window.pttVoiceMode = pttVoiceMode;
    updateVoiceRxStatus('語音：中繼片段', 'ok');
    setDialStatus('語音片段傳送中（中繼）…', 'ok');
  } else if (mode === 'webrtc') {
    stopPttStream();
    await ensureMic();
    rtc.micTrack.enabled = true;
    pttVoiceMode = 'webrtc';
    window.pttVoiceMode = pttVoiceMode;
    updateVoiceRxStatus('語音：WebRTC 即時', 'ok');
    setDialStatus('即時語音中（WebRTC）…', 'ok');
  }
}

async function switchPttOnRelayDrop() {
  const ptt = $('pttBtn');
  if (!ptt || !ptt.classList.contains('active') || pttVoiceMode !== 'relay') return;
  if (useLiveRtcVoice()) {
    await applyPttVoiceMode('webrtc');
    setDialStatus('中繼斷線，已自動切換 WebRTC 語音', 'ok');
  } else {
    stopPttStream();
    pttVoiceMode = null;
    window.pttVoiceMode = null;
    ptt.classList.remove('active');
    ptt.textContent = '按住說話';
    setDialStatus('中繼斷線且無 P2P 語音通道', 'err');
  }
}

function preferOfflineRtcAudio() {
  return useLiveRtcVoice();
}

function newPc() {
  const lan = lanRtcMode();
  const pc = new RTCPeerConnection({
    iceServers: iceServers(),
    iceCandidatePoolSize: lan ? 2 : 0,
    bundlePolicy: 'max-bundle',
    rtcpMuxPolicy: 'require',
  });
  const conn = { id: uuid(), pc, dc: null, audioTx: null, sid: null, name: '夥伴', audioEl: null, _iceRetried: false };
  pc.onconnectionstatechange = () => {
    const st = pc.connectionState;
    if (st === 'connected') { setDialStatus('語音通道已就緒！到對講機 → 啟用喇叭 → 按住說話', 'ok'); tryPlayRemoteAudio(); renderRtcPeers(); updateRtcStatus(); updateRelayStatusLine(); }
    else if (st === 'connecting') setDialStatus('正在建立語音連線…', 'busy');
    else if (['failed', 'closed', 'disconnected'].includes(st)) {
      if (st === 'failed') {
        const hint = lanRtcMode() ? '若用熱點：兩台靠近、關 AP 隔離或換一台當熱點' : '請重新配對或檢查網路';
        setDialStatus('語音連線失敗，' + hint, 'err');
      }
      removeConn(conn); renderRtcPeers(); updateRtcStatus();
    }
  };
  pc.oniceconnectionstatechange = () => {
    const ice = pc.iceConnectionState;
    if (ice === 'failed') {
      if (!conn._iceRetried && typeof pc.restartIce === 'function') {
        conn._iceRetried = true;
        setDialStatus('區網穿透重試中…', 'busy');
        try { pc.restartIce(); } catch {}
        return;
      }
      setDialStatus(lanRtcMode() ? '區網連線失敗，請重新掃 QR/貼碼' : '語音穿透失敗（NAT），請重新連線', 'err');
    } else if (ice === 'disconnected') {
      setDialStatus('連線不穩定，請靠近或檢查 Wi‑Fi…', 'busy');
    }
  };
  pc.ontrack = (e) => {
    attachRemoteAudio(conn, e.streams[0]);
  };
  rtc.conns.push(conn);
  return conn;
}
function setupDc(conn, dc) {
  conn.dc = dc;
  dc.onopen = () => {
    sendOn(conn, { type: 'HELLO' });
    if (rtc.micTrack && conn.audioTx) conn.audioTx.sender.replaceTrack(rtc.micTrack).catch(() => {});
    renderRtcPeers(); updateRtcStatus(); tryPlayRemoteAudio(); updateRelayStatusLine();
    if (window.flushOfflineQueue) flushOfflineQueue();
    if (window.updateHealthUI) updateHealthUI();
    if (!state.meshBridge && ws && ws.readyState === WebSocket.OPEN) {
      relayPausedForP2p = true;
      disconnectRelay();
    }
  };
  dc.onclose = () => {
    const sid = conn.sid;
    const name = conn.name;
    renderRtcPeers(); updateRtcStatus();
    if (relayPausedForP2p && !hasOpenRtc()) {
      relayPausedForP2p = false;
      connectRelay(true);
    }
    if (sid) onPeerLinkLost(sid, name);
  };
  dc.onmessage = (e) => handleWire(e.data, conn.id);
}
function removeConn(conn) {
  const sid = conn.sid;
  const name = conn.name;
  const i = rtc.conns.indexOf(conn); if (i >= 0) rtc.conns.splice(i, 1);
  try { conn.pc.close(); } catch {}
  if (conn.audioEl) conn.audioEl.remove();
  updateRelayStatusLine();
  if (sid) onPeerLinkLost(sid, name);
}
function iceComplete(pc) {
  const ms = lanRtcMode() ? 8000 : 2000;
  return new Promise(res => {
    if (pc.iceGatheringState === 'complete') return res();
    const t = setTimeout(res, ms);
    pc.addEventListener('icegatheringstatechange', () => { if (pc.iceGatheringState === 'complete') { clearTimeout(t); res(); } });
  });
}

/* signal encode with optional deflate compression (smaller QR / codes) */
function bytesToB64(b) { let s = ''; for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); return btoa(s); }
function b64ToBytes(b64) { const s = atob(b64), a = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) a[i] = s.charCodeAt(i); return a; }
async function deflate(str) { if (!window.CompressionStream) return null; const cs = new CompressionStream('deflate-raw'); const w = cs.writable.getWriter(); w.write(new TextEncoder().encode(str)); w.close(); return bytesToB64(new Uint8Array(await new Response(cs.readable).arrayBuffer())); }
async function inflate(b64) { const ds = new DecompressionStream('deflate-raw'); const w = ds.writable.getWriter(); w.write(b64ToBytes(b64)); w.close(); return new TextDecoder().decode(new Uint8Array(await new Response(ds.readable).arrayBuffer())); }
function minifySdp(sdp) {
  if (!sdp || typeof sdp !== 'string') return sdp;
  const lan = lanRtcMode();
  const lines = sdp.split(/\r\n|\n/);
  const out = [];
  let skipSection = false;
  let hostUdp = 0;
  let hostTcp = 0;
  const maxHostUdp = lan ? 4 : 2;
  const maxHostTcp = lan ? 2 : 1;
  for (const line of lines) {
    if (!line) continue;
    if (line.startsWith('m=')) {
      skipSection = line.startsWith('m=video');
      if (!skipSection) out.push(line);
      continue;
    }
    if (skipSection) continue;
    if (line.startsWith('a=candidate:')) {
      const typM = line.match(/ typ (\w+) /);
      const typ = typM ? typM[1] : '';
      if (lan && typ !== 'host') continue;
      if (typ === 'host') {
        const isTcp = line.includes(' TCP ');
        if (isTcp) { if (hostTcp >= maxHostTcp) continue; hostTcp++; }
        else { if (hostUdp >= maxHostUdp) continue; hostUdp++; }
      } else if (lan) continue;
      out.push(line);
      continue;
    }
    if (/^a=(extmap:|rtcp-fb:|ssrc-group:|msid:|rtcp-mux-only|ice-options:trickle|fmtp:63 )/.test(line)) continue;
    if (line.startsWith('a=ssrc:') && line.includes('msid:')) continue;
    out.push(line);
  }
  const joined = out.join('\r\n');
  return joined.endsWith('\r\n') ? joined : joined + '\r\n';
}
function b64ToB64url(b64) { return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function b64urlToB64(u) {
  let s = u.replace(/-/g, '+').replace(/_/g, '/');
  while (s.length % 4) s += '=';
  return s;
}
async function encodeSignal(d) {
  const json = JSON.stringify({ t: d.type, s: minifySdp(d.sdp) });
  const c = await deflate(json);
  return c ? 'C' + b64ToB64url(c) : 'R' + b64ToB64url(btoa(unescape(encodeURIComponent(json))));
}
async function decodeSignal(code) {
  code = (code || '').trim();
  const f = code[0], body = code.slice(1);
  let json;
  if (f === 'C') json = await inflate(b64urlToB64(body));
  else if (f === 'R') json = decodeURIComponent(escape(atob(b64urlToB64(body))));
  else json = decodeURIComponent(escape(atob(code)));
  const o = JSON.parse(json);
  return { type: o.t, sdp: o.s };
}

async function buildOffer() {
  await ensureMic();
  rtc.micTrack.enabled = false;
  const conn = newPc();
  conn.audioTx = conn.pc.addTransceiver(rtc.micTrack, { direction: 'sendrecv' });
  setupDc(conn, conn.pc.createDataChannel('data'));
  await conn.pc.setLocalDescription(await conn.pc.createOffer());
  await iceComplete(conn.pc);
  return conn;
}
async function buildAnswer(offerCode) {
  await ensureMic();
  rtc.micTrack.enabled = false;
  const conn = newPc();
  conn.pc.ondatachannel = (e) => setupDc(conn, e.channel);
  await conn.pc.setRemoteDescription(await decodeSignal(offerCode));
  conn.audioTx = conn.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'audio' || t.sender?.track?.kind === 'audio');
  if (conn.audioTx?.sender) await conn.audioTx.sender.replaceTrack(rtc.micTrack);
  await conn.pc.setLocalDescription(await conn.pc.createAnswer());
  await iceComplete(conn.pc);
  return conn;
}
async function makeOffer() { const conn = await buildOffer(); rtc.pendingOffer = conn; return await encodeSignal(conn.pc.localDescription); }
async function makeAnswer(offerCode) { const conn = await buildAnswer(offerCode); return await encodeSignal(conn.pc.localDescription); }
async function acceptAnswer(answerCode) { if (!rtc.pendingOffer) throw new Error('請先建立邀請碼'); await rtc.pendingOffer.pc.setRemoteDescription(await decodeSignal(answerCode)); rtc.pendingOffer = null; }

/* auto-signaling over relay: connect by knowing the peer's short code */
const rtcDialing = {}; // peerCode -> conn (pending auto offers)
const myCode = () => codeOf(state.id);
function signalSend(obj) { obj.kind = 'SIG'; obj.from = myCode(); if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); else alert('自動連線需先連中繼（區網本機伺服器即可，免網際網路）'); }
async function makeOfferToCode(peerCode, peerName) {
  setDialStatus('正在連線 ' + peerCode + (peerName ? ' (' + peerName + ')' : '') + '…', 'busy');
  if (ws && ws.readyState === WebSocket.OPEN) {
    await dial(peerCode);
    return;
  }
  setDialStatus('離線：請與 ' + peerCode + ' 互掃 QR（產生邀請 → 兩輪配對）', 'warn');
}
window.makeOfferToCode = makeOfferToCode;

async function dial(peerCode) {
  peerCode = (peerCode || '').trim().toUpperCase();
  if (!peerCode) { setDialStatus('請輸入對方的6碼代碼', 'err'); return; }
  if (peerCode === myCode()) { setDialStatus('不能連線自己', 'err'); return; }
  if (typeof shouldConnectRelay === 'function' && !shouldConnectRelay()) {
    setDialStatus('無網路：請掃邀請 QR 或到對講頁貼配對碼', 'err');
    return;
  }
  if (!(ws && ws.readyState === WebSocket.OPEN)) {
    setDialStatus('中繼未連線。請到「設定」→ 連線中繼，或重新整理頁面', 'err');
    return;
  }
  // 只保留一條 WebRTC，避免重複連線堆疊成 8 條
  rtc.conns.slice().forEach(c => removeConn(c));
  Object.keys(rtcDialing).forEach(k => delete rtcDialing[k]);
  const btn = $('dialBtn');
  if (btn) { btn.disabled = true; btn.textContent = '連線中…'; }
  setDialStatus('正在向 ' + peerCode + ' 發送邀請…', 'busy');
  try {
    const conn = await buildOffer();
    rtcDialing[peerCode] = conn;
    signalSend({ sig: 'offer', to: peerCode, data: await encodeSignal(conn.pc.localDescription) });
    noteRecentPeer(peerCode);
    store.set('p2p_last_peer', peerCode);
    setDialStatus('已發送邀請，等待 ' + peerCode + ' 回應…（對方也須開啟同一網址）', 'busy');
  } catch (e) {
    setDialStatus('連線失敗：' + (e.message || e), 'err');
  } finally {
    if (btn) { btn.disabled = false; btn.textContent = '連線'; }
  }
}
async function onSignal(msg) {
  if (msg.to !== myCode()) return;
  try {
    if (msg.sig === 'offer') {
      if (rtc.conns.some(c => c.dc && c.dc.readyState === 'open')) {
        setDialStatus('已與夥伴連線，忽略重複邀請', 'ok');
        return;
      }
      setDialStatus('收到來自 ' + msg.from + ' 的連線邀請…', 'busy');
      noteRecentPeer(msg.from);
      const conn = await buildAnswer(msg.data);
      signalSend({ sig: 'answer', to: msg.from, data: await encodeSignal(conn.pc.localDescription) });
      setDialStatus('已回應 ' + msg.from + '，正在建立連線…', 'busy');
    } else if (msg.sig === 'answer') {
      const conn = rtcDialing[msg.from];
      if (conn) {
        await conn.pc.setRemoteDescription(await decodeSignal(msg.data));
        delete rtcDialing[msg.from];
        setDialStatus('對方已回應，正在建立連線…', 'busy');
      }
    }
  } catch (e) {
    setDialStatus('信令失敗：' + (e.message || e), 'err');
  }
}

function sendOn(conn, msg) {
  msg.id = msg.id || uuid(); msg.ttl = msg.ttl ?? state.meshTtl; msg.sid = state.id; msg.name = state.name; msg.ts = msg.ts || Date.now();
  if (conn.dc && conn.dc.readyState === 'open') conn.dc.send(JSON.stringify(msg));
}

/* mic / realtime voice */
function attachRemoteAudio(conn, stream) {
  if (!conn.audioEl) {
    const el = document.createElement('audio');
    el.autoplay = true;
    el.playsInline = true;
    el.setAttribute('playsinline', '');
    el.setAttribute('webkit-playsinline', '');
    el.volume = 1;
    document.body.appendChild(el);
    conn.audioEl = el;
  }
  conn.audioEl.srcObject = stream;
  tryPlayRemoteAudio();
}
function tryPlayRemoteAudio() {
  let needUnlock = false;
  rtc.conns.forEach(c => {
    if (!c.audioEl || !c.audioEl.srcObject) return;
    c.audioEl.play().catch(() => { needUnlock = true; });
  });
  const btn = $('audioUnlockBtn');
  if (btn) btn.classList.toggle('hidden', audioUnlocked && !needUnlock);
  if (needUnlock) updateVoiceRxStatus('請點「啟用喇叭」才能聽到語音', 'err');
}
function unlockRemoteAudio() {
  audioUnlocked = true;
  rtc.conns.forEach(c => { if (c.audioEl) c.audioEl.play().catch(() => {}); });
  if (pttRxAudio) {
    pttRxAudio.play().catch(() => {});
    pttRxAudio.pause();
    pttRxAudio.currentTime = 0;
  }
  const btn = $('audioUnlockBtn');
  if (btn) btn.classList.add('hidden');
  updateVoiceRxStatus('喇叭已啟用', 'ok');
  setDialStatus('喇叭已啟用，請按住說話測試', 'ok');
  drainVoiceQueue();
}

async function ensureMic() {
  if (rtc.micTrack) return;
  rtc.micStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
  rtc.micTrack = rtc.micStream.getAudioTracks()[0];
  rtc.micTrack.enabled = false;
  rtc.conns.forEach(c => { if (c.audioTx) c.audioTx.sender.replaceTrack(rtc.micTrack).catch(() => {}); });
}
function updateRtcStatus() {
  const n = rtc.conns.filter(c => c.dc && c.dc.readyState === 'open').length;
  $('rtcLabel').textContent = '🟢 ' + n;
}
function renderRtcPeers() {
  const open = rtc.conns.filter(c => c.dc && c.dc.readyState === 'open');
  const empty = '<div class="muted">尚未建立 WebRTC 連線</div>';
  const renderBox = (box) => {
    if (!box) return;
    if (!open.length) { box.innerHTML = empty; return; }
    box.innerHTML = '';
    open.forEach(c => {
      const d = document.createElement('div'); d.className = 'peer-item';
      d.innerHTML = `<span>${escapeHtml(c.name)} <span class="muted">(${c.sid ? codeOf(c.sid) : '握手中'})</span></span>`;
      const b = document.createElement('button'); b.className = 'btn ghost small'; b.textContent = '中斷';
      b.onclick = () => { removeConn(c); renderRtcPeers(); updateRtcStatus(); };
      d.appendChild(b); box.appendChild(d);
    });
  };
  renderBox($('rtcPeers'));
  renderBox($('rtcPeersWalkie'));
}

/* ===================== Relay (optional, online) ===================== */
let ws = null;
let relayRetryTimer = null;
let relayOpenTimer = null;
let relayConnecting = false;
const onlinePeers = {}; // code -> name (from relay presence)

function sameOriginRelayUrl() {
  const h = location.hostname;
  if (!h || h === 'localhost' || h === '127.0.0.1') return '';
  if (h.endsWith('.web.app') || h.endsWith('.firebaseapp.com')) return '';
  if (location.protocol === 'https:' || /^\d+\.\d+\.\d+\.\d+$/.test(h)) return `wss://${location.host}`;
  return '';
}
function guessRelayUrl() { return sameOriginRelayUrl(); }

function applyRelayUrlOnBoot() {
  let url = sameOriginRelayUrl();
  if (!url) url = (state.fixedRelayUrl || store.get('p2p_fixed_relay', '') || state.relayUrl || store.get('p2p_relay_url', '') || '').trim();
  if (!url) return;
  state.relayUrl = url;
  store.set('p2p_relay_url', url);
  const inp = $('relayUrl');
  if (inp) inp.value = url;
}
function tryAutoReconnectPeer() {
  if (!state.autoReconnect) return;
  const code = (store.get('p2p_last_peer', '') || '').toUpperCase();
  if (!code || code === myCode()) return;
  if (rtc.conns.some(c => c.dc && c.dc.readyState === 'open')) return;
  if (!(ws && ws.readyState === WebSocket.OPEN)) return;
  if (!onlinePeers[code]) return;
  setDialStatus('正在自動重新連線 ' + code + '…', 'busy');
  dial(code);
}
function showConnectBanner() {
  const el = $('connectBanner');
  if (!el) return;
  if (!window.isSecureContext && location.protocol === 'http:' && location.hostname !== 'localhost') {
    el.classList.remove('hidden');
    document.body.classList.add('has-banner');
    el.innerHTML = '⚠️ 請改用 <b>https://' + location.hostname + ':8000</b> 開啟（警告頁按「進階→繼續」）。<b>http</b> 無法中繼連線。';
    return;
  }
  document.body.classList.remove('has-banner');
  applyRelayUrlOnBoot();
  el.classList.add('hidden');
}
function connectRelay(force) {
  if (!force && typeof shouldConnectRelay === 'function' && !shouldConnectRelay()) {
    updateRelayStatusLine();
    return;
  }
  applyRelayUrlOnBoot();
  if (!state.relayUrl) { setDialStatus('請用 https 網址開啟（見 公開網址.txt）', 'err'); return; }
  if (!force && ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
  if (relayRetryTimer) { clearTimeout(relayRetryTimer); relayRetryTimer = null; }
  if (relayOpenTimer) { clearTimeout(relayOpenTimer); relayOpenTimer = null; }
  if (ws) { try { ws.onclose = null; ws.close(); } catch {} ws = null; }
  relayConnecting = true;
  const url = typeof relayUrlWithRoom === 'function'
    ? relayUrlWithRoom(state.relayUrl, state.relayRoom)
    : state.relayUrl + (state.relayUrl.includes('?') ? '&' : '?') + 'room=' + encodeURIComponent(state.relayRoom);
  if (!force) setDialStatus('正在連中繼…', 'busy');
  updateRelayStatusLine();
  if (window.updateHealthUI) updateHealthUI();
  let sock;
  try {
    sock = new WebSocket(url);
    ws = sock;
  } catch (e) { relayConnecting = false; setDialStatus('中繼連線失敗：' + e.message, 'err'); return; }
  relayOpenTimer = setTimeout(() => {
    if (ws === sock && ws.readyState === WebSocket.CONNECTING) {
      try { ws.close(); } catch {}
      setDialStatus('中繼逾時：關閉分頁重開，ngrok 頁請按 Visit Site', 'err');
    }
  }, 12000);
  sock.onopen = () => {
    if (relayOpenTimer) { clearTimeout(relayOpenTimer); relayOpenTimer = null; }
    relayConnecting = false;
    if (window.recordConnStat) recordConnStat(true);
    if (window.onRelayOpen) onRelayOpen();
    setRelayStatus(true); relayRegister(); updateRelayStatusLine();
    setDialStatus('中繼已連線！輸入對方6碼或點線上夥伴', 'ok');
    if (window.flushOfflineQueue) flushOfflineQueue();
    if (window.updateHealthUI) updateHealthUI();
    setTimeout(tryAutoReconnectPeer, 2000);
  };
  sock.onclose = () => {
    if (relayOpenTimer) { clearTimeout(relayOpenTimer); relayOpenTimer = null; }
    if (ws !== sock) return;
    if (window.recordConnStat) recordConnStat(false);
    ws = null;
    relayConnecting = false;
    setRelayStatus(false); Object.keys(onlinePeers).forEach(k => delete onlinePeers[k]); renderOnline(); updateRelayStatusLine();
    if (window.updateHealthUI) updateHealthUI();
    switchPttOnRelayDrop();
    if (window.onRelayFailed) onRelayFailed();
    if (window.applyOfflineModeUI) applyOfflineModeUI();
    relayRetryTimer = setTimeout(() => {
      if (typeof shouldConnectRelay === 'function' && !shouldConnectRelay()) return;
      connectRelay(false);
    }, 3000);
  };
  sock.onerror = () => {
    setRelayStatus(false); updateRelayStatusLine();
    if (window.onRelayFailed) onRelayFailed();
    setDialStatus('中繼失敗：確認網址是 ' + location.origin + '，然後按「重新連中繼」', 'err');
  };
  sock.onmessage = (ev) => handleWire(ev.data, 'relay');
}
function disconnectRelay() {
  if (relayRetryTimer) { clearTimeout(relayRetryTimer); relayRetryTimer = null; }
  if (relayOpenTimer) { clearTimeout(relayOpenTimer); relayOpenTimer = null; }
  relayConnecting = false;
  if (ws) { try { ws.onclose = null; ws.close(); } catch {} ws = null; }
  setRelayStatus(false); Object.keys(onlinePeers).forEach(k => delete onlinePeers[k]); renderOnline();
}
function setRelayStatus(on) {
  $('relayDot').className = 'dot ' + ((on || preferOfflineRtcAudio()) ? 'on' : 'off');
  if (on) $('relayLabel').textContent = '中繼:' + state.relayRoom;
  else if (preferOfflineRtcAudio()) $('relayLabel').textContent = '離線P2P';
  else $('relayLabel').textContent = '未連中繼';
  updateRelayStatusLine();
}
function relayRegister() { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ kind: 'REGISTER', code: myCode(), name: state.name })); }

function handleControl(msg) {
  switch (msg.kind) {
    case 'SIG': onSignal(msg); break;
    case 'SIG_FAIL': setDialStatus('對方代碼 ' + msg.to + ' 不在線（請確認對方也開了同一網址）', 'err'); break;
    case 'PONG': if (window.onPong) onPong(msg.ts); break;
    case 'PRESENCE': (msg.peers || []).forEach(p => { onlinePeers[p.code] = p.name; }); renderOnline(); setTimeout(tryAutoReconnectPeer, 500); break;
    case 'PRESENCE_JOIN': onlinePeers[msg.code] = msg.name; renderOnline(); setTimeout(tryAutoReconnectPeer, 500); break;
    case 'PRESENCE_LEAVE':
      delete onlinePeers[msg.code];
      renderOnline();
      {
        const left = Object.values(state.contacts).find(c => c.code === msg.code);
        if (left) onPeerLinkLost(left.sid, left.name);
      }
      break;
    case 'TEAM_INVITE':
      if (typeof onTeamInvite === 'function') onTeamInvite(msg);
      break;
    case 'TEAM_JOIN':
      if (typeof onTeamJoin === 'function') onTeamJoin(msg);
      break;
  }
}
function renderOnline() {
  const codes = Object.keys(onlinePeers).filter(c => c && c !== myCode());
  const empty = '<div class="muted">等對方也開啟同一網址後，會出現在這裡，點一下連線。</div>';
  const renderBox = (box) => {
    if (!box) return;
    if (!codes.length) { box.innerHTML = empty; return; }
    box.innerHTML = '';
    codes.forEach(c => {
      const d = document.createElement('div'); d.className = 'peer-item';
      d.innerHTML = `<span>${escapeHtml(onlinePeers[c] || '夥伴')} <span class="muted">(${c})</span></span>`;
      const b = document.createElement('button'); b.className = 'btn small'; b.textContent = '連線';
      b.onclick = () => dial(c);
      d.appendChild(b); box.appendChild(d);
    });
  };
  renderBox($('onlinePeersWalkie'));
}

/* ===================== Unified mesh send / receive ===================== */
function originate(msg) {
  msg.id = msg.id || uuid(); msg.ttl = msg.ttl ?? state.meshTtl; msg.sid = state.id; msg.name = state.name; msg.ts = msg.ts || Date.now();
  state.seen.add(msg.id);
  msg._sent = forward(msg, null, false);
  return msg;
}
function forward(msg, exceptConnId, fromRelay) {
  const raw = JSON.stringify(msg);
  let sent = false;
  rtc.conns.forEach(c => {
    if (c.id !== exceptConnId && c.dc && c.dc.readyState === 'open') {
      try { c.dc.send(raw); sent = true; } catch {}
    }
  });
  if (!fromRelay && ws && ws.readyState === WebSocket.OPEN) {
    try { ws.send(raw); sent = true; } catch {}
  }
  const near = hasNearLink();
  if (!near && (msg.via === 'lora' || (typeof state !== 'undefined' && state.loraAutoFallback)) &&
      window.meshtasticSendWire && isLoraWireType(msg.type)) {
    meshtasticSendWire(msg);
  }
  return sent;
}
function handleWire(raw, fromConnId) {
  let msg; try { msg = JSON.parse(raw); } catch { return; }
  if (msg.kind) { handleControl(msg); return; }
  if (msg.type === 'ACK' && msg.to === state.id) {
    if (msg.id && !state.seen.has(msg.id)) {
      state.seen.add(msg.id);
      if (window.markDelivered) markDelivered(msg.refId);
    }
    return;
  }
  if (!msg.id || msg.sid === state.id) return;
  if (state.seen.has(msg.id)) return;
  state.seen.add(msg.id);
  if (state.seen.size > 800) state.seen = new Set([...state.seen].slice(-500));
  if (msg.ttl > 1) forward({ ...msg, ttl: msg.ttl - 1 }, fromConnId, fromConnId === 'relay');
  if (window.noteMeshHop) noteMeshHop(msg, fromConnId);
  dispatch(msg, fromConnId);
}

/** Meshtastic BLE / 外部橋接注入 Mesh（不廣播，僅本機顯示+轉發）。 */
function ingestLoraWire(msg) {
  if (!msg || !msg.type) return;
  msg.id = msg.id || uuid();
  msg.ttl = msg.ttl ?? state.meshTtl;
  msg.sid = msg.sid || 'meshtastic-node';
  msg.name = msg.name || 'LoRa';
  msg.via = 'lora';
  msg.ts = msg.ts || Date.now();
  if (state.seen.has(msg.id)) return;
  state.seen.add(msg.id);
  if (msg.ttl > 1) forward({ ...msg, ttl: msg.ttl - 1 }, null, false);
  dispatch(msg, 'lora');
  if (window.updateHealthUI) updateHealthUI();
}
window.ingestLoraWire = ingestLoraWire;
function dispatch(msg, fromConnId) {
  if (msg.sid) rememberContact(msg.sid, msg.name, fromConnId);
  switch (msg.type) {
    case 'HELLO': break;
    case 'TELEMETRY': onTelemetry(msg); break;
    case 'SOS':
      onChatIncoming({ ...msg, type: 'TEXT', text: msg.text || ('🆘 SOS ' + (msg.name || '')) });
      if (msg.lat != null && msg.lon != null) onTelemetry({ ...msg, type: 'TELEMETRY' });
      if (window.notify) notify('🆘 SOS', (msg.name || '隊友') + ' 發出求救');
      break;
    case 'TEXT': case 'IMAGE': case 'VOICE_CLIP': onChatIncoming(msg); break;
    case 'ACK': break;
    case 'VOICE_STREAM': onVoiceStream(msg); break;
    case 'RALLY': onRally(msg); break;
    case 'RALLY_CALL': onRallyCall(msg); break;
    case 'CALL_REQUEST':
      showCallBanner(msg);
      notify('📞 通話請求', `${msg.name} 要求通話`);
      break;
    case 'CALL_CANCEL': hideCallBanner(); break;
    case 'PTT_ACTIVE':
      if (msg.sid !== state.id && rtc.micTrack && rtc.micTrack.enabled) {
        rtc.micTrack.enabled = false;
        updateVoiceRxStatus(msg.name + ' 正在發言…', 'ok');
      }
      break;
  }
}
function rememberContact(sid, name, fromConnId) {
  if (!sid || sid === state.id) return;
  if (!state.contacts[sid]) state.contacts[sid] = { sid, code: codeOf(sid), name: name || '夥伴' };
  else if (name) state.contacts[sid].name = name;
  store.setJSON('p2p_contacts', state.contacts);
  if (!state.convs[sid]) state.convs[sid] = [];
  const conn = fromConnId && rtc.conns.find(c => c.id === fromConnId);
  if (conn) { conn.sid = sid; conn.name = name || conn.name; }
  renderConvChips(); renderRtcPeers();
}
function noteContact(sid, name, fromConnId) {
  rememberContact(sid, name, fromConnId);
}

function migrateChatStorage() {
  let changed = false;
  Object.keys(state.convs).forEach(k => {
    if (k.startsWith('ch:')) { delete state.convs[k]; changed = true; }
  });
  Object.keys(state.contacts).forEach(k => {
    if (k.startsWith('ch:')) { delete state.contacts[k]; changed = true; }
  });
  Object.keys(state.unread).forEach(k => {
    if (k.startsWith('ch:')) { delete state.unread[k]; changed = true; }
  });
  if (!state.convs.group) state.convs.group = [];
  if (changed) {
    persistConvs();
    persistUnread();
    store.setJSON('p2p_contacts', state.contacts);
  }
  if (state.activeConv && state.activeConv.startsWith('ch:')) state.activeConv = 'group';
}

function deleteContactChat(sid) {
  if (!sid || sid === 'group') return;
  delete state.contacts[sid];
  delete state.convs[sid];
  delete state.unread[sid];
  store.setJSON('p2p_contacts', state.contacts);
  persistConvs();
  persistUnread();
  if (state.chatLinkLost && state.chatLinkLost.sid === sid) state.chatLinkLost = null;
  if (state.activeConv === sid) {
    state.activeConv = 'group';
    showChatHome();
  } else {
    renderChatDisconnectBanner();
    renderChatHome();
  }
}

function onPeerLinkLost(sid, name) {
  if (!sid || sid === state.id) return;
  rememberContact(sid, name);
  const displayName = name || state.contacts[sid]?.name || '夥伴';
  state.chatLinkLost = { sid, name: displayName };
  renderChatDisconnectBanner();
}

function renderChatDisconnectBanner() {
  const el = $('chatDisconnectBanner');
  if (!el) return;
  if (!state.chatLinkLost) {
    el.classList.add('hidden');
    return;
  }
  el.classList.remove('hidden');
  const t = $('chatDisconnectText');
  if (t) t.textContent = '是否保留本次連線的對話與聯絡人？\n\n' + state.chatLinkLost.name;
}

function updateChatContactActions() {
  const row = $('chatContactActions');
  if (!row) return;
  row.classList.toggle('hidden', !state.activeConv || state.activeConv === 'group');
}

/* ===================== Geolocation + telemetry + peers ===================== */
const GEO_MS = { eco: 30000, normal: 10000, realtime: 3000 };
let geoWatchId = null;
let telemetryTimer = null;

function geoMaxAge() {
  return ({ eco: 25000, normal: 8000, realtime: 2000 })[state.geoMode] || 8000;
}

function restartTelemetry() {
  if (telemetryTimer) clearInterval(telemetryTimer);
  const ms = GEO_MS[state.geoMode] || 10000;
  telemetryTimer = setInterval(() => {
    if (state.self) {
      const extra = typeof telemetryExtras === 'function' ? telemetryExtras() : {};
      originate({ type: 'TELEMETRY', lat: state.self.lat, lon: state.self.lon, floor: state.manualFloor, ...extra });
    }
  }, ms);
}

function geoWatchOptions() {
  return {
    enableHighAccuracy: state.geoMode !== 'eco',
    maximumAge: state.geoMode === 'realtime' ? 0 : (state.geoMode === 'normal' ? 2000 : geoMaxAge()),
    timeout: 20000
  };
}

function applyGeoPosition(pos) {
  const c = pos.coords;
  state.self = { lat: c.latitude, lon: c.longitude, speed: c.speed, accuracy: c.accuracy, alt: c.altitude };
  if (typeof recordWalkSpeed === 'function') recordWalkSpeed(pos);
  if (typeof recordSelfTrack === 'function') recordSelfTrack(c.latitude, c.longitude, pos.timestamp);
  if (typeof updateGeoAccuracyUI === 'function') updateGeoAccuracyUI(c.accuracy);
  setSelfOnMap(state.self.lat, state.self.lon);
  updateDistances();
  guardianEvaluate();
  if (typeof checkGeofence === 'function') checkGeofence();
  if (state.rallyNavTarget && state.self) redrawRallyList();
}

function startGeolocation() {
  if (!navigator.geolocation) { showMapHint('此裝置不支援定位'); return; }
  if (geoWatchId != null) navigator.geolocation.clearWatch(geoWatchId);
  geoWatchId = navigator.geolocation.watchPosition(
    applyGeoPosition,
    (err) => showMapHint('定位失敗：' + err.message),
    geoWatchOptions());
  restartTelemetry();
}

function onTelemetry(msg) {
  if (msg.sid) rememberContact(msg.sid, msg.name);
  const p = {
    name: msg.name, lat: msg.lat, lon: msg.lon, floor: msg.floor,
    ts: msg.ts || Date.now(), role: msg.role, roleName: msg.roleName,
    battery: msg.battery != null ? msg.battery : undefined,
    acc: msg.acc != null ? msg.acc : undefined
  };
  state.peers[msg.sid] = p;
  state.lastKnown[msg.sid] = { ...p, sid: msg.sid };
  store.setJSON('p2p_last_known', state.lastKnown);
  recordPeerTrack(msg.sid, msg.lat, msg.lon, p.ts);
  setPeerOnMap(msg.sid, p); renderPeerList(); renderLastKnown(); updateDistances(); checkProximityAlarm(msg.sid);
}
function setFloor(v) {
  const n = parseInt(v, 10);
  state.manualFloor = Number.isFinite(n) ? n : 0;
  store.set('p2p_floor', String(state.manualFloor));
  const fi = $('floorInput'), mf = $('manualFloor');
  if (fi && document.activeElement !== fi) fi.value = state.manualFloor;
  if (mf && document.activeElement !== mf) mf.value = state.manualFloor;
      updateDistances();
}

function updateDistances() {
  $('selfFloor').textContent = state.manualFloor + 'F';
  $('mapSelfFloor').textContent = state.manualFloor + 'F';
  const mpc = $('mapPeerCount'); if (mpc) mpc.textContent = Object.keys(state.peers).length + ' 人';
  const ids = Object.keys(state.peers);
  const hintEl = $('peerDistHint');
  if (!ids.length) {
    $('peerName').textContent = '尚無'; $('peerFloor').textContent = '—';
    $('peerDist').textContent = '—';
    if (hintEl) hintEl.textContent = '';
    return;
  }
  const sid = (state.lockedPeers || []).find((id) => state.peers[id]) ||
    (state.lockedPeer && state.peers[state.lockedPeer] ? state.lockedPeer : ids[0]);
  const p = state.peers[sid];
  $('peerName').textContent = p.name;
  $('peerFloor').textContent = p.floor != null ? p.floor + 'F' : '—';
  const nearWifi = typeof rtc !== 'undefined' && rtc.conns && rtc.conns.some(c => c.dc && c.dc.readyState === 'open');
  const dist = typeof formatPeerDistance === 'function'
    ? formatPeerDistance(state.self, p, {
      nearWifi, wifiRssi: null,
      manualOverride: state.manualDistOverride,
      manualDistanceM: state.manualDistM,
      offsetM: state.manualDistOverride ? 0 : state.distOffsetM
    })
    : { line: (state.self && p.lat != null) ? Math.round(distanceM(state.self.lat, state.self.lon, p.lat, p.lon)) + ' 公尺' : '—', hint: null };
  $('peerDist').textContent = dist.line;
  if (hintEl) hintEl.textContent = dist.hint || '';
  updateBearingDisplay(sid, p);
}
function renderPeerList() {
  const now = Date.now();
  const ids = Object.keys(state.peers).filter(sid => now - (state.peers[sid].ts || 0) <= STALE_MS * 3);
  Object.keys(state.peers).forEach(sid => {
    if (now - (state.peers[sid].ts || 0) > STALE_MS * 3) {
      delete state.peers[sid];
      if (peerMarkers[sid]) { peerMarkers[sid].remove(); delete peerMarkers[sid]; }
    }
  });
  const box = $('peerList');
  const mpc = $('mapPeerCount'); if (mpc) mpc.textContent = ids.length + ' 人';
  if (!ids.length) { box.innerHTML = '<div class="muted">尚無在線夥伴（30秒內有定位才顯示）</div>'; return; }
  const lockedN = (state.lockedPeers || []).length;
  box.innerHTML = lockedN
    ? `<div class="muted small" style="margin-bottom:6px">已鎖定 ${lockedN} 人（同一警報距離）</div>`
    : '<div class="muted small" style="margin-bottom:6px">可同時鎖定多位隊友</div>';
  ids.forEach(sid => {
    const p = state.peers[sid], locked = (state.lockedPeers || []).includes(sid) || state.lockedPeer === sid;
    const stale = Date.now() - (p.ts || 0) > STALE_MS;
    const roleTag = (p.roleName || (typeof ROLE_LABELS !== 'undefined' && ROLE_LABELS[p.role]) || '').trim();
    const rolePart = roleTag ? ` · ${escapeHtml(roleTag)}` : '';
    const item = document.createElement('div'); item.className = 'peer-item' + (stale ? ' peer-stale' : '');
    const bat = p.battery != null ? ` · 🔋${p.battery}%` : '';
    item.innerHTML = `<span>${escapeHtml(p.name)}${rolePart}${p.floor != null ? ' · ' + p.floor + 'F' : ''}${bat}${stale ? ' <span class="peer-stale-tag">最後已知</span>' : ''}</span>`;
    const btn = document.createElement('button'); btn.className = 'btn ghost small'; btn.textContent = locked ? '已鎖定 ✓' : '鎖定';
    btn.onclick = () => {
      const set = new Set(state.lockedPeers || []);
      if (set.has(sid)) set.delete(sid); else set.add(sid);
      state.lockedPeers = [...set];
      state.lockedPeer = set.size ? [...set][0] : null;
      try { store.setJSON('p2p_locked_peers', state.lockedPeers); } catch {}
      renderPeerList();
      updateDistances();
      if (typeof renderPeerLockLists === 'function') renderPeerLockLists();
    };
    item.appendChild(btn); box.appendChild(item);
  });
  if (lockedN) {
    const clear = document.createElement('button');
    clear.className = 'btn ghost small';
    clear.textContent = '全部解除鎖定';
    clear.style.marginTop = '6px';
    clear.onclick = () => {
      state.lockedPeers = [];
      state.lockedPeer = null;
      try { store.setJSON('p2p_locked_peers', []); } catch {}
      renderPeerList();
      updateDistances();
      if (typeof renderPeerLockLists === 'function') renderPeerLockLists();
    };
    box.appendChild(clear);
  }
  if (typeof renderPeerLockLists === 'function') renderPeerLockLists();
}
function checkProximityAlarm(sid) {
  const lockedSet = new Set(state.lockedPeers || []);
  if (state.lockedPeer) lockedSet.add(state.lockedPeer);
  if (!lockedSet.has(sid) || !state.self) return;
  const p = state.peers[sid]; if (p.lat == null) return;
  const age = Date.now() - (p.ts || 0);
  if (age > 15000) return;
  const sAcc = state.self.accuracy != null ? state.self.accuracy : 999;
  const pAcc = p.acc != null ? p.acc : 999;
  if (sAcc > 80 || pAcc > 80) return;
  const d = distanceM(state.self.lat, state.self.lon, p.lat, p.lon);
  if (d > state.alarmThreshold) {
    const now = Date.now();
    checkProximityAlarm._lastByPeer = checkProximityAlarm._lastByPeer || {};
    if (now - (checkProximityAlarm._lastByPeer[sid] || 0) > 30000) {
      checkProximityAlarm._lastByPeer[sid] = now;
      notify('⚠️ 超出範圍', `${p.name} 距離 ${Math.round(d)} 公尺`);
      beep();
    }
  }
}

/* ===================== LINE-like chat ===================== */
function convArr(key) { if (!state.convs[key]) state.convs[key] = []; return state.convs[key]; }
function persistConvs() { store.setJSON('p2p_convs', state.convs); }
function persistUnread() { store.setJSON('p2p_unread', state.unread); }

function addSessionSeparators() {
  const label = '──── ' + new Date().toLocaleString('zh-TW') + ' 連線 ────';
  Object.keys(state.convs).forEach(k => {
    const arr = state.convs[k];
    if (arr.length && !arr[arr.length - 1].sep) arr.push({ sep: true, label });
  });
  persistConvs();
}

function convTitle(key) {
  if (typeof convTitleTeam === 'function') {
    const t = convTitleTeam(key);
    if (t) return t;
  }
  if (key === 'group') return '群組';
  const c = state.contacts[key]; return c ? `${c.name}(${c.code})` : codeOf(key);
}
function convPreview(e) {
  if (!e || e.sep) return '';
  if (e.type === 'TEXT') return e.text || '';
  if (e.type === 'IMAGE') return '[照片]';
  if (e.type === 'VOICE_CLIP') return '[語音]';
  return '';
}
function chatDateLabel(ts) {
  const d = new Date(ts);
  const now = new Date();
  if (d.toDateString() === now.toDateString()) return '今天';
  const y = new Date(now); y.setDate(y.getDate() - 1);
  if (d.toDateString() === y.toDateString()) return '昨天';
  return d.toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' });
}
function convAvatarChar(key) {
  if (typeof convAvatarTeam === 'function') {
    const a = convAvatarTeam(key);
    if (a) return a;
  }
  if (key === 'group') return '群';
  const t = convTitle(key);
  return (t[0] || '?').toUpperCase();
}
function renderChatHome() {
  const box = $('chatHomeList');
  if (!box) return;
  box.innerHTML = '';
  const keys = typeof chatKeysForHome === 'function' ? chatKeysForHome() : ['group', ...Object.keys(state.contacts)];
  keys.forEach(k => {
    const arr = convArr(k);
    const last = [...arr].reverse().find(e => !e.sep);
    const row = document.createElement('div');
    row.className = 'chat-row';
    const av = document.createElement('div');
    av.className = 'chat-avatar';
    av.textContent = convAvatarChar(k);
    const body = document.createElement('div');
    body.className = 'chat-row-body';
    const top = document.createElement('div');
    top.className = 'chat-row-top';
    const name = document.createElement('div');
    name.className = 'chat-row-name';
    name.textContent = convTitle(k);
    top.appendChild(name);
    if (last && last.ts) {
      const tm = document.createElement('div');
      tm.className = 'chat-row-time';
      tm.textContent = new Date(last.ts).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' });
      top.appendChild(tm);
    }
    const preview = document.createElement('div');
    preview.className = 'chat-row-preview';
    preview.textContent = last ? convPreview(last) : '尚無訊息';
    body.appendChild(top);
    body.appendChild(preview);
    row.appendChild(av);
    row.appendChild(body);
    const u = state.unread[k] || 0;
    if (u > 0) {
      const badge = document.createElement('div');
      badge.className = 'chat-row-unread';
      badge.textContent = u > 99 ? '99+' : String(u);
      row.appendChild(badge);
    }
    row.onclick = () => openChatRoom(k);
    box.appendChild(row);
  });
}
function showChatHome() {
  state.chatInRoom = false;
  const home = $('chatHome');
  const room = $('chatRoom');
  if (home) home.classList.remove('hidden');
  if (room) room.classList.add('hidden');
  renderChatHome();
  renderChatDisconnectBanner();
}
function openChatRoom(key) {
  state.activeConv = key;
  state.unread[key] = 0;
  persistUnread();
  if (state.chatLinkLost && state.chatLinkLost.sid === key) {
    state.chatLinkLost = null;
    renderChatDisconnectBanner();
  }
  state.chatInRoom = true;
  const home = $('chatHome');
  const room = $('chatRoom');
  if (home) home.classList.add('hidden');
  if (room) room.classList.remove('hidden');
  const title = $('chatRoomTitle');
  if (title) title.textContent = convTitle(key);
  updateChatContactActions();
  renderChat();
  renderChatHome();
}
function renderConvChips() { renderChatHome(); }
function ackLabel(e) {
  if (e.dir !== 'me' || !e.status) return '';
  if (e.status === 'queued') return '<span class="ack">⏳</span>';
  if (e.status === 'delivered') return '<span class="ack">✓✓</span>';
  if (e.status === 'sent') return '<span class="ack">✓</span>';
  return '';
}
function renderChat() {
  const list = $('chatList'); if (!list) return;
  list.innerHTML = '';
  let lastDate = '';
  convArr(state.activeConv).forEach(e => {
    if (!e.sep && e.ts) {
      const dl = chatDateLabel(e.ts);
      if (dl !== lastDate) {
        lastDate = dl;
        const sep = document.createElement('div');
        sep.className = 'chat-date-sep';
        sep.textContent = dl;
        list.appendChild(sep);
      }
    }
    list.appendChild(renderEntry(e));
  });
  list.scrollTop = list.scrollHeight;
}
function renderEntry(e) {
  if (e.sep) { const s = document.createElement('div'); s.className = 'sep'; s.textContent = e.label; return s; }
  const wrap = document.createElement('div');
  wrap.className = 'chat-row-wrap ' + (e.dir === 'me' ? 'me' : 'them');
  if (e.dir !== 'me') {
    const av = document.createElement('div');
    av.className = 'chat-mini-avatar';
    av.textContent = (e.name || '?')[0];
    wrap.appendChild(av);
  }
  const b = document.createElement('div'); b.className = 'bubble ' + (e.dir === 'me' ? 'me' : 'them');
  let inner = e.dir === 'me' ? '' : `<div class="who">${escapeHtml(e.name || '對方')}</div>`;
  if (e.type === 'TEXT') inner += escapeHtml(e.text || '');
  else if (e.type === 'IMAGE') inner += `<img src="data:${e.mime || 'image/jpeg'};base64,${e.blob}" alt="" />`;
  else if (e.type === 'VOICE_CLIP') {
    const mime = e.mime || 'audio/webm';
    const apk = typeof isApkVoiceMime === 'function' && isApkVoiceMime(mime);
    const src = apk ? '' : voiceDataUrl(mime, e.blob);
    inner += src
      ? `<audio class="voice-audio" controls playsinline preload="metadata" src="${src}"></audio>`
      : `<button type="button" class="btn ghost small voice-play-btn" data-voice-key="${escapeHtml(e.ts + '')}">▶ ${apk ? '播放 APK 語音' : '播放語音'}</button>`;
  }
  if (e.lora && typeof loraBadgeHtml === 'function') inner += loraBadgeHtml();
  inner += `<div class="meta">${new Date(e.ts).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' })}${ackLabel(e)}</div>`;
  b.innerHTML = inner;
  if (e.type === 'VOICE_CLIP') {
    const btn = b.querySelector('.voice-play-btn');
    if (btn) btn.onclick = () => playVoiceClipEntry(e).catch(err => setDialStatus('語音播放失敗：' + err.message, 'err'));
  }
  wrap.appendChild(b);
  return wrap;
}
function pushEntry(key, entry) {
  convArr(key).push(entry); persistConvs();
  if (state.activeConv === key && state.chatInRoom) renderChat();
  renderChatHome();
}
async function onChatIncoming(msg) {
  if (msg.to && msg.to !== state.id) return;
  if (typeof decodeInboundMsg === 'function') msg = await decodeInboundMsg(msg);
  const key = typeof convKeyForMsg === 'function' ? convKeyForMsg(msg) : (msg.to === state.id ? msg.sid : 'group');
  if (!key) return;
  const lora = typeof isLoraMsg === 'function' && isLoraMsg(msg);
  pushEntry(key, { dir: 'them', type: msg.type, text: msg.text, mime: msg.mime, blob: msg.blob, name: msg.name, ts: msg.ts, lora });
  if (window.sendAckFor) sendAckFor(msg);
  if (state.activeConv !== key) { state.unread[key] = (state.unread[key] || 0) + 1; persistUnread(); renderChatHome(); }
  notify(msg.name, msg.type === 'TEXT' ? msg.text : (msg.type === 'IMAGE' ? '[照片]' : '[語音]'));
}
async function sendChat(partial) {
  const to = state.activeConv === 'group' ? null : state.activeConv;
  const clientMsgId = uuid();
  const lora = partial.via === 'lora' || (typeof outboundViaLora === 'function' && outboundViaLora());
  pushEntry(state.activeConv, { dir: 'me', type: partial.type, text: partial.text, mime: partial.mime, blob: partial.blob, name: state.name, ts: Date.now(), msgId: clientMsgId, status: 'sending', lora });
  const outbound = { ...partial, to };
  const msg = typeof meshSend === 'function'
    ? await meshSend(outbound, clientMsgId)
    : originate(outbound);
  if (msg) {
    convArr(state.activeConv).forEach(e => {
      if (e.msgId === clientMsgId) e.serverMsgId = msg.id;
    });
    persistConvs();
  }
}
function sendText() { const t = $('chatText').value.trim(); if (!t) return; sendChat({ type: 'TEXT', text: t }); $('chatText').value = ''; }
function sendPhoto(file) {
  const reader = new FileReader();
  reader.onload = () => {
    const img = new Image();
    img.onload = () => {
      const max = 1024, scale = Math.min(1, max / Math.max(img.width, img.height));
      const c = document.createElement('canvas'); c.width = img.width * scale; c.height = img.height * scale;
      c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
      sendChat({ type: 'IMAGE', mime: 'image/jpeg', blob: c.toDataURL('image/jpeg', 0.7).split(',')[1] });
    };
    img.src = reader.result;
  };
  reader.readAsDataURL(file);
}

/* recorded voice clips (chat) */
let mediaRecorder = null, recChunks = [], recStream = null, recMime = '', recStartAt = 0;

function pickRecorderMime() {
  const types = [
    'audio/webm;codecs=opus',
    'audio/webm',
    'audio/ogg;codecs=opus',
    'audio/mp4',
    'audio/aac'
  ];
  for (const t of types) if (window.MediaRecorder && MediaRecorder.isTypeSupported(t)) return t;
  return '';
}

function voiceDataUrl(mime, b64) {
  if (!b64 || b64.length < 20) return '';
  if (typeof isApkVoiceMime === 'function' && isApkVoiceMime(mime)) return '';
  return `data:${mime || 'audio/webm'};base64,${b64}`;
}

async function playVoiceClipEntry(e) {
  const mime = e.mime || 'audio/webm';
  if (typeof isApkVoiceMime === 'function' && isApkVoiceMime(mime) && typeof playPichOpusB64 === 'function') {
    try {
      await playPichOpusB64(e.blob);
      return true;
    } catch {}
  }
  const url = voiceBlobUrl(mime, e.blob) || voiceDataUrl(mime, e.blob);
  if (!url) return false;
  const a = new Audio();
  a.playsInline = true;
  await new Promise((resolve, reject) => {
    a.onended = resolve;
    a.onerror = reject;
    a.src = url;
    a.play().catch(reject);
  });
  if (url.startsWith('blob:')) URL.revokeObjectURL(url);
  return true;
}

function voiceBlobUrl(mime, b64) {
  if (!b64) return '';
  try {
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
    const blob = new Blob([bytes], { type: mime || 'audio/webm' });
    if (blob.size < 50) return '';
    return URL.createObjectURL(blob);
  } catch { return ''; }
}

async function startClip() {
  try {
    recMime = pickRecorderMime();
    recStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
    mediaRecorder = new MediaRecorder(recStream, recMime ? { mimeType: recMime } : undefined);
    recChunks = [];
    recStartAt = Date.now();
    mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.push(e.data); };
    mediaRecorder.onstop = onClipStop;
    mediaRecorder.start(250);
  } catch (e) { setDialStatus('無法錄音：' + e.message, 'err'); }
}

async function onClipStop() {
  if (recStream) { recStream.getTracks().forEach(t => t.stop()); recStream = null; }
  const duration = Date.now() - recStartAt;
  const mime = (mediaRecorder && mediaRecorder.mimeType) || recMime || 'audio/webm';
  const mr = mediaRecorder;
  mediaRecorder = null;
  if (duration < 400 || !recChunks.length) {
    setDialStatus('錄音太短，請按住麥克風按鈕至少 1 秒', 'err');
    return;
  }
  const blob = new Blob(recChunks, { type: mime });
  recChunks = [];
  if (blob.size < 100) {
    setDialStatus('錄音失敗（檔案太小），請再試', 'err');
    return;
  }
  sendChat({ type: 'VOICE_CLIP', mime: blob.type, blob: await blobToBase64(blob) });
  setDialStatus('語音訊息已送出', 'ok');
}

function stopClipAndSend() {
  if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
  try { mediaRecorder.requestData(); } catch {}
  mediaRecorder.stop();
}

function hasPartnerLink() {
  if (rtc.conns.some(c => c.dc && c.dc.readyState === 'open')) return true;
  const last = (store.get('p2p_last_peer', '') || '').toUpperCase();
  if (last && onlinePeers[last]) return true;
  return Object.keys(onlinePeers).length > 0;
}

/* PTT voice via relay — 每段為完整錄音檔（可播放），走中繼不靠 WebRTC 音訊 */
let pttRecorder = null, pttStream = null, pttActive = false, pttVoiceMode = null, pttMime = '', pttFlushTimer = null;
const voicePlayQ = [];
let voicePlaying = false;
let pttRxAudio = null;

let audioUnlocked = false;

function ensurePttRxAudio() {
  if (!pttRxAudio) {
    pttRxAudio = document.createElement('audio');
    pttRxAudio.autoplay = true;
    pttRxAudio.playsInline = true;
    pttRxAudio.setAttribute('playsinline', '');
    pttRxAudio.setAttribute('webkit-playsinline', '');
    pttRxAudio.volume = 1;
    document.body.appendChild(pttRxAudio);
  }
  return pttRxAudio;
}

function updateVoiceRxStatus(text, kind) {
  const el = $('voiceRxStatus');
  if (!el) return;
  el.textContent = text || '';
  el.className = 'voice-rx-status small' + (kind ? ' ' + kind : '');
}

function tryPlayVoiceUrl(a, url, mime, b64) {
  return new Promise((resolve, reject) => {
    const done = (ok) => { a.oncanplaythrough = null; a.onerror = null; ok ? resolve() : reject(new Error('play fail')); };
    a.oncanplaythrough = () => { a.play().then(() => done(true)).catch(() => done(false)); };
    a.onerror = () => {
      const dataUrl = voiceDataUrl(mime, b64);
      if (dataUrl && a.src !== dataUrl) {
        a.src = dataUrl;
        return;
      }
      done(false);
    };
    a.src = url;
    if (a.readyState >= 3) a.play().then(() => done(true)).catch(() => done(false));
  });
}

function pttFlushChunk() {
  if (!pttActive || !pttRecorder || pttRecorder.state !== 'recording') return;
  try { pttRecorder.requestData(); } catch {}
  pttRecorder.stop();
}

async function startPttStream() {
  pttMime = pickRecorderMime() || 'audio/webm';
  pttStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
  pttActive = true;
  ensurePttRxAudio();
  pttCycle();
}

function pttCycle() {
  if (!pttActive || !pttStream) return;
  pttRecorder = new MediaRecorder(pttStream, pttMime ? { mimeType: pttMime } : undefined);
  const chunks = [];
  pttRecorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
  pttRecorder.onstop = async () => {
    if (!chunks.length) { if (pttActive) pttCycle(); return; }
    const blob = new Blob(chunks, { type: pttMime || 'audio/webm' });
    if (blob.size > 80) {
      if (typeof hasMeshLink === 'function' && hasMeshLink()) {
        originate({ type: 'VOICE_STREAM', mime: blob.type || pttMime, blob: await blobToBase64(blob), ttl: state.meshTtl });
        updateVoiceRxStatus('已送出語音片段', 'ok');
      } else {
        updateVoiceRxStatus('無連線，語音片段未送出', 'err');
      }
    }
    if (pttActive) pttCycle();
  };
  pttRecorder.start();
  if (pttFlushTimer) clearTimeout(pttFlushTimer);
  const ms = typeof teamMeshVoiceIntervalMs === 'function' ? teamMeshVoiceIntervalMs() : 350;
  pttFlushTimer = setTimeout(pttFlushChunk, ms);
}

function stopPttStream() {
  pttActive = false;
  if (pttFlushTimer) { clearTimeout(pttFlushTimer); pttFlushTimer = null; }
  if (pttRecorder && pttRecorder.state !== 'inactive') {
    try { pttRecorder.requestData(); } catch {}
    pttRecorder.stop();
  }
  if (pttStream) { pttStream.getTracks().forEach(t => t.stop()); pttStream = null; }
  pttRecorder = null;
}

function onVoiceStream(msg) {
  if (!msg.blob) return;
  const mime = msg.mime || 'audio/webm';
  if (typeof isApkVoiceMime === 'function' && isApkVoiceMime(mime)) {
    playPichOpusB64(msg.blob).then(() => {
      updateVoiceRxStatus('正在播放 APK 語音…', 'ok');
      if (typeof playVoiceNotifyExtended === 'function') playVoiceNotifyExtended();
      else if (typeof playVoiceNotify === 'function') playVoiceNotify();
    }).catch(() => {
      voicePlayQ.push({ mime, blob: msg.blob });
      drainVoiceQueue();
    });
    return;
  }
  voicePlayQ.push({ mime, blob: msg.blob });
  drainVoiceQueue();
}

async function drainVoiceQueue() {
  if (voicePlaying || !voicePlayQ.length) return;
  voicePlaying = true;
  const { mime, blob } = voicePlayQ.shift();
  if (typeof isApkVoiceMime === 'function' && isApkVoiceMime(mime) && typeof playPichOpusB64 === 'function') {
    try {
      await playPichOpusB64(blob);
      voicePlaying = false;
      updateVoiceRxStatus('正在播放 APK 語音…', 'ok');
      return drainVoiceQueue();
    } catch {}
  }
  const url = voiceBlobUrl(mime, blob);
  if (!url) { voicePlaying = false; return drainVoiceQueue(); }
  const a = ensurePttRxAudio();
  a.onended = () => { URL.revokeObjectURL(url); voicePlaying = false; drainVoiceQueue(); };
  try {
    if (!audioUnlocked) {
      const btn = $('audioUnlockBtn');
      if (btn) btn.classList.remove('hidden');
      updateVoiceRxStatus('收到語音！請點「啟用喇叭」', 'err');
    }
    await tryPlayVoiceUrl(a, url, mime, blob);
    updateVoiceRxStatus('正在播放語音…', 'ok');
    if (typeof playVoiceNotifyExtended === 'function') playVoiceNotifyExtended();
    else if (typeof playVoiceNotify === 'function') playVoiceNotify();
  } catch {
    URL.revokeObjectURL(url);
    voicePlaying = false;
    const btn = $('audioUnlockBtn');
    if (btn) btn.classList.remove('hidden');
    updateVoiceRxStatus('無法播放語音，請點「啟用喇叭」後重試', 'err');
    drainVoiceQueue();
  }
}

function requestGeoOnce() {
  if (!navigator.geolocation) { showMapHint('不支援定位'); return; }
  showMapHint('正在取得位置…');
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      applyGeoPosition(pos);
      if (typeof recordWalkSpeed === 'function') recordWalkSpeed(pos);
      showMapHint('定位：' + state.self.lat.toFixed(5) + ', ' + state.self.lon.toFixed(5));
      if (map) map.easeTo({ center: [state.self.lon, state.self.lat], zoom: 16 });
    },
    (err) => showMapHint('定位失敗：' + err.message),
    { enableHighAccuracy: true, maximumAge: 0, timeout: 25000 }
  );
}

function playVoice(mime, b64) {
  const url = voiceBlobUrl(mime, b64);
  if (!url) { setDialStatus('語音檔案損壞或為空', 'err'); return; }
  const a = new Audio(url);
  a.playsInline = true;
  a.onended = () => URL.revokeObjectURL(url);
  a.play().catch(() => setDialStatus('無法播放語音，請點聊天室裡的播放列', 'err'));
}

/* ===================== Safety route guardian ===================== */
let guard = { offCourseSince: 0, lastAlertAt: 0, alertCount: 0, decision: 'NONE' };
function guardianEvaluate() {
  if (!state.guardEnabled || !state.route.length || !state.self) { updateGuardStatus(); return; }
  const dev = distanceToRoute(state.self.lat, state.self.lon, state.route);
  if (dev <= state.devThreshold) { if (guard.offCourseSince) resetGuard(); updateGuardStatus(dev, false); hideOffCourseModal(); return; }
  const now = Date.now();
  if (!guard.offCourseSince) guard.offCourseSince = now;
  const elapsed = now - guard.offCourseSince;
  if (guard.decision === 'NONE' && elapsed >= 30 * 60000) { guard.decision = 'NOTIFIED'; escalate('偏航逾 30 分鐘未回應'); }
  else if (guard.decision === 'IGNORED' && elapsed >= 60 * 60000) { guard.decision = 'NOTIFIED'; escalate('已選無視但偏航逾 1 小時'); }
  else if (guard.decision === 'NONE') {
    const due = guard.alertCount === 0 ? elapsed >= 30000 : (now - guard.lastAlertAt) >= 60000;
    if (due) { guard.alertCount++; guard.lastAlertAt = now; showOffCourseModal(dev, elapsed); notify('⚠️ 偏離路線', `已偏航約 ${Math.floor(elapsed / 60000)} 分鐘`); beep(); }
  }
  updateGuardStatus(dev, true);
}
setInterval(guardianEvaluate, 5000);
function resetGuard() { guard = { offCourseSince: 0, lastAlertAt: 0, alertCount: 0, decision: 'NONE' }; }
function guardianIgnore() { guard.decision = 'IGNORED'; hideOffCourseModal(); updateGuardStatus(); }
function guardianNotifyNow() { guard.decision = 'NOTIFIED'; hideOffCourseModal(); escalate('使用者手動要求通知'); updateGuardStatus(); }
function escalate(reason) {
  const loc = state.self ? `https://maps.google.com/?q=${state.self.lat},${state.self.lon}` : '(無位置)';
  const body = `[Pich2Pich 安全警示] ${state.name} 可能偏離路線：${reason}。位置 ${loc}`;
  notify('🆘 已觸發緊急通報', reason);
  const payload = { type: 'TEXT', to: null, text: '🆘 ' + body };
  if (typeof meshSend === 'function') meshSend(payload);
  else originate(payload);
  pushEntry('group', { dir: 'me', type: 'TEXT', text: '🆘 ' + body, name: state.name, ts: Date.now() });
  showEmergencyBanner(reason, body);
}
function showEmergencyBanner(reason, body) {
  $('emergencyText').textContent = '🆘 ' + reason;
  const phone = state.contactPhone;
  $('smsLink').href = phone ? `sms:${phone}?body=${encodeURIComponent(body)}` : '#';
  $('telLink').href = phone ? `tel:${phone}` : '#';
  $('emergencyBanner').classList.remove('hidden');
}
function updateGuardStatus(dev, off) {
  const el = $('guardStatus');
  if (!state.guardEnabled) return el.textContent = '未啟用';
  if (!state.route.length) return el.textContent = '尚未設定路線';
  if (!off) return el.textContent = `在路線上${dev != null ? '（偏移 ' + Math.round(dev) + 'm）' : ''}`;
  const d = { IGNORED: '已選無視（1 小時後仍通知）', NOTIFIED: '已通知緊急聯絡人' }[guard.decision] || '等待回應中';
  el.textContent = `偏離 ${Math.round(dev)}m · ${d}`;
}

/* ===================== geometry / helpers ===================== */
function distanceM(la1, lo1, la2, lo2) {
  const R = 6371000, r = d => d * Math.PI / 180;
  const dLa = r(la2 - la1), dLo = r(lo2 - lo1);
  const a = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2;
  return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function distanceToRoute(lat, lon, route) {
  if (!route.length) return Infinity;
  if (route.length === 1) return distanceM(lat, lon, route[0].lat, route[0].lon);
  const mLat = 111320, mLon = 111320 * Math.cos(lat * Math.PI / 180);
  const x = p => (p - lon) * mLon, y = p => (p - lat) * mLat;
  let min = Infinity;
  for (let i = 0; i < route.length - 1; i++) min = Math.min(min, segDist(0, 0, x(route[i].lon), y(route[i].lat), x(route[i + 1].lon), y(route[i + 1].lat)));
  return min;
}
function segDist(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay, l2 = dx * dx + dy * dy;
  if (!l2) return Math.hypot(px - ax, py - ay);
  let t = ((px - ax) * dx + (py - ay) * dy) / l2; t = Math.max(0, Math.min(1, t));
  return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
function notify(title, body) { if ('Notification' in window && Notification.permission === 'granted') { try { new Notification(title, { body }); } catch {} } }
function beep() { try { const c = new (window.AudioContext || window.webkitAudioContext)(), o = c.createOscillator(), g = c.createGain(); o.connect(g); g.connect(c.destination); o.frequency.value = 880; g.gain.setValueAtTime(.3, c.currentTime); o.start(); o.stop(c.currentTime + .4); g.gain.exponentialRampToValueAtTime(.001, c.currentTime + .4); } catch {} }
function playVoiceNotify() {
  const m = state.voiceNotifyMode || 'off';
  if ((m === 'vibrate' || m === 'both') && navigator.vibrate) navigator.vibrate(80);
  if (m === 'sound' || m === 'both') beep();
}
function noteRecentPeer(code, name) {
  code = (code || '').trim().toUpperCase();
  if (!code || code === myCode()) return;
  state.recentPeers = [{ code, name: name || code, ts: Date.now() }, ...state.recentPeers.filter(p => p.code !== code)].slice(0, 12);
  store.setJSON('p2p_recent_peers', state.recentPeers);
  renderRecentPeers();
}
function renderRecentPeers() {
  const box = $('recentPeersList');
  if (!box) return;
  if (!state.recentPeers.length) { box.innerHTML = '<div class="muted small">尚無</div>'; return; }
  box.innerHTML = '';
  state.recentPeers.forEach(p => {
    const d = document.createElement('div');
    d.className = 'peer-item';
    d.innerHTML = `<span>${escapeHtml(p.name)} <span class="muted">${p.code}</span></span>`;
    const b = document.createElement('button');
    b.className = 'btn ghost small';
    b.textContent = '連線';
    b.onclick = () => {
      const dw = $('dialCodeWalkie');
      if (dw) dw.value = p.code;
      dial(p.code);
    };
    d.appendChild(b);
    box.appendChild(d);
  });
}
function downloadJson(filename, obj) {
  const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = filename;
  a.click();
  URL.revokeObjectURL(a.href);
}
function exportSettings() {
  downloadJson('pich2pich-settings.json', {
    v: 1,
    name: state.name,
    relayUrl: state.relayUrl,
    relayRoom: state.relayRoom,
    turnUrl: state.turnUrl,
    turnUser: state.turnUser,
    turnCred: state.turnCred,
    routingUrl: state.routingUrl,
    mapSrc: state.mapSrc,
    pmtilesPath: state.pmtilesPath,
    contactName: state.contactName,
    contactPhone: state.contactPhone,
    devThreshold: state.devThreshold,
    alarmThreshold: state.alarmThreshold,
    manualFloor: state.manualFloor,
    autoReconnect: state.autoReconnect,
    voiceNotifyMode: state.voiceNotifyMode,
    keepScreenOn: state.keepScreenOn,
    geoMode: state.geoMode,
    meshTtl: state.meshTtl,
    markerColor: state.markerColor,
    markerNick: state.markerNick,
    medCard: state.medCard
  });
}
function applyImportedSettings(data) {
  if (!data || typeof data !== 'object') throw new Error('無效的 JSON');
  const set = (k, sk, fn = v => v) => { if (data[k] != null) { state[sk] = fn(data[k]); } };
  set('name', 'name', String);
  set('relayUrl', 'relayUrl', String);
  set('relayRoom', 'relayRoom', String);
  set('turnUrl', 'turnUrl', String);
  set('turnUser', 'turnUser', String);
  set('turnCred', 'turnCred', String);
  set('routingUrl', 'routingUrl', String);
  set('mapSrc', 'mapSrc', String);
  set('pmtilesPath', 'pmtilesPath', String);
  set('contactName', 'contactName', String);
  set('contactPhone', 'contactPhone', String);
  set('devThreshold', 'devThreshold', Number);
  set('alarmThreshold', 'alarmThreshold', Number);
  set('manualFloor', 'manualFloor', Number);
  set('autoReconnect', 'autoReconnect', Boolean);
  set('voiceNotifyMode', 'voiceNotifyMode', String);
  set('keepScreenOn', 'keepScreenOn', Boolean);
  set('geoMode', 'geoMode', String);
  set('meshTtl', 'meshTtl', Number);
  set('markerColor', 'markerColor', String);
  set('markerNick', 'markerNick', String);
  if (data.medCard) state.medCard = { ...state.medCard, ...data.medCard };
  store.set('p2p_name', state.name);
  store.set('p2p_relay_url', state.relayUrl);
  store.set('p2p_relay_room', state.relayRoom);
  store.set('p2p_turn_url', state.turnUrl);
  store.set('p2p_turn_user', state.turnUser);
  store.set('p2p_turn_cred', state.turnCred);
  store.set('p2p_routing', state.routingUrl);
  store.set('p2p_mapsrc', state.mapSrc);
  store.set('p2p_pmtiles', state.pmtilesPath);
  store.set('p2p_contact_name', state.contactName);
  store.set('p2p_contact_phone', state.contactPhone);
  store.set('p2p_dev_th', String(state.devThreshold));
  store.set('p2p_alarm_th', String(state.alarmThreshold));
  store.set('p2p_floor', String(state.manualFloor));
  store.set('p2p_auto_reconnect', state.autoReconnect ? '1' : '0');
  store.set('p2p_voice_notify', state.voiceNotifyMode);
  store.set('p2p_keep_screen', state.keepScreenOn ? '1' : '0');
  store.set('p2p_geo_mode', state.geoMode);
  store.set('p2p_mesh_ttl', String(state.meshTtl));
  store.set('p2p_marker_color', state.markerColor);
  store.set('p2p_marker_nick', state.markerNick);
  store.setJSON('p2p_medcard', state.medCard);
  if (state.self) setSelfOnMap(state.self.lat, state.self.lon);
  restartTelemetry();
  startGeolocation();
  updateWakeLock();
  location.reload();
}
function exportChatHistory() {
  downloadJson('pich2pich-chat-' + new Date().toISOString().slice(0, 10) + '.json', {
    v: 1,
    exported: new Date().toISOString(),
    convs: state.convs,
    contacts: state.contacts
  });
}
async function makeOfferAndShowQR() {
  const out = $('offerOut');
  const btnW = $('makeOfferWalkieBtn');
  const btnS = $('makeOfferBtn');
  if (out) out.value = '產生中…';
  if (btnW) { btnW.disabled = true; btnW.textContent = '產生中…'; }
  if (btnS) btnS.disabled = true;
  showQrLoading('產生邀請 QR', '正在準備麥克風與連線碼…');
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  try {
    const code = await makeOffer();
    if (out) out.value = code;
    showQR('掃我加入（邀請碼）', code);
    if (typeof isOfflineMode === 'function' && isOfflineMode()) {
      setDialStatus('第1步：複製邀請碼傳給對方貼上；等對方回應碼後你再貼一次', 'busy');
    }
  } catch (e) {
    if (out) out.value = '失敗：' + e.message;
    $('qrModal').classList.add('hidden');
    alert('產生失敗：' + e.message);
  } finally {
    if (btnW) { btnW.disabled = false; btnW.textContent = '📱 產生我的邀請 QR'; }
    if (btnS) btnS.disabled = false;
  }
}
function blobToBase64(b) { return new Promise(r => { const f = new FileReader(); f.onload = () => r(f.result.split(',')[1]); f.readAsDataURL(b); }); }
function escapeHtml(s) { return (s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function persistRoute() { store.setJSON('p2p_route', state.route); }
function copy(text) { navigator.clipboard?.writeText(text).catch(() => {}); }

/* ===================== Rescue features ===================== */
function bearingDeg(lat1, lon1, lat2, lon2) {
  const r = d => d * Math.PI / 180;
  const dLon = r(lon2 - lon1);
  const y = Math.sin(dLon) * Math.cos(r(lat2));
  const x = Math.cos(r(lat1)) * Math.sin(r(lat2)) - Math.sin(r(lat1)) * Math.cos(r(lat2)) * Math.cos(dLon);
  return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
}
function bearingLabel(deg) {
  const dirs = ['正北', '東北', '正東', '東南', '正南', '西南', '正西', '西北'];
  return dirs[Math.round(deg / 45) % 8];
}
function updateBearingDisplay(sid, p) {
  const el1 = $('walkieBearing'), el2 = $('mapBearing'), el3 = $('bearingText');
  if (!state.self || p.lat == null) {
    if (el1) el1.textContent = '';
    if (el2) el2.textContent = '';
    if (el3) el3.textContent = '連線夥伴後，會顯示「我在對方的哪個方向」。';
    return;
  }
  const b = bearingDeg(p.lat, p.lon, state.self.lat, state.self.lon);
  const dir = bearingLabel(b);
  const dist = Math.round(distanceM(state.self.lat, state.self.lon, p.lat, p.lon));
  const txt = `我在 ${p.name} 的${dir}方，約 ${dist} 公尺`;
  if (el1) el1.textContent = '🧭 ' + txt;
  if (el2) el2.textContent = '🧭 ' + txt;
  if (el3) el3.textContent = txt + (state.heading != null ? ` · 我面朝${bearingLabel(state.heading)}` : '');
}

function startCompass() {
  if (!window.DeviceOrientationEvent) return;
  const handler = (e) => {
    let h = e.webkitCompassHeading != null ? e.webkitCompassHeading : (360 - (e.alpha || 0));
    if (h < 0 || !Number.isFinite(h)) return;
    state.heading = h % 360;
    const ch = $('compassHeading');
    if (ch) ch.textContent = bearingLabel(state.heading) + ` (${Math.round(state.heading)}°)`;
    const ids = Object.keys(state.peers);
    if (ids.length) {
      const focus = (state.lockedPeers || []).find((id) => state.peers[id]) ||
        (state.lockedPeer && state.peers[state.lockedPeer] ? state.lockedPeer : ids[0]);
      updateBearingDisplay(focus, state.peers[ids[0]]);
    }
  };
  if (typeof DeviceOrientationEvent.requestPermission === 'function') {
    DeviceOrientationEvent.requestPermission().then(r => { if (r === 'granted') window.addEventListener('deviceorientation', handler); }).catch(() => {});
  } else window.addEventListener('deviceorientation', handler);
}

function formatMedCard() {
  const m = state.medCard;
  const parts = [];
  if (m.blood) parts.push('血型:' + m.blood);
  if (m.allergies) parts.push('過敏:' + m.allergies);
  if (m.notes) parts.push('備註:' + m.notes);
  if (m.phone) parts.push('電話:' + m.phone);
  return parts.length ? '[' + parts.join(' ') + ']' : '';
}
function renderMedPreview() {
  const el = $('medCardPreview');
  if (!el) return;
  const t = formatMedCard();
  el.textContent = t || '填寫後會顯示預覽，並隨 SOS 一併廣播。';
  el.classList.toggle('muted', !t);
}
function saveMedCard() {
  store.setJSON('p2p_medcard', state.medCard);
  renderMedPreview();
}

async function broadcastSos(includeVoice) {
  if (typeof queueSosOrBroadcast === 'function') return queueSosOrBroadcast(includeVoice);
  const loc = state.self ? `https://maps.google.com/?q=${state.self.lat},${state.self.lon}` : '(無定位)';
  const text = `🆘 SOS ${state.name}：${state.sosText} ${formatMedCard()} 位置 ${loc}`;
  const payload = {
    type: 'SOS',
    to: null,
    text,
    lat: state.self ? state.self.lat : undefined,
    lon: state.self ? state.self.lon : undefined
  };
  const sent = typeof meshSend === 'function' ? await meshSend(payload) : originate(payload);
  pushEntry('group', { dir: 'me', type: 'TEXT', text, name: state.name, ts: Date.now() });
  const near = typeof hasNearLink === 'function' ? hasNearLink() : false;
  if (includeVoice && state.sosVoiceBlob && near) {
    const voice = { type: 'VOICE_CLIP', to: null, mime: state.sosVoiceMime, blob: state.sosVoiceBlob };
    if (typeof meshSend === 'function') await meshSend(voice);
    else originate(voice);
    pushEntry('group', { dir: 'me', type: 'VOICE_CLIP', mime: state.sosVoiceMime, blob: state.sosVoiceBlob, name: state.name, ts: Date.now() });
  }
  notify('🆘 SOS', sent ? '已廣播求救訊息' : '已佇列，連線後自動送出');
  beep();
}

let sosRecorder = null, sosRecChunks = [];
async function recordSosVoice(start) {
  if (start) {
    try {
      const s = await navigator.mediaDevices.getUserMedia({ audio: true });
      sosRecorder = new MediaRecorder(s); sosRecChunks = [];
      sosRecorder.ondataavailable = e => { if (e.data.size) sosRecChunks.push(e.data); };
      sosRecorder.onstop = async () => {
        const blob = new Blob(sosRecChunks, { type: sosRecorder.mimeType || 'audio/webm' });
        sosRecorder.stream.getTracks().forEach(t => t.stop());
        state.sosVoiceBlob = await blobToBase64(blob);
        state.sosVoiceMime = blob.type;
        store.set('p2p_sos_voice', state.sosVoiceBlob);
        store.set('p2p_sos_voice_mime', state.sosVoiceMime);
        $('sosRecStatus').textContent = '已錄製求救語音（' + Math.round(blob.size / 1024) + ' KB）';
        sosRecorder = null;
      };
      sosRecorder.start();
      $('sosRecStatus').textContent = '錄音中…再按一次停止';
    } catch (e) { alert('麥克風失敗：' + e.message); }
  } else if (sosRecorder) sosRecorder.stop();
}
function playSosVoice() {
  if (!state.sosVoiceBlob) return alert('尚未錄製求救語音');
  new Audio(`data:${state.sosVoiceMime};base64,${state.sosVoiceBlob}`).play().catch(() => {});
}

let sosFlashTimer = null, sosFlashOn = false;
const MORSE_SOS = ['s','s','s','l','l','l','s','s','s']; // s=short l=long
function toggleSosFlash() {
  const ov = $('sosFlashOverlay');
  if (sosFlashOn) { stopSosFlash(); return; }
  sosFlashOn = true; ov.classList.remove('hidden');
  let i = 0, unit = 200;
  const step = () => {
    if (!sosFlashOn) return;
    const sym = MORSE_SOS[i % MORSE_SOS.length];
    ov.classList.add('on');
    sosFlashTimer = setTimeout(() => {
      ov.classList.remove('on');
      sosFlashTimer = setTimeout(() => { i++; step(); }, unit);
    }, sym === 's' ? unit : unit * 3);
  };
  step();
  try {
    navigator.wakeLock?.request('screen').catch(() => {});
  } catch {}
}
function stopSosFlash() {
  sosFlashOn = false;
  if (sosFlashTimer) clearTimeout(sosFlashTimer);
  $('sosFlashOverlay').classList.add('hidden');
  $('sosFlashOverlay').classList.remove('on');
}

async function sendCheckin() {
  const loc = state.self ? `(${state.self.lat.toFixed(5)},${state.self.lon.toFixed(5)})` : '';
  const text = `✅ ${state.name} 報平安：我還好 ${loc}`;
  const payload = { type: 'TEXT', to: null, text };
  const sent = typeof meshSend === 'function' ? await meshSend(payload) : originate(payload);
  pushEntry('group', { dir: 'me', type: 'TEXT', text, name: state.name, ts: Date.now(), status: sent ? 'sent' : 'queued' });
  if (window.notify) notify('✅ 報平安', sent ? '已送出' : '已佇列，連線後送出');
}
function startCheckinTimer() {
  clearInterval(startCheckinTimer._id);
  if (!state.checkinEnabled) return;
  const ms = state.checkinMinutes * 60000;
  startCheckinTimer._id = setInterval(sendCheckin, ms);
}

async function setRallyPoint(lat, lon, label) {
  const pt = { lat, lon, label: label || '集合點', sid: state.id, name: state.name, ts: Date.now() };
  state.rallyPoints = state.rallyPoints.filter(r => r.sid !== state.id);
  state.rallyPoints.push(pt);
  store.setJSON('p2p_rally', state.rallyPoints);
  const msg = { type: 'RALLY', lat, lon, text: label };
  const sent = typeof meshSend === 'function' ? await meshSend(msg) : originate(msg);
  redrawRally(); renderRallyList();
  if (window.notify) notify('📍 集合點', sent ? '已設定集合點' : '已佇列，連線後送出');
}
function onRally(msg) {
  state.rallyPoints = state.rallyPoints.filter(r => !(r.sid === msg.sid));
  state.rallyPoints.push({ lat: msg.lat, lon: msg.lon, label: msg.text || '集合點', sid: msg.sid, name: msg.name, ts: msg.ts });
  store.setJSON('p2p_rally', state.rallyPoints);
  redrawRally(); renderRallyList();
  notify('📍 集合點', `${msg.name} 設定了集合點`);
}

function flashRallyAt(lat, lon) {
  if (!map) return;
  const el = document.createElement('div');
  el.className = 'rally-flash-marker';
  if (rallyFlashMarker) rallyFlashMarker.remove();
  rallyFlashMarker = new maplibregl.Marker({ element: el }).setLngLat([lon, lat]).addTo(map);
  let n = 0;
  const iv = setInterval(() => {
    el.classList.toggle('pulse', n % 2 === 0);
    n++;
    if (n > 10) {
      clearInterval(iv);
      if (rallyFlashMarker) { rallyFlashMarker.remove(); rallyFlashMarker = null; }
    }
  }, 400);
  const area = $('mapArea');
  if (area) {
    area.classList.add('map-rally-flash');
    setTimeout(() => area.classList.remove('map-rally-flash'), 2400);
  }
}

async function broadcastRallyAll() {
  if (!state.self) { alert('尚無定位，請先按「取得我的位置」'); return; }
  const label = (state.roleDisplayName || state.name) + ' 呼叫全員集合';
  const msg = { type: 'RALLY_CALL', lat: state.self.lat, lon: state.self.lon, text: label, ttl: 6 };
  const sent = typeof meshSend === 'function' ? await meshSend(msg) : originate(msg);
  state.rallyPoints = state.rallyPoints.filter(r => r.sid !== state.id);
  state.rallyPoints.push({ lat: state.self.lat, lon: state.self.lon, label: '全員集合', sid: state.id, name: state.name, ts: Date.now() });
  store.setJSON('p2p_rally', state.rallyPoints);
  redrawRally(); renderRallyList();
  flashRallyAt(state.self.lat, state.self.lon);
  if (navigator.vibrate) navigator.vibrate([200, 100, 200]);
  notify('📢 集合', sent ? '已廣播全員集合' : '已佇列，連線後自動送出');
}

function onRallyCall(msg) {
  state.rallyPoints = state.rallyPoints.filter(r => r.sid !== msg.sid);
  state.rallyPoints.push({ lat: msg.lat, lon: msg.lon, label: msg.text || '全員集合', sid: msg.sid, name: msg.name, ts: msg.ts || Date.now() });
  store.setJSON('p2p_rally', state.rallyPoints);
  redrawRally(); renderRallyList();
  flashRallyAt(msg.lat, msg.lon);
  if (navigator.vibrate) navigator.vibrate([300, 150, 300, 150, 500]);
  notify('📢 集合！', `${msg.name || '隊友'} 呼叫全員集合`);
  showPage('map');
  if (map) map.easeTo({ center: [msg.lon, msg.lat], zoom: 16, duration: 800 });
}
function clearMyRally() {
  state.rallyPoints = state.rallyPoints.filter(r => r.sid !== state.id);
  store.setJSON('p2p_rally', state.rallyPoints);
  redrawRally(); renderRallyList();
}
function redrawRally() {
  rallyMarkers.forEach(m => m.remove()); rallyMarkers.length = 0;
  if (!map) return;
  state.rallyPoints.forEach((r, i) => {
    const code = typeof rallyLetter === 'function' ? rallyLetter(i) : String(i + 1);
    const el = markerEl('#1565C0', code, false);
    el.style.borderRadius = '4px';
    const eta = typeof rallyEtaMinutes === 'function' ? formatEta(rallyEtaMinutes(r.lat, r.lon)) : '';
    const m = new maplibregl.Marker({ element: el }).setLngLat([r.lon, r.lat])
      .setPopup(new maplibregl.Popup().setHTML(`<b>集合點 ${code}</b><br>${escapeHtml(r.name || '')}<br>${escapeHtml(r.label || '')}${eta ? '<br>ETA ' + eta : ''}`)).addTo(map);
    rallyMarkers.push(m);
  });
}
function renderRallyList() {
  const box = $('rallyList'); if (!box) return;
  if (!state.rallyPoints.length) { box.innerHTML = '<div class="muted">尚無集合點</div>'; return; }
  box.innerHTML = '';
  state.rallyPoints.forEach((r, i) => {
    const code = typeof rallyLetter === 'function' ? rallyLetter(i) : String(i + 1);
    const eta = typeof rallyEtaMinutes === 'function' ? formatEta(rallyEtaMinutes(r.lat, r.lon)) : '';
    const nav = state.rallyNavTarget && state.rallyNavTarget.sid === r.sid;
    const d = document.createElement('div'); d.className = 'peer-item';
    d.innerHTML = `<span><b>[${code}]</b> ${escapeHtml(r.name)}：${escapeHtml(r.label)}${eta ? ' · <b>' + eta + '</b>' : ''}${nav ? ' <span class="rally-nav-tag">導引中</span>' : ''}<br><span class="muted small">${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}</span></span>`;
    const row = document.createElement('div'); row.className = 'row gap';
    const b = document.createElement('button'); b.className = 'btn ghost small'; b.textContent = '看地圖';
    b.onclick = () => { showPage('map'); map.easeTo({ center: [r.lon, r.lat], zoom: 16 }); };
    const navBtn = document.createElement('button'); navBtn.className = 'btn ghost small'; navBtn.textContent = '導引';
    navBtn.onclick = () => { if (typeof navigateToRally === 'function') navigateToRally(r); };
    row.appendChild(b); row.appendChild(navBtn);
    d.appendChild(row); box.appendChild(d);
  });
}
function renderLastKnown() {
  const box = $('lastKnownList'); if (!box) return;
  const ids = Object.keys(state.lastKnown);
  if (!ids.length) { box.innerHTML = '<div class="muted">尚無記錄</div>'; return; }
  box.innerHTML = '';
  ids.forEach(sid => {
    const p = state.lastKnown[sid];
    const stale = !state.peers[sid] || Date.now() - (state.peers[sid].ts || 0) > STALE_MS;
    const d = document.createElement('div'); d.className = 'peer-item';
    const roleTag = (p.roleName || (typeof ROLE_LABELS !== 'undefined' && ROLE_LABELS[p.role]) || '').trim();
    const rolePart = roleTag ? ` · ${escapeHtml(roleTag)}` : '';
    const bat = p.battery != null ? ` · 🔋${p.battery}%` : '';
    d.innerHTML = `<span>${escapeHtml(p.name)}${rolePart}${bat}${stale ? ' <span class="peer-stale-tag">離線</span>' : ' <span class="muted small">在線</span>'}<br><span class="muted small">${new Date(p.ts).toLocaleString('zh-TW')} · ${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}</span></span>`;
    box.appendChild(d);
  });
}
function loadLastKnownPeers() {
  Object.keys(state.lastKnown).forEach(sid => {
    if (!state.peers[sid]) state.peers[sid] = { ...state.lastKnown[sid] };
    setPeerOnMap(sid, state.peers[sid]);
  });
  renderLastKnown();
}
setInterval(() => {
  const area = $('mapArea');
  if (!map || !area || area.classList.contains('hidden')) return;
  Object.keys(state.peers).forEach(sid => setPeerOnMap(sid, state.peers[sid]));
  renderPeerList(); renderLastKnown();
}, 15000);

/* banners / modals */
let pendingCallMsg = null;
function showCallBanner(msgOrName) {
  const msg = (msgOrName && typeof msgOrName === 'object') ? msgOrName : { name: msgOrName };
  pendingCallMsg = msg;
  const name = msg.name || '隊友';
  // 1:1 私訊用 msg.sid（來電方）；勿用 text（APK 對私訊把對方 sid 放 text，收端會變成自己的 sid）
  const isGroup = !msg.text || msg.text === 'group' || !msg.ackTo;
  const conv = isGroup ? 'group' : (msg.sid || 'group');
  pendingCallMsg._convKey = conv;
  $('callText').textContent = `📞 ${name} 要求通話`;
  $('callBanner').classList.remove('hidden');
}
function hideCallBanner() {
  pendingCallMsg = null;
  $('callBanner').classList.add('hidden');
}
function joinIncomingCall() {
  const m = pendingCallMsg;
  hideCallBanner();
  if (!m) return;
  if (m.sid) rememberContact(m.sid, m.name);
  const isGroup = !m.text || m.text === 'group' || !m.ackTo;
  const key = m._convKey || (isGroup ? 'group' : (m.sid || 'group'));
  if (typeof showPage === 'function') showPage('chat');
  openChatRoom(key);
}
function showOffCourseModal(dev, el) { $('offCourseText').textContent = `你已偏離路線約 ${Math.round(dev)} 公尺、持續約 ${Math.floor(el / 60000)} 分鐘。若無回應，30 分鐘後將自動通知緊急聯絡人。`; $('offCourseModal').classList.remove('hidden'); }
function hideOffCourseModal() { $('offCourseModal').classList.add('hidden'); }

function isAndroidDevice() {
  return /Android/i.test(navigator.userAgent || '');
}

const CONN_MODE_HINTS = {
  auto: '近用 Wi‑Fi、遠用網：無網→熱點+QR；有網→中繼+6碼。Android 近距更遠→裝 APK。',
  hotspot: '近用 Wi‑Fi：熱點+QR 兩輪配對。適合 Chrome／iPhone 近距。',
  apk: '近距更遠：兩台 Android 到「設定→安裝與下載」裝 APK，Wi‑Fi Direct 搜尋連線（50–200 m）。遠距仍用中繼+6碼。',
};

function setConnMode(mode) {
  const m = mode === 'hotspot' || mode === 'apk' ? mode : 'auto';
  state.connMode = m;
  store.set('p2p_conn_mode', m);
  applyConnModeUI();
}

function applyConnModeUI() {
  const mode = state.connMode || 'auto';
  document.body.classList.toggle('conn-mode-hotspot', mode === 'hotspot');
  document.body.classList.toggle('conn-mode-apk', mode === 'apk');
  document.body.classList.toggle('needs-offline-pair', typeof needsOfflinePairing === 'function' && needsOfflinePairing());
  document.querySelectorAll('.conn-mode-btn').forEach((btn) => {
    btn.classList.toggle('active', btn.dataset.connMode === mode);
  });
  const hint = $('connModeHint');
  if (hint) {
    let t = CONN_MODE_HINTS[mode] || CONN_MODE_HINTS.auto;
    if (mode === 'auto' && isAndroidDevice()) t += ' （偵測到 Android，近距建議 APK）';
    hint.textContent = t;
  }
  if (typeof updateOfflineModeBanner === 'function') updateOfflineModeBanner();
  if (typeof updateDegradeBanner === 'function') updateDegradeBanner();
}

function bindConnModeUI() {
  document.querySelectorAll('.conn-mode-btn').forEach((btn) => {
    btn.onclick = () => setConnMode(btn.dataset.connMode || 'auto');
  });
  const qp = new URLSearchParams(location.search).get('conn');
  if (qp === 'hotspot' || qp === 'apk' || qp === 'auto') setConnMode(qp);
  if (new URLSearchParams(location.search).get('mixed') === '1') {
    state.mixedTeam = true;
    document.body.classList.add('mixed-team-apk');
    if (!qp) setConnMode('hotspot');
  }
  else applyConnModeUI();
}

/** APK WebPair bridge: align identity + relay with native so UDP 6-code one-tap works. */
function applyApkBridgeFromUrl() {
  const params = new URLSearchParams(location.search);
  const nid = (params.get('nativeId') || '').trim();
  const nativeSelf = (params.get('nativeSelf') || '').trim().toUpperCase();
  if (nid.length >= 8) {
    state.id = nid;
    store.set('p2p_id', nid);
  } else if (/^[A-Z0-9]{6}$/.test(nativeSelf)) {
    state.id = nativeSelf;
    store.set('p2p_id', nativeSelf);
  }
  const relay = (params.get('relay') || '').trim();
  if (relay) {
    state.relayUrl = relay;
    state.fixedRelayUrl = relay;
    store.set('p2p_relay_url', relay);
    store.set('p2p_fixed_relay', relay);
  }
  const room = (params.get('room') || '').trim();
  if (room) {
    state.relayRoom = room;
    store.set('p2p_relay_room', room);
  }
  if (typeof refreshMyCodeUI === 'function') refreshMyCodeUI();
}

/** APK WebPair 一鍵連線：?autoDial=1&dial=ABCDEF → 就緒後自動 dial 一次 */
function tryAutoDialFromUrl() {
  if (window._autoDialDone) return;
  const params = new URLSearchParams(location.search);
  if (params.get('autoDial') !== '1') return;
  const code = (params.get('dial') || '').trim().toUpperCase();
  if (!/^[A-Z0-9]{6}$/.test(code)) return;
  window._autoDialDone = true;
  applyApkBridgeFromUrl();
  // One-tap must use relay SIG even if offline-first / navigator.onLine is flaky
  if (typeof connectRelay === 'function') connectRelay(true);
  if (typeof setDialStatus === 'function') {
    setDialStatus('一鍵連線：正在連中繼並撥號 ' + code + '…', 'busy');
  }
  const attempt = (n) => {
    if (n > 40) {
      const dw = $('dialCodeWalkie');
      if (dw) dw.value = code;
      if (typeof noteRecentPeer === 'function') noteRecentPeer(code);
      store.set('p2p_last_peer', code);
      document.body.classList.add('needs-offline-pair');
      if (typeof showPage === 'function') showPage('walkie');
      if (typeof setDialStatus === 'function') {
        setDialStatus('一鍵：中繼未連上。已預填 ' + code + ' — 請互掃「產生邀請 QR」離線配對，或連 Wi‑Fi／熱點後重試。', 'warn');
      }
      return;
    }
    if (ws && ws.readyState === WebSocket.OPEN) {
      const dw = $('dialCodeWalkie');
      if (dw) dw.value = code;
      dial(code);
      return;
    }
    setTimeout(() => attempt(n + 1), 500);
  };
  setTimeout(() => attempt(0), 600);
}

window.applyConnModeUI = applyConnModeUI;
window.setConnMode = setConnMode;

/* ===================== UI binding ===================== */
const PAGE_TITLES = { walkie: '對講機', chat: '聊天', map: '地圖', safety: '安全', settings: '設定' };
const MAP_PAGES = new Set(['map']);
const FULL_PAGES = new Set(['walkie', 'chat', 'settings']);

let mapSplitPct = parseFloat(store.get('p2p_map_split', '50')) || 50;
function applyMapSplit() {
  document.documentElement.style.setProperty('--map-split', mapSplitPct + '%');
}
function bindMapSplit() {
  const handle = $('mapSplitHandle');
  const body = $('body');
  if (!handle || !body) return;
  let dragging = false;
  const onMove = (clientY) => {
    const rect = body.getBoundingClientRect();
    const pct = Math.min(85, Math.max(15, ((clientY - rect.top) / rect.height) * 100));
    mapSplitPct = pct;
    store.set('p2p_map_split', String(Math.round(pct)));
    applyMapSplit();
    const slider = $('mapSizeSlider');
    const label = $('mapSizeLabel');
    if (slider) slider.value = Math.round(pct);
    if (label) label.textContent = Math.round(pct);
    if (map) map.resize();
  };
  const end = () => { dragging = false; };
  handle.addEventListener('mousedown', (e) => { dragging = true; e.preventDefault(); });
  handle.addEventListener('touchstart', (e) => { dragging = true; e.preventDefault(); }, { passive: false });
  window.addEventListener('mousemove', (e) => { if (dragging) onMove(e.clientY); });
  window.addEventListener('touchmove', (e) => { if (dragging && e.touches[0]) onMove(e.touches[0].clientY); }, { passive: false });
  window.addEventListener('mouseup', end);
  window.addEventListener('touchend', end);
}

function showPage(name) {
  document.querySelectorAll('.nav-item, .bottom-nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === name));
  document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
  const pageEl = $('page-' + name);
  if (pageEl) pageEl.classList.remove('hidden');
  $('pageTitle').textContent = PAGE_TITLES[name] || name;
  const mapArea = $('mapArea'), pages = $('pages'), body = $('body'), splitHandle = $('mapSplitHandle');
  if (MAP_PAGES.has(name)) {
    mapArea.classList.remove('hidden');
    body.classList.add('split-view');
    if (splitHandle) splitHandle.classList.remove('hidden');
    pages.classList.remove('pages-full');
    applyMapSplit();
    requestAnimationFrame(() => {
      ensureMap();
      if (map) {
        map.resize();
        if (state.self) map.easeTo({ center: [state.self.lon, state.self.lat] });
        state.mapFollowSelf = true;
      }
    });
    setTimeout(() => { if (map) map.resize(); }, 150);
    setTimeout(() => { if (map) map.resize(); }, 500);
  } else {
    mapArea.classList.add('hidden');
    body.classList.remove('split-view');
    if (splitHandle) splitHandle.classList.add('hidden');
    pages.classList.add('pages-full');
  }
  $('sidebar').classList.remove('open');
  syncSidebarMenuBtn();
  if (name === 'chat') showChatHome();
  setTimeout(() => { if (map && MAP_PAGES.has(name)) map.resize(); }, 80);
}

function syncSidebarMenuBtn() {
  const open = $('sidebar').classList.contains('open');
  document.body.classList.toggle('sidebar-open', open);
  const btn = $('menuBtn');
  if (btn) {
    btn.setAttribute('aria-hidden', open ? 'true' : 'false');
    btn.tabIndex = open ? -1 : 0;
  }
}

function bindUI() {
  document.querySelectorAll('.nav-item, .bottom-nav-item').forEach(n => n.onclick = () => showPage(n.dataset.page));
  $('menuBtn').onclick = () => {
    $('sidebar').classList.toggle('open');
    syncSidebarMenuBtn();
  };

  $('floorInput').value = state.manualFloor;
  $('floorInput').oninput = e => setFloor(e.target.value);

  $('threshold').value = state.alarmThreshold; $('thLabel').textContent = state.alarmThreshold;
  $('threshold').oninput = e => { state.alarmThreshold = +e.target.value; $('thLabel').textContent = state.alarmThreshold; store.set('p2p_alarm_th', String(state.alarmThreshold)); };

  const syncDistPanels = () => {
    const manual = $('manualDistSwitch');
    const mPanel = $('manualDistPanel');
    const oPanel = $('distOffsetPanel');
    if (manual) manual.checked = state.manualDistOverride;
    if (mPanel) mPanel.classList.toggle('hidden', !state.manualDistOverride);
    if (oPanel) oPanel.classList.toggle('hidden', state.manualDistOverride);
  };
  if ($('manualDistSwitch')) {
    $('manualDistSlider').value = state.manualDistM;
    $('manualDistLabel').textContent = state.manualDistM;
    $('distOffsetSlider').value = state.distOffsetM;
    $('distOffsetLabel').textContent = state.distOffsetM;
    syncDistPanels();
    $('manualDistSwitch').onchange = e => {
      state.manualDistOverride = e.target.checked;
      store.set('p2p_manual_dist', state.manualDistOverride ? '1' : '0');
      syncDistPanels();
      updateDistances();
    };
    $('manualDistSlider').oninput = e => {
      state.manualDistM = +e.target.value;
      $('manualDistLabel').textContent = state.manualDistM;
      store.set('p2p_manual_dist_m', String(state.manualDistM));
      updateDistances();
    };
    $('distOffsetSlider').oninput = e => {
      state.distOffsetM = +e.target.value;
      $('distOffsetLabel').textContent = state.distOffsetM;
      store.set('p2p_dist_offset', String(state.distOffsetM));
      updateDistances();
    };
  }
  if ($('mapSizeSlider')) {
    $('mapSizeSlider').value = Math.round(mapSplitPct);
    $('mapSizeLabel').textContent = Math.round(mapSplitPct);
    $('mapSizeSlider').oninput = e => {
      mapSplitPct = +e.target.value;
      $('mapSizeLabel').textContent = mapSplitPct;
      store.set('p2p_map_split', String(mapSplitPct));
      applyMapSplit();
      if (map) map.resize();
    };
  }

  // PTT realtime voice
  const ptt = $('pttBtn');
  const pttDown = async (e) => {
    e.preventDefault();
    if (!hasMeshLink()) {
      setDialStatus('請先配對：無外網用「產生邀請 QR」→ 複製代碼 → 對方「貼上配對碼」', 'err');
      return;
    }
    try {
      unlockRemoteAudio();
      const mode = choosePttVoiceMode();
      if (!mode) {
        setDialStatus('無可用語音通道，請完成 WebRTC 配對或連中繼', 'err');
        return;
      }
      await applyPttVoiceMode(mode);
      if (state.teamId) {
        originate({ type: 'PTT_ACTIVE', text: state.name, ttl: 2 });
      }
      ptt.classList.add('active');
      ptt.textContent = '說話中…放開停止';
    } catch (err) { setDialStatus('麥克風失敗：' + err.message, 'err'); }
  };
  const pttUp = (e) => {
    e.preventDefault();
    if (rtc.micTrack) rtc.micTrack.enabled = false;
    stopPttStream();
    pttVoiceMode = null;
    window.pttVoiceMode = null;
    ptt.classList.remove('active');
    ptt.textContent = '按住說話';
  };
  ptt.addEventListener('mousedown', pttDown); ptt.addEventListener('touchstart', pttDown, { passive: false });
  ptt.addEventListener('mouseup', pttUp); ptt.addEventListener('touchend', pttUp);
  $('audioUnlockBtn').onclick = unlockRemoteAudio;
  $('relayReconnectWalkie').onclick = () => {
    if (typeof forceRelayReconnect === 'function') forceRelayReconnect();
    else connectRelay(true);
  };
  bindConnModeUI();
  if (typeof initStabilityV62 === 'function') initStabilityV62();
  else if (typeof initStabilityV51 === 'function') initStabilityV51();
  bindScanTrigger($('scanConnectWalkieBtn'));
  bindScanZoomButtons();
  $('makeOfferWalkieBtn').onclick = () => makeOfferAndShowQR();
  const opg = $('offlinePairGuideBtn');
  const opc = $('offlinePairClose');
  if (opg) opg.onclick = () => $('offlinePairModal')?.classList.remove('hidden');
  if (opc) opc.onclick = () => $('offlinePairModal')?.classList.add('hidden');
  $('mapOnlineBtn').onclick = async () => {
    state.mapSrc = 'online';
    store.set('p2p_mapsrc', 'online');
    ensureMap();
    if (map) {
      map.setStyle(MAP_ONLINE_STYLE);
      map.once('style.load', () => {
        ensureRouteLayer(); map.resize();
        if (state.self) setSelfOnMap(state.self.lat, state.self.lon);
        Object.keys(state.peers).forEach(sid => setPeerOnMap(sid, state.peers[sid]));
      });
      updateMapHint();
    }
    if (typeof showMapHint === 'function') showMapHint('已切換線上街道圖（需網路）');
  };
  const mapOffBtn = $('mapOfflineOnlyBtn');
  if (mapOffBtn) mapOffBtn.onclick = () => {
    state.mapSrc = 'offline';
    store.set('p2p_mapsrc', 'offline');
    const off = $('mapOffline');
    if (off) off.checked = true;
    ensureMap();
    if (typeof applyMapSource === 'function') applyMapSource();
    else if (typeof currentStyle === 'function' && map) {
      try {
        map.setStyle(currentStyle());
        map.once('style.load', () => {
          ensureRouteLayer(); map.resize();
          if (state.self) setSelfOnMap(state.self.lat, state.self.lon);
          Object.keys(state.peers).forEach(sid => setPeerOnMap(sid, state.peers[sid]));
        });
      } catch (e) {}
    }
    if (typeof updateMapHint === 'function') updateMapHint();
    if (typeof showMapHint === 'function') showMapHint('已切換純離線圖（不打線上 OSM）');
  };
  $('geoBtn').onclick = requestGeoOnce;

  $('callBtn').onclick = async () => {
    const key = state.activeConv || 'group';
    const payload = { type: 'CALL_REQUEST', text: key, to: key === 'group' ? null : key };
    const sent = typeof meshSend === 'function' ? await meshSend(payload) : originate(payload);
    alert(sent ? '已送出通話請求' : '已佇列（需 Direct／中繼；LoRa 不支援通話）');
  };
  const callJoin = $('callJoin');
  if (callJoin) callJoin.onclick = joinIncomingCall;
  $('callDismiss').onclick = hideCallBanner;

  // chat
  $('chatBackBtn').onclick = showChatHome;
  const chatKeep = $('chatDisconnectKeep');
  const chatDel = $('chatDisconnectDelete');
  if (chatKeep) chatKeep.onclick = () => { state.chatLinkLost = null; renderChatDisconnectBanner(); };
  if (chatDel) chatDel.onclick = () => {
    if (!state.chatLinkLost) return;
    const sid = state.chatLinkLost.sid;
    const n = state.chatLinkLost.name;
    if (confirm('確定刪除「' + n + '」的聊天記錄與通訊錄？')) {
      deleteContactChat(sid);
      state.chatLinkLost = null;
      renderChatDisconnectBanner();
    }
  };
  const chatKeepHist = $('chatKeepHistoryBtn');
  const chatDelHist = $('chatDeleteHistoryBtn');
  if (chatKeepHist) chatKeepHist.onclick = () => {
    chatKeepHist.classList.add('active');
    chatDelHist?.classList.remove('active');
  };
  if (chatDelHist) chatDelHist.onclick = () => {
    const sid = state.activeConv;
    if (!sid || sid === 'group') return;
    if (!confirm('確定刪除「' + convTitle(sid) + '」的聊天記錄與通訊錄？')) return;
    deleteContactChat(sid);
  };
  $('sendBtn').onclick = sendText;
  $('chatText').addEventListener('keydown', e => { if (e.key === 'Enter') sendText(); });
  $('photoInput').onchange = e => { if (e.target.files[0]) sendPhoto(e.target.files[0]); e.target.value = ''; };
  const vb = $('voiceBtn');
  const vDown = (e) => { e.preventDefault(); vb.classList.add('recording'); startClip(); };
  const vUp = (e) => { e.preventDefault(); vb.classList.remove('recording'); stopClipAndSend(); };
  vb.addEventListener('mousedown', vDown); vb.addEventListener('touchstart', vDown, { passive: false });
  vb.addEventListener('mouseup', vUp); vb.addEventListener('touchend', vUp);

  // connect (codes) — 6碼／貼碼在對講頁
  refreshMyCodeUI();
  const copyWalkie = $('copyCodeWalkieBtn');
  if (copyWalkie) copyWalkie.onclick = () => { copy(myCode()); };
  const copySettings = $('copyCodeBtn');
  if (copySettings) copySettings.onclick = () => { copy(myCode()); };
  const makeOfferSettings = $('makeOfferBtn');
  if (makeOfferSettings) makeOfferSettings.onclick = () => makeOfferAndShowQR();
  $('dialBtnWalkie').onclick = () => dial($('dialCodeWalkie').value);
  $('dialCodeWalkie').addEventListener('keydown', (e) => { if (e.key === 'Enter') dial($('dialCodeWalkie').value); });
  const scanSettings = $('scanConnectBtn');
  if (scanSettings) bindScanTrigger(scanSettings);
  $('qrClose').onclick = () => $('qrModal').classList.add('hidden');
  const qrCp = $('qrCopyBtn');
  if (qrCp) qrCp.onclick = () => { if (lastQrPayload) copy(lastQrPayload); alert('已複製代碼'); };
  $('scanClose').onclick = closeScanner;
  const scanPhotoBtn = $('scanPhotoBtn');
  const qrPhotoInput = $('qrPhotoInput');
  if (scanPhotoBtn && qrPhotoInput) {
    scanPhotoBtn.onclick = () => qrPhotoInput.click();
    qrPhotoInput.onchange = (e) => {
      const f = e.target.files && e.target.files[0];
      e.target.value = '';
      if (f) scanQrFromPhotoFile(f);
    };
  }

  // safety
  $('editRouteBtn').onclick = () => { state.editingRoute = !state.editingRoute; $('editRouteBtn').textContent = state.editingRoute ? '編輯中：點地圖加點 ✓' : '編輯路線'; };
  $('addHereBtn').onclick = () => { if (state.self) { state.route.push({ ...state.self }); persistRoute(); redrawRoute(); } };
  $('clearRouteBtn').onclick = () => { state.route = []; persistRoute(); redrawRoute(); $('abStatus').textContent = '已清除路線'; };
  $('abRouteBtn').onclick = startAB;
  $('abFromHereBtn').onclick = abFromHere;
  $('contactName').value = state.contactName; $('contactPhone').value = state.contactPhone;
  $('contactName').oninput = e => { state.contactName = e.target.value; store.set('p2p_contact_name', state.contactName); };
  $('contactPhone').oninput = e => { state.contactPhone = e.target.value; store.set('p2p_contact_phone', state.contactPhone); };
  $('devThreshold').value = state.devThreshold; $('devLabel').textContent = state.devThreshold;
  $('devThreshold').oninput = e => { state.devThreshold = +e.target.value; $('devLabel').textContent = state.devThreshold; store.set('p2p_dev_th', String(state.devThreshold)); };
  $('guardSwitch').checked = state.guardEnabled;
  $('guardSwitch').onchange = e => { state.guardEnabled = e.target.checked; store.set('p2p_guard', state.guardEnabled ? '1' : '0'); if (!state.guardEnabled) resetGuard(); updateGuardStatus(); updateWakeLock(); };
  $('ocIgnore').onclick = guardianIgnore; $('ocNotify').onclick = guardianNotifyNow;

  // settings page (no drawer)
  $('myName').value = state.name; $('relayUrl').value = state.relayUrl; $('relayRoom').value = state.relayRoom; $('manualFloor').value = state.manualFloor;
  $('myName').oninput = e => {
    state.name = e.target.value || '我的瀏覽器';
    store.set('p2p_name', state.name);
    refreshMyCodeUI();
    if (typeof updateWalkieStatusBar === 'function') updateWalkieStatusBar();
  };
  $('showMyCodeQrBtn').onclick = showMyCodeQR;
  $('autoReconnectSwitch').checked = state.autoReconnect;
  $('autoReconnectSwitch').onchange = e => { state.autoReconnect = e.target.checked; store.set('p2p_auto_reconnect', state.autoReconnect ? '1' : '0'); };
  $('keepScreenSwitch').checked = state.keepScreenOn;
  $('keepScreenSwitch').onchange = e => { state.keepScreenOn = e.target.checked; store.set('p2p_keep_screen', state.keepScreenOn ? '1' : '0'); updateWakeLock(); };
  const vnm = $('voiceNotifyMode');
  if (vnm) { vnm.value = state.voiceNotifyMode; vnm.onchange = e => { state.voiceNotifyMode = e.target.value; store.set('p2p_voice_notify', state.voiceNotifyMode); }; }
  document.querySelectorAll('input[name=geoMode]').forEach(r => {
    r.checked = r.value === state.geoMode;
    r.onchange = () => { if (r.checked) { state.geoMode = r.value; store.set('p2p_geo_mode', state.geoMode); restartTelemetry(); startGeolocation(); } };
  });
  $('meshTtl').value = state.meshTtl;
  $('meshTtlLabel').textContent = state.meshTtl;
  $('meshTtl').oninput = e => { state.meshTtl = +e.target.value; $('meshTtlLabel').textContent = state.meshTtl; store.set('p2p_mesh_ttl', String(state.meshTtl)); };
  $('markerColor').value = state.markerColor;
  $('markerColor').oninput = e => { state.markerColor = e.target.value; store.set('p2p_marker_color', state.markerColor); if (state.self) setSelfOnMap(state.self.lat, state.self.lon); };
  $('markerNick').value = state.markerNick;
  $('markerNick').oninput = e => { state.markerNick = e.target.value.slice(0, 2); store.set('p2p_marker_nick', state.markerNick); if (state.self) setSelfOnMap(state.self.lat, state.self.lon); };
  $('exportSettingsBtn').onclick = exportSettings;
  $('importSettingsBtn').onclick = () => $('importSettingsFile').click();
  $('importSettingsFile').onchange = e => {
    const f = e.target.files[0];
    if (!f) return;
    const reader = new FileReader();
    reader.onload = () => { try { applyImportedSettings(JSON.parse(reader.result)); } catch (err) { alert('匯入失敗：' + err.message); } };
    reader.readAsText(f);
    e.target.value = '';
  };
  $('exportChatBtn').onclick = exportChatHistory;
  $('mapWizardBtn').onclick = () => $('mapWizardModal').classList.remove('hidden');
  $('mapWizardClose').onclick = () => $('mapWizardModal').classList.add('hidden');
  $('firebaseGuideBtn').onclick = () => $('firebaseModal').classList.remove('hidden');
  $('firebaseClose').onclick = () => $('firebaseModal').classList.add('hidden');
  $('androidWifiGuideBtn').onclick = () => $('androidWifiModal').classList.remove('hidden');
  $('androidWifiClose').onclick = () => $('androidWifiModal').classList.add('hidden');
  $('meshtasticGuideBtn').onclick = () => $('meshtasticModal').classList.remove('hidden');
  $('meshtasticClose').onclick = () => $('meshtasticModal').classList.add('hidden');
  $('meshtasticBleBtn').onclick = () => {
    const el = $('meshtasticStatus');
    const on = el && el.classList.contains('ok');
    if (on && window.meshtasticDisconnectBle) meshtasticDisconnectBle();
    else if (window.meshtasticConnectBle) meshtasticConnectBle();
  };
  const aws = $('androidWifiStatus');
  if (aws && /Android/i.test(navigator.userAgent)) {
    aws.textContent = 'Android：建議安裝 APK，近距以 Wi‑Fi Direct 為主通道';
    aws.classList.add('ok');
  }
  renderRecentPeers();
  $('resetMapSplitBtn').onclick = () => { mapSplitPct = 50; store.set('p2p_map_split', '50'); applyMapSplit(); if (map) map.resize(); };
  $('relayUrl').oninput = e => { state.relayUrl = e.target.value.trim(); store.set('p2p_relay_url', state.relayUrl); };
  $('relayRoom').oninput = e => { state.relayRoom = e.target.value.trim() || 'team1'; store.set('p2p_relay_room', state.relayRoom); };
  $('manualFloor').oninput = e => setFloor(e.target.value);
  $('relayConnect').onclick = () => connectRelay(true); $('relayDisconnect').onclick = disconnectRelay;

  // TURN + routing
  $('turnUrl').value = state.turnUrl; $('turnUser').value = state.turnUser; $('turnCred').value = state.turnCred; $('routingUrl').value = state.routingUrl;
  $('turnUrl').oninput = e => { state.turnUrl = e.target.value.trim(); store.set('p2p_turn_url', state.turnUrl); };
  $('turnUser').oninput = e => { state.turnUser = e.target.value.trim(); store.set('p2p_turn_user', state.turnUser); };
  $('turnCred').oninput = e => { state.turnCred = e.target.value.trim(); store.set('p2p_turn_cred', state.turnCred); };
  $('routingUrl').oninput = e => { state.routingUrl = e.target.value.trim(); store.set('p2p_routing', state.routingUrl); };

  // map source
  $('mapOnline').checked = state.mapSrc === 'online'; $('mapOffline').checked = state.mapSrc === 'offline'; $('mapBlank').checked = state.mapSrc === 'blank';
  $('pmtilesPath').value = state.pmtilesPath;
  $('mapOnline').onchange = () => { state.mapSrc = 'online'; store.set('p2p_mapsrc', 'online'); };
  $('mapOffline').onchange = () => { state.mapSrc = 'offline'; store.set('p2p_mapsrc', 'offline'); };
  $('mapBlank').onchange = () => { state.mapSrc = 'blank'; store.set('p2p_mapsrc', 'blank'); };
  $('pmtilesPath').oninput = e => { state.pmtilesPath = e.target.value.trim(); store.set('p2p_pmtiles', state.pmtilesPath); };
  $('applyMapBtn').onclick = () => applyMapSource($('pmtilesFile').files[0]);

  $('clearHistoryBtn').onclick = () => {
    if (!confirm('確定清除所有聊天記錄？（通訊錄聯絡人保留）')) return;
    state.convs = { group: [] };
    Object.keys(state.contacts).forEach(sid => { state.convs[sid] = []; });
    state.unread = {};
    persistConvs();
    persistUnread();
    showChatHome();
  };

  // rescue page
  $('sosText').value = state.sosText;
  $('sosText').oninput = e => { state.sosText = e.target.value; store.set('p2p_sos_text', state.sosText); };
  $('sosVoiceBroadcastBtn').onclick = () => broadcastSos(true);
  // sosBroadcastBtn: long-press bound in features.js
  $('sosFlashBtn').onclick = toggleSosFlash;
  $('sosFlashStop').onclick = stopSosFlash;
  let sosRecActive = false;
  $('sosRecBtn').onclick = () => { sosRecActive = !sosRecActive; recordSosVoice(sosRecActive); $('sosRecBtn').textContent = sosRecActive ? '停止錄製' : '錄製求救語音'; };
  $('sosPlayBtn').onclick = playSosVoice;
  if (state.sosVoiceBlob) $('sosRecStatus').textContent = '已錄製求救語音';

  $('medBlood').value = state.medCard.blood || '';
  $('medAllergies').value = state.medCard.allergies || '';
  $('medNotes').value = state.medCard.notes || '';
  $('medPhone').value = state.medCard.phone || '';
  $('medBlood').oninput = e => { state.medCard.blood = e.target.value; saveMedCard(); };
  $('medAllergies').oninput = e => { state.medCard.allergies = e.target.value; saveMedCard(); };
  $('medNotes').oninput = e => { state.medCard.notes = e.target.value; saveMedCard(); };
  $('medPhone').oninput = e => { state.medCard.phone = e.target.value; saveMedCard(); };
  renderMedPreview();

  $('checkinSwitch').checked = state.checkinEnabled;
  $('checkinMinutes').value = state.checkinMinutes;
  $('checkinLabel').textContent = state.checkinMinutes;
  $('checkinSwitch').onchange = e => { state.checkinEnabled = e.target.checked; store.set('p2p_checkin', state.checkinEnabled ? '1' : '0'); startCheckinTimer(); };
  $('checkinMinutes').oninput = e => { state.checkinMinutes = +e.target.value; $('checkinLabel').textContent = state.checkinMinutes; store.set('p2p_checkin_min', String(state.checkinMinutes)); startCheckinTimer(); };
  $('checkinNowBtn').onclick = sendCheckin;

  $('rallyHereBtn').onclick = () => { if (!state.self) return alert('尚無定位'); setRallyPoint(state.self.lat, state.self.lon, '集合點'); };
  $('rallyMapBtn').onclick = () => { rallyPickMode = true; showPage('map'); showMapHint('請在地圖點選集合點位置'); };
  const rallyAll = $('rallyAllBtn');
  if (rallyAll) rallyAll.onclick = broadcastRallyAll;
  const pts = $('peerTracksSwitch');
  if (pts) {
    pts.checked = state.showPeerTracks;
    pts.onchange = e => {
      state.showPeerTracks = e.target.checked;
      store.set('p2p_show_tracks', state.showPeerTracks ? '1' : '0');
      redrawPeerTracks();
    };
  }
  $('rallyClearBtn').onclick = clearMyRally;
  $('compassPermBtn').onclick = startCompass;
  $('osrmLanBtn').onclick = () => {
    const ip = prompt('輸入區網 OSRM 伺服器 IP（本機填 127.0.0.1 或區網 IP）', '127.0.0.1');
    if (!ip) return;
    state.routingUrl = `http://${ip}:5000`;
    store.set('p2p_routing', state.routingUrl);
    $('routingUrl').value = state.routingUrl;
    alert('已套用：' + state.routingUrl);
  };
}

/* ===================== Wake lock (keep guardian alive when screen on) ===================== */
let wakeLock = null;
async function updateWakeLock() {
  try {
    const want = (state.guardEnabled || state.keepScreenOn) && 'wakeLock' in navigator && document.visibilityState === 'visible';
    if (want) {
      if (!wakeLock) { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; }); }
    } else if (wakeLock) { await wakeLock.release(); wakeLock = null; }
  } catch {}
}
document.addEventListener('visibilitychange', updateWakeLock);

/* ===================== PWA ===================== */
let deferredInstall = null;

function isPwaInstalled() {
  return window.matchMedia('(display-mode: standalone)').matches
    || window.matchMedia('(display-mode: fullscreen)').matches
    || !!window.navigator.standalone;
}

function isAndroidChrome() {
  return /Android/i.test(navigator.userAgent) && /Chrome/i.test(navigator.userAgent) && !/EdgA|OPR|SamsungBrowser/i.test(navigator.userAgent);
}

function showAndroidInstallModal(extra) {
  const m = $('androidInstallModal');
  const hint = $('androidInstallHint');
  if (hint) {
    hint.textContent = extra || (deferredInstall
      ? '瀏覽器已準備好，按下方「再試一次安裝」應會跳出安裝視窗。'
      : 'Chrome 未自動跳出安裝視窗時，請依下列步驟手動加入主畫面。');
  }
  if (m) m.classList.remove('hidden');
}

function registerSW() {
  if (!('serviceWorker' in navigator)) return;
  navigator.serviceWorker.register('./sw.js', { updateViaCache: 'none' }).then((reg) => {
    reg.update().catch(() => {});
  }).catch(() => {});
}

function refreshInstallButtons() {
  const installed = isPwaInstalled();
  const androidPwa = $('installAndroidPwaBtn');
  const legacy = $('installAppBtn');
  if (installed) {
    if (androidPwa) { androidPwa.textContent = '✓ 已安裝到主畫面'; androidPwa.disabled = true; }
    if (legacy) legacy.classList.add('hidden');
    return;
  }
  if (androidPwa) {
    androidPwa.disabled = false;
    androidPwa.textContent = deferredInstall
      ? '📲 安裝到主畫面（一鍵）'
      : '📲 Android Chrome 安裝網頁版';
  }
}

function setupPwaInstall() {
  window.addEventListener('beforeinstallprompt', (e) => {
    e.preventDefault();
    deferredInstall = e;
    refreshInstallButtons();
    const legacy = $('installAppBtn');
    if (legacy) legacy.classList.remove('hidden');
  });
  window.addEventListener('appinstalled', () => {
    deferredInstall = null;
    refreshInstallButtons();
  });
  const legacyBtn = $('installAppBtn');
  if (legacyBtn) legacyBtn.onclick = () => promptPwaInstall();
  const androidPwa = $('installAndroidPwaBtn');
  if (androidPwa) androidPwa.onclick = () => promptPwaInstall();
  const iosBtn = $('installIosBtn');
  if (iosBtn) iosBtn.onclick = () => $('iosInstallModal').classList.remove('hidden');
  const iosClose = $('iosInstallClose');
  if (iosClose) iosClose.onclick = () => $('iosInstallModal').classList.add('hidden');
  const androidClose = $('androidInstallClose');
  if (androidClose) androidClose.onclick = () => $('androidInstallModal').classList.add('hidden');
  const androidRetry = $('androidInstallRetry');
  if (androidRetry) androidRetry.onclick = () => promptPwaInstall(true);
  const apkBtn = $('installApkBtn');
  if (apkBtn) apkBtn.onclick = () => {
    const base = (state.apiBaseUrl || store.get('p2p_api_base', '') || 'https://pich2pich.duckdns.org').replace(/\/$/, '');
    const url = base + '/download/Pich2Pich.apk';
    if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
      alert('APK 僅供 Android。iPhone 請用上方「Safari 加入主畫面」。');
      return;
    }
    window.location.href = url;
  };
  const isIos = /iPhone|iPad|iPod/i.test(navigator.userAgent);
  const isAndroid = /Android/i.test(navigator.userAgent);
  if (isIos && iosBtn) iosBtn.classList.add('primary-install');
  if (isAndroid && androidPwa && !isPwaInstalled()) androidPwa.textContent = '📲 Android 安裝到主畫面';
  if (!isAndroid && apkBtn) apkBtn.classList.add('muted-btn');
  refreshInstallButtons();
}

async function promptPwaInstall(fromRetry) {
  if (isPwaInstalled()) {
    alert('已安裝到主畫面。請從主畫面圖示開啟 Pich2Pich。');
    return;
  }
  if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
    $('iosInstallModal').classList.remove('hidden');
    return;
  }
  if (deferredInstall) {
    try {
      deferredInstall.prompt();
      const choice = await deferredInstall.userChoice;
      if (choice.outcome === 'accepted') {
        deferredInstall = null;
        refreshInstallButtons();
        $('androidInstallModal')?.classList.add('hidden');
        return;
      }
    } catch (e) {
      showAndroidInstallModal('安裝視窗被阻擋：' + (e.message || e));
      return;
    }
  }
  if (/Android/i.test(navigator.userAgent)) {
    if (!isAndroidChrome()) {
      showAndroidInstallModal('目前不是 Chrome 瀏覽器，請複製 https://pich2pich.web.app 到 Chrome 開啟後再安裝。');
    } else {
      showAndroidInstallModal(fromRetry ? '仍未跳出安裝視窗，請依步驟從 ⋮ 選單手動「加入主畫面」。' : null);
    }
    return;
  }
  alert('請用 Chrome 或 Safari 開啟，並從瀏覽器選單「加入主畫面」。');
}

/* ===================== boot ===================== */
window.addEventListener('load', () => {
  if (navigator.onLine && location.protocol === 'https:') {
    const saved = store.get('p2p_mapsrc', '');
    if (!saved || saved === 'online') {
      state.mapSrc = saved || 'online';
      store.set('p2p_mapsrc', state.mapSrc);
    }
  }
  migrateChatStorage();
  bindUI();
  bindMapSplit();
  applyMapSplit();
  showPage('walkie');
  setTimeout(requestGeoOnce, 1500);
  showConnectBanner();
  startGeolocation();
  addSessionSeparators();
  renderChatHome(); renderPeerList(); renderRtcPeers(); updateRtcStatus(); updateDistances(); updateGuardStatus();
  renderRecentPeers();
  loadLastKnownPeers(); redrawRally(); renderRallyList(); startCheckinTimer();
  if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
  if (window.initExtendedFeatures) initExtendedFeatures();
  if (window.initTeamFeatures) initTeamFeatures();
  if (window.initExtras) initExtras();
  applyApkBridgeFromUrl();
  applyRelayUrlOnBoot();
  refreshMyCodeUI();
  const afterNetReady = () => {
    const auto = new URLSearchParams(location.search).get('autoDial') === '1';
    if (auto) connectRelay(true);
    else if (typeof shouldConnectRelay === 'function' ? shouldConnectRelay() : navigator.onLine) connectRelay();
    else updateRelayStatusLine();
  };
  if (window.initOfflineMode) initOfflineMode().then(() => { afterNetReady(); tryAutoDialFromUrl(); });
  else { afterNetReady(); tryAutoDialFromUrl(); }
  setInterval(updateRelayStatusLine, 5000);
  setInterval(prunePeerTracks, 60000);
  window.addEventListener('pageshow', () => {
    applyRelayUrlOnBoot();
    if ((typeof shouldConnectRelay === 'function' ? shouldConnectRelay() : navigator.onLine) && (!ws || ws.readyState !== WebSocket.OPEN)) connectRelay(false);
  });
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible' && (typeof shouldConnectRelay === 'function' ? shouldConnectRelay() : navigator.onLine) && (!ws || ws.readyState !== WebSocket.OPEN)) connectRelay(false);
  });
  updateWakeLock();
  registerSW();
  setupPwaInstall();
  window.applyGeoPosition = applyGeoPosition;
  setInterval(renderPeerList, 15000);
});
