'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),
  lockedPeer: null,
  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',
  mapSrc: store.get('p2p_mapsrc', 'online'),
  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),
  lastKnown: store.getJSON('p2p_last_known', {}),
  rallyPoints: store.getJSON('p2p_rally', []),
  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',
  seen: new Set(),
  editingRoute: false
};

/* ===================== MAP ===================== */
const STALE_MS = 30000;
let map, selfMarker = null;
const peerMarkers = {};
const rallyMarkers = [];
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': '#0d2519' } },
      { id: 'off', type: 'raster', source: 'off' }
    ]
  };
}

function blankStyle() {
  // zero-network style: solid background; markers/route still plot by coordinates
  return { version: 8, sources: {}, glyphs: undefined, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#0d2519' } }] };
}
function currentStyle() {
  if (state.mapSrc === 'blank') return blankStyle();
  if (state.mapSrc === 'offline' && state.pmtilesPath) return offlineStyle(state.pmtilesPath);
  return MAP_ONLINE_STYLE;
}

function initMap() {
  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: 15 });
  map.on('error', (e) => { if (state.mapSrc === 'offline') showMapHint('離線圖磚載入失敗，請確認 PMTiles 路徑或改用線上地圖。'); });
  map.addControl(new maplibregl.NavigationControl(), 'top-left');
  map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }), 'top-left');
  map.on('style.load', ensureRouteLayer);
  if (state.mapSrc === 'offline') showMapHint('離線地圖模式：' + state.pmtilesPath);
  // auto-fallback: if the online style can't load (no network), drop to blank so positions still work
  setTimeout(() => {
    if (map && state.mapSrc === 'online' && !map.isStyleLoaded()) {
      showMapHint('偵測不到網路，已切換「無底圖」模式（純定位仍可用）。');
      map.setStyle(blankStyle());
    }
  }, 6000);
}

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 } });
  }
  map.off('click', onMapClick); map.on('click', onMapClick);
  redrawRoute(); redrawRally();
}
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 showQR(title, text) {
  if (!window.qrcode) return alert('QR 程式庫未載入');
  if (!text) return alert('沒有內容可產生 QR，請先建立/產生代碼');
  try {
    const qr = qrcode(0, 'L'); qr.addData(text); qr.make();
    $('qrImg').innerHTML = qr.createImgTag(4, 8);
    $('qrTitle').textContent = title; $('qrModal').classList.remove('hidden');
  } catch (e) { alert('資料太長無法產生 QR，請改用複製貼上。'); }
}
let scanStream = null, scanRAF = 0;
async function openScanner(targetId, after) {
  if (!('BarcodeDetector' in window)) return alert('此瀏覽器不支援掃描，請用相機 App 掃 QR 後貼上。');
  try {
    const det = new BarcodeDetector({ formats: ['qr_code'] });
    scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
    const v = $('scanVideo'); v.srcObject = scanStream; $('scanModal').classList.remove('hidden');
    const tick = async () => {
      if (!scanStream) return;
      try { const codes = await det.detect(v); if (codes.length) { $(targetId).value = codes[0].rawValue; closeScanner(); if (after) after(); return; } } catch {}
      scanRAF = requestAnimationFrame(tick);
    };
    scanRAF = requestAnimationFrame(tick);
  } catch (e) { alert('開啟相機失敗：' + e.message); closeScanner(); }
}
function closeScanner() { if (scanRAF) cancelAnimationFrame(scanRAF); scanRAF = 0; if (scanStream) { scanStream.getTracks().forEach(t => t.stop()); scanStream = null; } $('scanModal').classList.add('hidden'); }

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); }
}

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) {
  const ll = [lon, lat];
  if (!selfMarker) { selfMarker = new maplibregl.Marker({ element: markerEl('#25d366', '我') }).setLngLat(ll).addTo(map); map.easeTo({ center: ll }); }
  else selfMarker.setLngLat(ll);
}
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 label = p.name + (p.floor != null ? ` ${p.floor}F` : '') + tag;
  const color = stale ? '#888' : '#ef6c00';
  const txt = stale ? '?' : '人';
  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>${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>${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 iceServers() {
  // STUN only matters online; offline falls back to host/mDNS candidates.
  // TURN (optional) is required for cross-network NAT traversal.
  const list = [{ urls: 'stun:stun.l.google.com:19302' }];
  if (state.turnUrl) list.push({ urls: state.turnUrl, username: state.turnUser, credential: state.turnCred });
  return list;
}

function newPc() {
  const pc = new RTCPeerConnection({ iceServers: iceServers() });
  const conn = { id: uuid(), pc, dc: null, audioTx: null, sid: null, name: '夥伴', audioEl: null };
  pc.onconnectionstatechange = () => { if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) removeConn(conn); renderRtcPeers(); updateRtcStatus(); };
  pc.ontrack = (e) => {
    if (!conn.audioEl) { conn.audioEl = document.createElement('audio'); conn.audioEl.autoplay = true; document.body.appendChild(conn.audioEl); }
    conn.audioEl.srcObject = 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();
  };
  dc.onclose = () => { renderRtcPeers(); updateRtcStatus(); };
  dc.onmessage = (e) => handleWire(e.data, conn.id);
}
function removeConn(conn) {
  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();
}
function iceComplete(pc) {
  return new Promise(res => {
    if (pc.iceGatheringState === 'complete') return res();
    const t = setTimeout(res, 2500);
    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())); }
async function encodeSignal(d) { const json = JSON.stringify({ t: d.type, s: d.sdp }); const c = await deflate(json); return c ? 'C' + c : 'R' + 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(body); else if (f === 'R') json = decodeURIComponent(escape(atob(body))); else json = decodeURIComponent(escape(atob(code))); const o = JSON.parse(json); return { type: o.t, sdp: o.s }; }

async function buildOffer() {
  const conn = newPc();
  conn.audioTx = conn.pc.addTransceiver('audio', { 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) {
  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 && t.receiver.track.kind === 'audio') || (t.sender.track && t.sender.track.kind === 'audio')) || conn.pc.getTransceivers()[0];
  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 dial(peerCode) {
  peerCode = (peerCode || '').trim().toUpperCase();
  if (!peerCode) return;
  if (!(ws && ws.readyState === WebSocket.OPEN)) { alert('請先在設定連線中繼（可用區網本機伺服器）'); return; }
  const conn = await buildOffer(); rtcDialing[peerCode] = conn;
  signalSend({ sig: 'offer', to: peerCode, data: await encodeSignal(conn.pc.localDescription) });
  showMapHint('已對 ' + peerCode + ' 發出自動連線邀請…');
}
async function onSignal(msg) {
  if (msg.to !== myCode()) return;
  if (msg.sig === 'offer') { const conn = await buildAnswer(msg.data); signalSend({ sig: 'answer', to: msg.from, data: await encodeSignal(conn.pc.localDescription) }); }
  else if (msg.sig === 'answer') { const conn = rtcDialing[msg.from]; if (conn) { await conn.pc.setRemoteDescription(await decodeSignal(msg.data)); delete rtcDialing[msg.from]; } }
}

function sendOn(conn, msg) {
  msg.id = msg.id || uuid(); msg.ttl = msg.ttl ?? 6; 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 */
async function ensureMic() {
  if (rtc.micTrack) return;
  rtc.micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
  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 box = $('rtcPeers');
  const open = rtc.conns.filter(c => c.dc && c.dc.readyState === 'open');
  if (open.length === 0) { box.innerHTML = '<div class="muted">尚未建立任何 WebRTC 連線</div>'; 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);
  });
}

/* ===================== Relay (optional, online) ===================== */
let ws = null;
const onlinePeers = {}; // code -> name (from relay presence)
function connectRelay() {
  if (!state.relayUrl) { alert('請先在設定填入中繼伺服器 URL'); return; }
  disconnectRelay();
  const url = state.relayUrl + (state.relayUrl.includes('?') ? '&' : '?') + 'room=' + encodeURIComponent(state.relayRoom);
  ws = new WebSocket(url);
  ws.onopen = () => { setRelayStatus(true); relayRegister(); };
  ws.onclose = () => { setRelayStatus(false); Object.keys(onlinePeers).forEach(k => delete onlinePeers[k]); renderOnline(); };
  ws.onerror = () => setRelayStatus(false);
  ws.onmessage = (ev) => handleWire(ev.data, 'relay');
}
function disconnectRelay() { if (ws) { try { ws.close(); } catch {} ws = null; } setRelayStatus(false); Object.keys(onlinePeers).forEach(k => delete onlinePeers[k]); renderOnline(); }
function setRelayStatus(on) { $('relayDot').className = 'dot ' + (on ? 'on' : 'off'); $('relayLabel').textContent = on ? '中繼:' + state.relayRoom : '未連中繼'; }
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': showMapHint('代碼 ' + msg.to + ' 不在線上'); break;
    case 'PRESENCE': (msg.peers || []).forEach(p => { onlinePeers[p.code] = p.name; }); renderOnline(); break;
    case 'PRESENCE_JOIN': onlinePeers[msg.code] = msg.name; renderOnline(); break;
    case 'PRESENCE_LEAVE': delete onlinePeers[msg.code]; renderOnline(); break;
  }
}
function renderOnline() {
  const box = $('onlinePeers'); if (!box) return;
  const codes = Object.keys(onlinePeers).filter(c => c && c !== myCode());
  if (!codes.length) { box.innerHTML = '<div class="muted">連上中繼後，這裡會列出線上夥伴，點一下即自動連線。</div>'; 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);
  });
}

/* ===================== Unified mesh send / receive ===================== */
function originate(msg) {
  msg.id = msg.id || uuid(); msg.ttl = msg.ttl ?? 6; msg.sid = state.id; msg.name = state.name; msg.ts = msg.ts || Date.now();
  state.seen.add(msg.id);
  forward(msg, null, false);
  return msg;
}
function forward(msg, exceptConnId, fromRelay) {
  const raw = JSON.stringify(msg);
  rtc.conns.forEach(c => { if (c.id !== exceptConnId && c.dc && c.dc.readyState === 'open') c.dc.send(raw); });
  if (!fromRelay && ws && ws.readyState === WebSocket.OPEN) ws.send(raw);
}
function handleWire(raw, fromConnId) {
  let msg; try { msg = JSON.parse(raw); } catch { return; }
  if (msg.kind) { handleControl(msg); return; }   // relay control: SIG / PRESENCE / ...
  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');
  dispatch(msg, fromConnId);
}
function dispatch(msg, fromConnId) {
  if (msg.sid) noteContact(msg.sid, msg.name, fromConnId);
  switch (msg.type) {
    case 'HELLO': break;
    case 'TELEMETRY': onTelemetry(msg); break;
    case 'TEXT': case 'IMAGE': case 'VOICE_CLIP': onChatIncoming(msg); break;
    case 'RALLY': onRally(msg); break;
    case 'CALL_REQUEST': showCallBanner(msg.name); notify('📞 通話請求', `${msg.name} 要求通話`); break;
    case 'CALL_CANCEL': hideCallBanner(); break;
  }
}
function noteContact(sid, name, fromConnId) {
  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);
  const conn = rtc.conns.find(c => c.id === fromConnId);
  if (conn) { conn.sid = sid; conn.name = name || conn.name; }
  renderConvChips(); renderRtcPeers();
}

/* ===================== Geolocation + telemetry + peers ===================== */
function startGeolocation() {
  if (!navigator.geolocation) return;
  navigator.geolocation.watchPosition(
    (pos) => { state.self = { lat: pos.coords.latitude, lon: pos.coords.longitude }; setSelfOnMap(state.self.lat, state.self.lon); updateDistances(); guardianEvaluate(); },
    (err) => console.warn('geo', err.message),
    { enableHighAccuracy: true, maximumAge: 1000, timeout: 15000 });
}
setInterval(() => { if (state.self) originate({ type: 'TELEMETRY', lat: state.self.lat, lon: state.self.lon, floor: state.manualFloor }); }, 2000);

function onTelemetry(msg) {
  const p = { name: msg.name, lat: msg.lat, lon: msg.lon, floor: msg.floor, ts: msg.ts || Date.now() };
  state.peers[msg.sid] = p;
  state.lastKnown[msg.sid] = { ...p, sid: msg.sid };
  store.setJSON('p2p_last_known', state.lastKnown);
  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';
  const mf = $('mapSelfFloor'); if (mf) mf.textContent = state.manualFloor + 'F';
  const mpc = $('mapPeerCount'); if (mpc) mpc.textContent = Object.keys(state.peers).length + ' 人';
  const ids = Object.keys(state.peers);
  if (!ids.length) { $('peerName').textContent = '尚無'; $('peerFloor').textContent = '—'; $('peerDist').textContent = '—'; return; }
  const sid = 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' : '—';
  $('peerDist').textContent = (state.self && p.lat != null) ? Math.round(distanceM(state.self.lat, state.self.lon, p.lat, p.lon)) + ' 公尺' : '—';
  updateBearingDisplay(sid, p);
}
function renderPeerList() {
  const ids = Object.keys(state.peers), box = $('peerList');
  const mpc = $('mapPeerCount'); if (mpc) mpc.textContent = ids.length + ' 人';
  if (!ids.length) { box.innerHTML = '<div class="muted">尚無連線；到「聊天與連線」加入夥伴</div>'; return; }
  box.innerHTML = '';
  ids.forEach(sid => {
    const p = state.peers[sid], locked = state.lockedPeer === sid;
    const stale = Date.now() - (p.ts || 0) > STALE_MS;
    const item = document.createElement('div'); item.className = 'peer-item' + (stale ? ' peer-stale' : '');
    item.innerHTML = `<span>${escapeHtml(p.name)}${p.floor != null ? ' · ' + p.floor + 'F' : ''}${stale ? ' <span class="peer-stale-tag">最後已知</span>' : ''}</span>`;
    const btn = document.createElement('button'); btn.className = 'btn ghost small'; btn.textContent = locked ? '已鎖定 ✓' : '鎖定';
    btn.onclick = () => { state.lockedPeer = locked ? null : sid; renderPeerList(); updateDistances(); };
    item.appendChild(btn); box.appendChild(item);
  });
}
function checkProximityAlarm(sid) {
  if (state.lockedPeer !== sid || !state.self) return;
  const p = state.peers[sid]; if (p.lat == null) return;
  const d = distanceM(state.self.lat, state.self.lon, p.lat, p.lon);
  if (d > state.alarmThreshold) {
    const now = Date.now();
    if (now - (checkProximityAlarm._last || 0) > 30000) { checkProximityAlarm._last = 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 (key === 'group') return '群組';
  const c = state.contacts[key]; return c ? `${c.name}(${c.code})` : codeOf(key);
}
function renderConvChips() {
  const box = $('convChips'); box.innerHTML = '';
  const keys = ['group', ...Object.keys(state.contacts)];
  keys.forEach(k => {
    const chip = document.createElement('div');
    chip.className = 'conv-chip' + (state.activeConv === k ? ' active' : '');
    chip.textContent = convTitle(k);
    const u = state.unread[k] || 0;
    if (u > 0 && state.activeConv !== k) { const b = document.createElement('span'); b.className = 'unread'; b.textContent = u; chip.appendChild(b); }
    chip.onclick = () => { state.activeConv = k; state.unread[k] = 0; persistUnread(); renderConvChips(); renderChat(); };
    box.appendChild(chip);
  });
}
function renderChat() {
  const list = $('chatList'); list.innerHTML = '';
  convArr(state.activeConv).forEach(e => 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 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}" />`;
  else if (e.type === 'VOICE_CLIP') inner += `<div class="voice">▶️ 語音訊息</div>`;
  inner += `<div class="meta">${new Date(e.ts).toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' })}</div>`;
  b.innerHTML = inner;
  if (e.type === 'VOICE_CLIP') b.querySelector('.voice').onclick = () => playVoice(e.mime || 'audio/webm', e.blob);
  return b;
}
function pushEntry(key, entry) {
  convArr(key).push(entry); persistConvs();
  if (state.activeConv === key) renderChat();
}
function onChatIncoming(msg) {
  if (msg.to && msg.to !== state.id) return; // direct message not for me (already relayed)
  const key = msg.to === state.id ? msg.sid : 'group';
  pushEntry(key, { dir: 'them', type: msg.type, text: msg.text, mime: msg.mime, blob: msg.blob, name: msg.name, ts: msg.ts });
  if (state.activeConv !== key) { state.unread[key] = (state.unread[key] || 0) + 1; persistUnread(); renderConvChips(); }
  notify(msg.name, msg.type === 'TEXT' ? msg.text : (msg.type === 'IMAGE' ? '[照片]' : '[語音]'));
}
function sendChat(partial) {
  const to = state.activeConv === 'group' ? null : state.activeConv;
  const msg = originate({ ...partial, to });
  pushEntry(state.activeConv, { dir: 'me', type: msg.type, text: msg.text, mime: msg.mime, blob: msg.blob, name: state.name, ts: msg.ts });
}
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 = [];
async function startClip() {
  try { const s = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(s); recChunks = []; mediaRecorder.ondataavailable = e => { if (e.data.size) recChunks.push(e.data); }; mediaRecorder.start(); }
  catch (e) { alert('無法取得麥克風：' + e.message); }
}
function stopClipAndSend() {
  if (!mediaRecorder) return;
  mediaRecorder.onstop = async () => {
    const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
    mediaRecorder.stream.getTracks().forEach(t => t.stop());
    sendChat({ type: 'VOICE_CLIP', mime: blob.type, blob: await blobToBase64(blob) });
    mediaRecorder = null;
  };
  mediaRecorder.stop();
}
function playVoice(mime, b64) { new Audio(`data:${mime};base64,${b64}`).play().catch(() => {}); }

/* ===================== 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);
  originate({ type: 'TEXT', to: null, text: '🆘 ' + body });
  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 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) updateBearingDisplay(state.lockedPeer && state.peers[state.lockedPeer] ? state.lockedPeer : ids[0], 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();
}

function broadcastSos(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}`;
  originate({ type: 'TEXT', to: null, text });
  pushEntry('group', { dir: 'me', type: 'TEXT', text, name: state.name, ts: Date.now() });
  if (includeVoice && state.sosVoiceBlob) {
    originate({ type: 'VOICE_CLIP', to: null, mime: state.sosVoiceMime, blob: state.sosVoiceBlob });
    pushEntry('group', { dir: 'me', type: 'VOICE_CLIP', mime: state.sosVoiceMime, blob: state.sosVoiceBlob, name: state.name, ts: Date.now() });
  }
  notify('🆘 SOS', '已廣播求救訊息');
  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');
}

function sendCheckin() {
  const loc = state.self ? `(${state.self.lat.toFixed(5)},${state.self.lon.toFixed(5)})` : '';
  const text = `✅ ${state.name} 報平安：我還好 ${loc}`;
  originate({ type: 'TEXT', to: null, text });
  pushEntry('group', { dir: 'me', type: 'TEXT', text, name: state.name, ts: Date.now() });
}
function startCheckinTimer() {
  clearInterval(startCheckinTimer._id);
  if (!state.checkinEnabled) return;
  const ms = state.checkinMinutes * 60000;
  startCheckinTimer._id = setInterval(sendCheckin, ms);
}

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);
  originate({ type: 'RALLY', lat, lon, text: label });
  redrawRally(); renderRallyList();
}
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 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 el = markerEl('#1565C0', '★', false);
    el.style.borderRadius = '4px';
    const m = new maplibregl.Marker({ element: el }).setLngLat([r.lon, r.lat])
      .setPopup(new maplibregl.Popup().setHTML(`<b>集合點</b><br>${escapeHtml(r.name || '')}<br>${escapeHtml(r.label || '')}`)).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 => {
    const d = document.createElement('div'); d.className = 'peer-item';
    d.innerHTML = `<span>★ ${escapeHtml(r.name)}：${escapeHtml(r.label)}<br><span class="muted small">${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}</span></span>`;
    const b = document.createElement('button'); b.className = 'btn ghost small'; b.textContent = '看地圖';
    b.onclick = () => { showPage('mapsafe'); map.easeTo({ center: [r.lon, r.lat], zoom: 16 }); };
    d.appendChild(b); 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';
    d.innerHTML = `<span>${escapeHtml(p.name)}${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(() => {
  Object.keys(state.peers).forEach(sid => setPeerOnMap(sid, state.peers[sid]));
  renderPeerList(); renderLastKnown();
}, 15000);

/* banners / modals */
function showCallBanner(n) { $('callText').textContent = `📞 ${n} 要求通話`; $('callBanner').classList.remove('hidden'); }
function hideCallBanner() { $('callBanner').classList.add('hidden'); }
function showOffCourseModal(dev, el) { $('offCourseText').textContent = `你已偏離路線約 ${Math.round(dev)} 公尺、持續約 ${Math.floor(el / 60000)} 分鐘。若無回應，30 分鐘後將自動通知緊急聯絡人。`; $('offCourseModal').classList.remove('hidden'); }
function hideOffCourseModal() { $('offCourseModal').classList.add('hidden'); }

/* ===================== UI binding ===================== */
const PAGE_TITLES = { walkie: '對講機', mapsafe: '地圖與安全', chatconn: '聊天與連線', rescueset: '救援與設定' };
const MAP_PAGES = new Set(['mapsafe']);
const FULL_PAGES = new Set(['walkie', 'chatconn', 'rescueset']);

function showPage(name) {
  document.querySelectorAll('.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');
  if (MAP_PAGES.has(name)) {
    mapArea.classList.remove('hidden');
    pages.classList.remove('pages-full');
  } else {
    mapArea.classList.add('hidden');
    pages.classList.add('pages-full');
  }
  $('sidebar').classList.remove('open');
  setTimeout(() => { if (map && MAP_PAGES.has(name)) map.resize(); }, 80);
}

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

  $('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)); };

  // PTT realtime voice
  const ptt = $('pttBtn');
  const pttDown = async (e) => { e.preventDefault(); try { await ensureMic(); rtc.micTrack.enabled = true; ptt.classList.add('active'); ptt.textContent = '說話中…放開停止'; } catch (err) { alert('麥克風失敗：' + err.message); } };
  const pttUp = (e) => { e.preventDefault(); if (rtc.micTrack) rtc.micTrack.enabled = false; ptt.classList.remove('active'); ptt.textContent = '按住說話（即時語音）'; };
  ptt.addEventListener('mousedown', pttDown); ptt.addEventListener('touchstart', pttDown, { passive: false });
  ptt.addEventListener('mouseup', pttUp); ptt.addEventListener('touchend', pttUp);

  $('callBtn').onclick = () => { originate({ type: 'CALL_REQUEST' }); alert('已送出通話請求'); };
  $('callDismiss').onclick = hideCallBanner;

  // chat
  $('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)
  $('myCode').textContent = codeOf(state.id);
  $('copyCodeBtn').onclick = () => { copy(codeOf(state.id)); };
  $('makeOfferBtn').onclick = async () => { $('offerOut').value = '產生中…'; try { $('offerOut').value = await makeOffer(); } catch (e) { $('offerOut').value = '失敗：' + e.message; } };
  $('copyOfferBtn').onclick = () => copy($('offerOut').value);
  $('acceptAnswerBtn').onclick = async () => { try { await acceptAnswer($('answerIn').value); alert('連線完成！'); } catch (e) { alert('失敗：' + e.message); } };
  $('makeAnswerBtn').onclick = async () => { $('answerOut').value = '產生中…'; try { $('answerOut').value = await makeAnswer($('offerIn').value); } catch (e) { $('answerOut').value = '失敗：' + e.message; } };
  $('copyAnswerBtn').onclick = () => copy($('answerOut').value);
  $('dialBtn').onclick = () => dial($('dialCode').value);
  $('qrOfferBtn').onclick = () => showQR('掃我加入（邀請碼）', $('offerOut').value);
  $('qrAnswerBtn').onclick = () => showQR('回應碼（給邀請方掃）', $('answerOut').value);
  $('scanOfferBtn').onclick = () => openScanner('offerIn', () => $('makeAnswerBtn').click());
  $('scanAnswerBtn').onclick = () => openScanner('answerIn', () => $('acceptAnswerBtn').click());
  $('qrClose').onclick = () => $('qrModal').classList.add('hidden');
  $('scanClose').onclick = closeScanner;

  // 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); };
  $('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; $('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('確定清除所有聊天記錄？')) { state.convs = { group: [] }; state.unread = {}; persistConvs(); persistUnread(); renderConvChips(); renderChat(); } };

  // rescue page
  $('sosText').value = state.sosText;
  $('sosText').oninput = e => { state.sosText = e.target.value; store.set('p2p_sos_text', state.sosText); };
  $('sosBroadcastBtn').onclick = () => broadcastSos(false);
  $('sosVoiceBroadcastBtn').onclick = () => broadcastSos(true);
  $('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('mapsafe'); showMapHint('請在地圖點選集合點位置'); };
  $('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 {
    if (state.guardEnabled && 'wakeLock' in navigator && document.visibilityState === 'visible') {
      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 ===================== */
function registerSW() { if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {}); }

/* ===================== boot ===================== */
window.addEventListener('load', () => {
  bindUI();
  initMap();
  showPage('walkie');
  startGeolocation();
  addSessionSeparators();
  renderConvChips(); renderChat(); renderPeerList(); renderRtcPeers(); updateRtcStatus(); updateDistances(); updateGuardStatus();
  loadLastKnownPeers(); redrawRally(); renderRallyList(); startCheckinTimer();
  if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
  if (state.relayUrl) connectRelay();
  updateWakeLock();
  registerSW();
});
