'use strict';

const RALLY_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';

function rallyLetter(i) {
  return RALLY_LETTERS[i % RALLY_LETTERS.length];
}

const DEFAULT_ORACLE_APP = 'https://pich2pich.duckdns.org';
const DEFAULT_ORACLE_RELAY = 'wss://pich2pich.duckdns.org';
const DEFAULT_FIREBASE_RELAY = 'wss://pich2pich.duckdns.org';
const DEFAULT_API_BASE = 'https://pich2pich.duckdns.org';

function isFirebaseHost() {
  return /\.(web\.app|firebaseapp\.com)$/.test(location.hostname);
}

function getApiBase() {
  if (state.apiBaseUrl) return state.apiBaseUrl.replace(/\/$/, '');
  if (isFirebaseHost()) return DEFAULT_API_BASE;
  return '';
}

function apiUrl(path) {
  const p = path.startsWith('/') ? path : '/' + path;
  const base = getApiBase();
  return base ? base + p : p;
}

function apiAbsoluteUrl(pathOrUrl) {
  if (!pathOrUrl) return pathOrUrl;
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
  return apiUrl(pathOrUrl);
}

function apiFetch(path, opts) {
  return fetch(apiUrl(path), opts);
}

window.getApiBase = getApiBase;
window.apiUrl = apiUrl;
window.apiAbsoluteUrl = apiAbsoluteUrl;
window.apiFetch = apiFetch;

function applyConfigObject(cfg) {
  if (!cfg) return;
  if (cfg.relayUrl && !isFirebaseHost()) {
    if (!state.fixedRelayUrl || state.fixedRelayUrl.includes('161.33.8.27')) {
      state.fixedRelayUrl = cfg.relayUrl;
      store.set('p2p_fixed_relay', cfg.relayUrl);
    }
    if (!state.relayUrl) state.relayUrl = cfg.relayUrl;
  }
  if (cfg.appUrl && !state.fixedAppUrl && !isFirebaseHost()) {
    state.fixedAppUrl = cfg.appUrl;
    store.set('p2p_fixed_app_url', cfg.appUrl);
  }
  if (cfg.firebaseAppUrl) state.firebaseAppUrl = cfg.firebaseAppUrl;
  if (cfg.apiBaseUrl) {
    state.apiBaseUrl = cfg.apiBaseUrl;
    store.set('p2p_api_base', cfg.apiBaseUrl);
  }
  if (isFirebaseHost()) {
    const relay = cfg.firebaseRelayUrl || cfg.relayUrl || DEFAULT_FIREBASE_RELAY;
    state.fixedRelayUrl = relay;
    store.set('p2p_fixed_relay', relay);
    state.relayUrl = relay;
    store.set('p2p_relay_url', relay);
    store.set('p2p_oracle_default', '0');
    state.fixedAppUrl = cfg.firebaseAppUrl || location.origin;
    store.set('p2p_fixed_app_url', state.fixedAppUrl);
    state.apiBaseUrl = cfg.apiBaseUrl || DEFAULT_API_BASE;
    store.set('p2p_api_base', state.apiBaseUrl);
  }
  if (cfg.relayRoom) state.relayRoom = cfg.relayRoom;
  if (cfg.turnUrl) {
    state.turnUrl = cfg.turnUrl;
    store.set('p2p_turn_url', cfg.turnUrl);
    const tu = $('turnUrl');
    if (tu) tu.value = cfg.turnUrl;
  }
  if (cfg.turnUser) { state.turnUser = cfg.turnUser; store.set('p2p_turn_user', cfg.turnUser); }
  if (cfg.turnCred) { state.turnCred = cfg.turnCred; store.set('p2p_turn_cred', cfg.turnCred); }
  if (cfg.suggestTeamKey && !store.get('p2p_e2ee_key')) {
    state.e2eeKey = cfg.suggestTeamKey;
    store.set('p2p_e2ee_key', cfg.suggestTeamKey);
  }
  if (cfg.e2eeDefault && store.get('p2p_e2ee', '') === '') {
    state.e2eeEnabled = true;
    store.set('p2p_e2ee', '1');
  }
  applyFixedRelayOnBoot();
  const fu = $('fixedAppUrl');
  const fr = $('fixedRelayUrl');
  if (fu && state.fixedAppUrl) fu.value = state.fixedAppUrl;
  if (fr && state.fixedRelayUrl) fr.value = state.fixedRelayUrl;
}

function loadExtrasState() {
  state.fixedAppUrl = store.get('p2p_fixed_app_url', '');
  state.fixedRelayUrl = store.get('p2p_fixed_relay', '');
  state.selfTracks = store.getJSON('p2p_self_track', []);
  state.sosQueue = store.getJSON('p2p_sos_queue', []);
  state.sosLog = store.getJSON('p2p_sos_log', []);
  state.geofence = store.getJSON('p2p_geofence', { enabled: false, lat: null, lon: null, radiusM: 500 });
  if (isFirebaseHost()) {
    state.fixedAppUrl = location.origin;
    store.set('p2p_fixed_app_url', state.fixedAppUrl);
    state.apiBaseUrl = store.get('p2p_api_base', DEFAULT_API_BASE);
    if (!state.fixedRelayUrl || state.fixedRelayUrl.includes('161.33.8.27')) {
      state.fixedRelayUrl = DEFAULT_FIREBASE_RELAY;
      state.relayUrl = DEFAULT_FIREBASE_RELAY;
      store.set('p2p_fixed_relay', DEFAULT_FIREBASE_RELAY);
      store.set('p2p_relay_url', DEFAULT_FIREBASE_RELAY);
    }
    store.set('p2p_oracle_default', '0');
    return;
  }
  if (!state.fixedRelayUrl && store.get('p2p_oracle_default', '1') !== '0') {
    state.fixedRelayUrl = DEFAULT_ORACLE_RELAY;
    state.fixedAppUrl = state.fixedAppUrl || DEFAULT_ORACLE_APP;
    store.set('p2p_fixed_relay', state.fixedRelayUrl);
    store.set('p2p_fixed_app_url', state.fixedAppUrl);
  }
}

function applyFixedRelayOnBoot() {
  if (typeof sameOriginRelayUrl === 'function' && sameOriginRelayUrl()) return;
  const fixed = (state.fixedRelayUrl || '').trim();
  if (!fixed) return;
  state.relayUrl = fixed;
  store.set('p2p_relay_url', fixed);
  const inp = $('relayUrl');
  if (inp) inp.value = fixed;
}

function preloadMapTiles() {
  /* 改為進入地圖頁再初始化，避免對講頁背景載入 MapLibre 造成卡頓 */
}

function openPasteCodeModal(mode) {
  const m = $('pasteCodeModal');
  const ta = $('pasteCodeInput');
  const title = $('pasteCodeTitle');
  if (!m || !ta) return;
  pasteCodeMode = mode || 'auto';
  if (title) title.textContent = mode === 'offer' ? '貼上邀請碼' : (mode === 'answer' ? '貼上回應碼' : '貼上配對代碼');
  ta.value = '';
  m.classList.remove('hidden');
  ta.focus();
}

let pasteCodeMode = 'auto';

async function submitPasteCode() {
  const raw = ($('pasteCodeInput').value || '').trim();
  if (!raw) return alert('請貼上代碼');
  $('pasteCodeModal').classList.add('hidden');
  const off = typeof isOfflineMode === 'function' && isOfflineMode();
  setDialStatus(off ? '區網配對中…（不需外網）' : '連線中…', 'busy');
  try {
    await connectFromScannedCode(raw);
  } catch (e) {
    setDialStatus('連線失敗：' + e.message, 'err');
    alert('連線失敗：' + e.message);
  }
}

function scanOrPaste(mode) {
  if (typeof startScanOnUserGesture === 'function') return startScanOnUserGesture();
  if (typeof window.openScannerAuto === 'function') return window.openScannerAuto();
  openPasteCodeModal(mode || 'auto');
}

function updateVoiceQualityLights() {
  const relay = $('vqRelay');
  const rtc = $('vqRtc');
  const ptt = $('vqPtt');
  if (!relay) return;
  const relayOk = ws && ws.readyState === WebSocket.OPEN;
  const rtcOk = typeof rtcAudioReady === 'function' && rtcAudioReady();
  const pttMode = window.pttVoiceMode || '';
  const ms = state.health && state.health.relayMs;
  relay.className = 'vq-dot ' + (relayOk ? (ms != null && ms < 200 ? 'ok' : 'warn') : 'off');
  rtc.className = 'vq-dot ' + (rtcOk ? 'ok' : 'off');
  ptt.className = 'vq-dot ' + (pttMode ? 'ok' : 'off');
  const tip = $('vqTip');
  if (tip) {
    tip.textContent = relayOk
      ? `中繼${ms != null ? ' ' + ms + 'ms' : ''} · ${rtcOk ? 'P2P' : '—'} · PTT ${pttMode || '—'}`
      : (rtcOk ? '僅 P2P' : '未連線');
  }
}

function recordSelfTrack(lat, lon, ts) {
  if (lat == null || lon == null) return;
  const t = ts || Date.now();
  const arr = state.selfTracks || [];
  const last = arr[arr.length - 1];
  if (last && typeof distanceM === 'function' && distanceM(last.lat, last.lon, lat, lon) < 3) return;
  arr.push({ lat, lon, ts: t });
  const cut = Date.now() - (typeof PEER_TRACK_MS !== 'undefined' ? PEER_TRACK_MS : 30 * 60000);
  state.selfTracks = arr.filter(p => p.ts >= cut);
  store.setJSON('p2p_self_track', state.selfTracks);
}

function buildActivityGpxXml() {
  const lines = [];
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
  lines.push('<gpx version="1.1" creator="Pich2Pich" xmlns="http://www.topografix.com/GPX/1/1">');
  lines.push(`<metadata><name>Pich2Pich ${new Date().toISOString()}</name></metadata>`);
  (state.rallyPoints || []).forEach((r, i) => {
    const code = rallyLetter(i);
    lines.push(`<wpt lat="${r.lat}" lon="${r.lon}"><name>${esc(code)} ${esc(r.label || r.name || '集合')}</name><desc>${esc(r.name || '')}</desc></wpt>`);
  });
  const trackParts = [];
  if (state.selfTracks && state.selfTracks.length >= 2) trackParts.push({ name: '我的軌跡', pts: state.selfTracks });
  Object.keys(state.peerTracks || {}).forEach(sid => {
    const pts = state.peerTracks[sid];
    if (pts && pts.length >= 2) {
      const n = (state.peers[sid] && state.peers[sid].name) || sid.slice(0, 6);
      trackParts.push({ name: n, pts });
    }
  });
  if (state.route && state.route.length >= 2) {
    trackParts.push({ name: '路線', pts: state.route.map(p => ({ lat: p.lat, lon: p.lon, ts: Date.now() })) });
  }
  trackParts.forEach(tr => {
    lines.push(`<trk><name>${esc(tr.name)}</name><trkseg>`);
    tr.pts.forEach(p => lines.push(`<trkpt lat="${p.lat}" lon="${p.lon}"><time>${new Date(p.ts || Date.now()).toISOString()}</time></trkpt>`));
    lines.push('</trkseg></trk>');
  });
  lines.push('</gpx>');
  return lines.join('\n');
}

function activityGpxFilename() {
  return 'pich2pich-' + new Date().toISOString().slice(0, 10) + '.gpx';
}

function activityReportFilename() {
  return 'pich2pich-report-' + new Date().toISOString().slice(0, 10) + '.html';
}

async function shareBlobViaPhone(blob, filename, title, text, fallback) {
  if (navigator.share) {
    try {
      const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' });
      const payload = { title: title || 'Pich2Pich', text: text || '' };
      if (navigator.canShare && navigator.canShare({ files: [file] })) {
        await navigator.share({ ...payload, files: [file] });
        return true;
      }
      if (text) {
        await navigator.share(payload);
        return true;
      }
    } catch (e) {
      if (e && e.name === 'AbortError') return true;
    }
  }
  if (typeof fallback === 'function') fallback();
  return false;
}

function buildReportPlainSummary() {
  const selfKm = trackDistanceKm(state.selfTracks);
  const peerKm = Object.keys(state.peerTracks || {}).reduce((sum, sid) => sum + trackDistanceKm(state.peerTracks[sid]), 0);
  const sos = (state.sosLog || []).slice(-5).map(e =>
    `${new Date(e.ts).toLocaleString()} ${e.kind}: ${e.text}`
  ).join('\n') || '（無）';
  return [
    `Pich2Pich 活動報告`,
    `隊伍：${state.name || ''}  房間：${state.relayRoom || ''}`,
    `時間：${new Date().toLocaleString()}`,
    `軌跡：我的約 ${selfKm.toFixed(2)} km，隊友合計約 ${peerKm.toFixed(2)} km`,
    `路線點：${(state.route || []).length}  集合點：${(state.rallyPoints || []).length}`,
    `最近 SOS：\n${sos}`,
    '',
    '完整 GPX 請用 App「分享 GPX 路線」附加檔案。',
  ].join('\n');
}

async function shareGpxViaPhone() {
  const xml = buildActivityGpxXml();
  const name = activityGpxFilename();
  const blob = new Blob([xml], { type: 'application/gpx+xml' });
  setReportEmailStatus('開啟分享…請選郵件 App', 'busy');
  const ok = await shareBlobViaPhone(blob, name, 'Pich2Pich GPX', '活動軌跡、路線與集合點', () => {
    exportActivityGpx();
    alert('已下載 GPX。\n\n請開郵件 App → 寫信 → 附加檔案 → 從「下載」選 ' + name);
  });
  if (ok) setReportEmailStatus('已分享 GPX', 'ok');
}

async function shareReportViaPhone() {
  const html = buildActivityReportHtml();
  const name = activityReportFilename();
  const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
  setReportEmailStatus('開啟分享…請選郵件 App', 'busy');
  const ok = await shareBlobViaPhone(blob, name, 'Pich2Pich 活動報告', buildReportPlainSummary(), () => {
    openMailAppWithSummary();
  });
  if (ok) setReportEmailStatus('已分享報告檔', 'ok');
}

function openMailAppWithSummary() {
  const to = ($('reportEmailTo') && $('reportEmailTo').value || '').trim();
  const subject = encodeURIComponent(`Pich2Pich 活動報告 — ${state.name || '隊伍'}`);
  const body = encodeURIComponent(buildReportPlainSummary());
  const mailto = 'mailto:' + (to ? encodeURIComponent(to) : '') + `?subject=${subject}&body=${body}`;
  window.location.href = mailto;
  setReportEmailStatus('已開啟郵件 App（請手動附加 GPX）', 'ok');
}

function getSosVoiceFile() {
  if (!state.sosVoiceBlob) return null;
  try {
    const bin = atob(state.sosVoiceBlob);
    const arr = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
    const mime = state.sosVoiceMime || 'audio/webm';
    const ext = mime.includes('mp4') ? 'm4a' : 'webm';
    return new File([arr], `sos-voice.${ext}`, { type: mime });
  } catch {
    return null;
  }
}

function buildActivityShareFiles() {
  const files = [
    new File([buildActivityGpxXml()], activityGpxFilename(), { type: 'application/gpx+xml' }),
    new File([buildActivityReportHtml()], activityReportFilename(), { type: 'text/html;charset=utf-8' }),
  ];
  const sos = getSosVoiceFile();
  if (sos) files.push(sos);
  return files;
}

function activityZipFilename() {
  const d = new Date().toISOString().slice(0, 10);
  return `pich2pich-activity-${d}.zip`;
}

async function buildActivityZipBlob(files) {
  if (!window.fflate || !window.fflate.zipSync) throw new Error('zip 程式庫未載入');
  const zipObj = {};
  for (const f of files) {
    zipObj[f.name] = new Uint8Array(await f.arrayBuffer());
  }
  return window.fflate.zipSync(zipObj, { level: 6 });
}

async function downloadActivityZip(files) {
  const zipped = await buildActivityZipBlob(files);
  const blob = new Blob([zipped], { type: 'application/zip' });
  const a = document.createElement('a');
  const url = URL.createObjectURL(blob);
  a.href = url;
  a.download = activityZipFilename();
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
}

async function shareActivityBundle() {
  setReportEmailStatus('準備分享…', 'busy');
  const files = buildActivityShareFiles();
  if (navigator.share && navigator.canShare && navigator.canShare({ files })) {
    try {
      await navigator.share({
        title: 'Pich2Pich 活動資料',
        text: buildReportPlainSummary(),
        files,
      });
      setReportEmailStatus('已分享 GPX+報告' + (state.sosVoiceBlob ? '+SOS語音' : ''), 'ok');
      return;
    } catch (e) {
      if (e && e.name === 'AbortError') return;
    }
  }
  if (navigator.share && navigator.canShare) {
    let shared = 0;
    for (const f of files) {
      if (!navigator.canShare({ files: [f] })) continue;
      try {
        await navigator.share({ title: f.name, files: [f] });
        shared++;
        await new Promise(r => setTimeout(r, 350));
      } catch (e) {
        if (e && e.name === 'AbortError') {
          setReportEmailStatus(shared ? `已分享 ${shared} 個檔案` : '', shared ? 'ok' : '');
          return;
        }
      }
    }
    if (shared >= files.length) {
      setReportEmailStatus('已分次分享全部檔案', 'ok');
      return;
    }
    if (shared > 0) {
      setReportEmailStatus(`已分享 ${shared}/${files.length}，其餘請手動下載`, 'warn');
      return;
    }
  }
  try {
    await downloadActivityZip(files);
    setReportEmailStatus('已下載 ZIP（含 GPX+報告' + (state.sosVoiceBlob ? '+SOS語音' : '') + '）', 'ok');
    return;
  } catch (e) {
    console.warn('zip fallback', e);
  }
  exportActivityGpx();
  alert('此裝置無法分享檔案。\n已下載 GPX；若 ZIP 失敗請分次分享各檔。');
  setReportEmailStatus('請分次分享各檔案', 'warn');
}

function buildEmailAttachments() {
  const list = [{
    filename: activityGpxFilename(),
    content: utf8ToB64(buildActivityGpxXml()),
    subtype: 'gpx+xml',
  }];
  if (state.sosVoiceBlob) {
    list.push({
      filename: 'sos-voice.webm',
      content: state.sosVoiceBlob,
      subtype: 'webm',
    });
  }
  return list;
}

function exportActivityGpx() {
  const xml = buildActivityGpxXml();
  const blob = new Blob([xml], { type: 'application/gpx+xml' });
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = 'pich2pich-' + new Date().toISOString().slice(0, 10) + '.gpx';
  a.click();
  URL.revokeObjectURL(a.href);
}

function utf8ToB64(str) {
  const bytes = new TextEncoder().encode(str);
  let bin = '';
  bytes.forEach(b => { bin += String.fromCharCode(b); });
  return btoa(bin);
}

function loadReportSmtpUI() {
  const u = $('reportSmtpUser');
  const p = $('reportSmtpPass');
  const t = $('reportEmailTo');
  if (u) u.value = store.get('p2p_report_smtp_user', '');
  if (p) p.value = store.get('p2p_report_smtp_pass', '');
  if (t) t.value = store.get('p2p_report_email_to', '') || store.get('p2p_report_smtp_user', '');
}

function saveReportSmtpSettings() {
  const u = ($('reportSmtpUser') && $('reportSmtpUser').value || '').trim();
  const p = ($('reportSmtpPass') && $('reportSmtpPass').value || '').trim();
  const t = ($('reportEmailTo') && $('reportEmailTo').value || '').trim();
  if (!u || !p) return alert('請填 Gmail 與應用程式密碼');
  store.set('p2p_report_smtp_user', u);
  store.set('p2p_report_smtp_pass', p);
  if (t) store.set('p2p_report_email_to', t);
  setReportEmailStatus('Email 設定已存本機', 'ok');
}

function setReportEmailStatus(msg, kind) {
  const el = $('reportEmailStatus');
  if (!el) return;
  el.textContent = msg || '';
  el.className = 'map-download-status small ' + (kind || '');
}

function getReportSmtpPayload() {
  const user = ($('reportSmtpUser') && $('reportSmtpUser').value || store.get('p2p_report_smtp_user', '')).trim();
  const pass = ($('reportSmtpPass') && $('reportSmtpPass').value || store.get('p2p_report_smtp_pass', '')).trim();
  if (!user || !pass) return null;
  return { host: 'smtp.gmail.com', port: 587, user, pass, from: user };
}

/* —— 離線 SOS 佇列 —— */
function updateSosQueueUI() {
  const el = $('sosQueueStatus');
  if (!el) return;
  const n = (state.sosQueue || []).length;
  el.textContent = n ? `離線佇列：${n} 筆 SOS 待廣播` : '';
  el.classList.toggle('warn', n > 0);
}

function logSosEvent(kind, text, queued) {
  state.sosLog = state.sosLog || [];
  state.sosLog.push({ ts: Date.now(), kind, text: (text || '').slice(0, 500), queued: !!queued });
  if (state.sosLog.length > 300) state.sosLog = state.sosLog.slice(-300);
  store.setJSON('p2p_sos_log', state.sosLog);
  updateSosQueueUI();
}

async function sendSosPayload(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
  };
  let sent = null;
  if (typeof meshSend === 'function') sent = await meshSend(payload);
  else sent = 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() });
  } else if (includeVoice && state.sosVoiceBlob && !near) {
    pushEntry('group', { dir: 'me', type: 'TEXT', text: '（SOS 語音待近距／中繼後再送）', name: state.name, ts: Date.now() });
  }
  if (state.contactPhone) {
    const smsBody = `[Pich2Pich SOS] ${state.name}：${state.sosText || '求救'} ${loc}`;
    try {
      const url = `sms:${state.contactPhone}?body=${encodeURIComponent(smsBody)}`;
      window.location.href = url;
    } catch {}
  }
  logSosEvent(includeVoice ? 'sos+voice' : 'sos', text, !sent);
  return !!sent;
}

async function queueSosOrBroadcast(includeVoice) {
  if (typeof hasMeshLink === 'function' && !hasMeshLink()) {
    state.sosQueue.push({ includeVoice: !!includeVoice, ts: Date.now(), id: uuid() });
    store.setJSON('p2p_sos_queue', state.sosQueue);
    const loc = state.self ? `${state.self.lat.toFixed(5)},${state.self.lon.toFixed(5)}` : '(無定位)';
    logSosEvent(includeVoice ? 'sos+voice' : 'sos', `🆘 ${state.name}（待送） ${state.sosText || ''} @ ${loc}`, true);
    notify('🆘 SOS 已暫存', `無網路，恢復後自動廣播（${state.sosQueue.length} 筆）`);
    beep();
    updateSosQueueUI();
    return;
  }
  const sent = await sendSosPayload(includeVoice);
  notify('🆘 SOS', sent ? '已廣播求救訊息' : '已佇列，連線後自動送出');
  beep();
}

async function flushSosQueue() {
  if (typeof hasMeshLink !== 'function' || !hasMeshLink() || !state.sosQueue.length) return;
  const q = state.sosQueue.splice(0);
  store.setJSON('p2p_sos_queue', []);
  q.sort((a, b) => a.ts - b.ts);
  const failed = [];
  let ok = 0;
  for (const item of q) {
    const sent = await sendSosPayload(item.includeVoice);
    if (sent) ok++;
    else failed.push(item);
  }
  if (failed.length) {
    state.sosQueue.push(...failed);
    store.setJSON('p2p_sos_queue', state.sosQueue);
  }
  if (ok) notify('🆘 SOS', `已送出 ${ok} 筆暫存求救` + (failed.length ? `（${failed.length} 筆仍待送）` : ''));
  updateSosQueueUI();
}

/* —— 地圖圍籬（簡化偏航守護） —— */
let geofenceAlertTs = 0;

function circlePolygon(lat, lon, radiusM, steps) {
  steps = steps || 64;
  const coords = [];
  const cos = Math.cos(lat * Math.PI / 180);
  for (let i = 0; i <= steps; i++) {
    const ang = (i / steps) * 2 * Math.PI;
    const dx = radiusM * Math.cos(ang);
    const dy = radiusM * Math.sin(ang);
    coords.push([lon + dx / (111320 * cos), lat + dy / 111320]);
  }
  return { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] } };
}

function geofenceGeoJSON() {
  const g = state.geofence;
  if (!g || g.lat == null || g.lon == null) return { type: 'FeatureCollection', features: [] };
  return { type: 'FeatureCollection', features: [circlePolygon(g.lat, g.lon, g.radiusM || 500)] };
}

function updateGeofenceMapLayer() {
  if (!map || !mapInited) return;
  const src = 'geofence';
  const data = geofenceGeoJSON();
  if (!map.getSource(src)) {
    map.addSource(src, { type: 'geojson', data });
    map.addLayer({ id: 'geofence-fill', type: 'fill', source: src, paint: { 'fill-color': '#ff9800', 'fill-opacity': 0.12 } });
    map.addLayer({ id: 'geofence-line', type: 'line', source: src, paint: { 'line-color': '#f57c00', 'line-width': 2, 'line-dasharray': [2, 2] } });
  } else map.getSource(src).setData(data);
  const show = state.geofence.enabled && state.geofence.lat != null;
  ['geofence-fill', 'geofence-line'].forEach(id => {
    if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', show ? 'visible' : 'none');
  });
}

function checkGeofence() {
  const g = state.geofence;
  const el = $('geofenceStatus');
  if (!g || !g.enabled || !state.self || g.lat == null) {
    if (el && g && g.lat == null) el.textContent = '請設定圍籬中心';
    return;
  }
  const d = distanceM(state.self.lat, state.self.lon, g.lat, g.lon);
  const r = g.radiusM || 500;
  if (el) {
    el.textContent = d <= r ? `活動區內 · 距邊界約 ${Math.max(0, Math.round(r - d))}m` : `⚠️ 已離開活動區約 ${Math.round(d - r)}m`;
    el.className = 'geofence-status small ' + (d > r ? 'bad' : 'ok');
  }
  if (d > r && Date.now() - geofenceAlertTs > 60000) {
    geofenceAlertTs = Date.now();
    notify('⚠️ 離開活動區', `已超出圍籬約 ${Math.round(d - r)} 公尺`);
    beep();
    const fenceMsg = { type: 'TEXT', to: null, text: `⚠️ ${state.name} 已離開活動圍籬（距中心 ${Math.round(d)}m）` };
    if (typeof meshSend === 'function') meshSend(fenceMsg);
    else if (typeof originate === 'function') originate(fenceMsg);
  }
}

function persistGeofence() {
  store.setJSON('p2p_geofence', state.geofence);
  updateGeofenceMapLayer();
  if (typeof refreshGeofenceCenterMarker === 'function') refreshGeofenceCenterMarker();
}

function bindGeofenceUI() {
  const sw = $('geofenceSwitch');
  const rad = $('geofenceRadius');
  const radL = $('geofenceRadiusLabel');
  const here = $('geofenceHereBtn');
  const g = state.geofence;
  if (sw) {
    sw.checked = !!g.enabled;
    sw.onchange = () => { g.enabled = sw.checked; persistGeofence(); checkGeofence(); };
  }
  if (rad) {
    rad.value = g.radiusM || 500;
    if (radL) radL.textContent = rad.value;
    rad.oninput = () => {
      g.radiusM = parseInt(rad.value, 10) || 500;
      if (radL) radL.textContent = g.radiusM;
      persistGeofence();
      checkGeofence();
    };
  }
  if (here) here.onclick = () => {
    if (!state.self) return alert('請先取得定位');
    g.lat = state.self.lat;
    g.lon = state.self.lon;
    g.enabled = true;
    if (sw) sw.checked = true;
    persistGeofence();
    checkGeofence();
    showMapHint('已以目前位置設為圍籬中心');
  };
}

/* —— 活動結束報告（列印存 PDF） —— */
function reportEsc(s) {
  return (s || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function trackDistanceKm(pts) {
  if (!pts || pts.length < 2) return 0;
  let m = 0;
  for (let i = 1; i < pts.length; i++) m += distanceM(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon);
  return m / 1000;
}

function buildActivityReportHtml() {
  const now = new Date();
  const sosRows = (state.sosLog || []).map(e =>
    `<tr><td>${reportEsc(new Date(e.ts).toLocaleString())}</td><td>${reportEsc(e.kind)}</td><td>${reportEsc(e.text)}${e.queued ? ' <em>(待送)</em>' : ''}</td></tr>`
  ).join('') || '<tr><td colspan="3">（無 SOS 紀錄）</td></tr>';
  const chatItems = [];
  Object.keys(state.convs || {}).forEach(k => {
    (state.convs[k] || []).forEach(e => {
      if (e.type === 'TEXT' && e.text) chatItems.push({ ts: e.ts || 0, who: e.name || (e.dir === 'me' ? state.name : k), text: e.text });
    });
  });
  chatItems.sort((a, b) => a.ts - b.ts);
  const chatRows = chatItems.slice(-250).map(c =>
    `<tr><td>${reportEsc(new Date(c.ts).toLocaleString())}</td><td>${reportEsc(c.who)}</td><td>${reportEsc(c.text)}</td></tr>`
  ).join('') || '<tr><td colspan="3">（無聊天）</td></tr>';
  const rallyHtml = (state.rallyPoints || []).map((p, i) =>
    `${rallyLetter(i)} ${reportEsc(p.label || '集合點')}：${p.lat?.toFixed(5)}, ${p.lon?.toFixed(5)}`
  ).join('<br>') || '（無集合點）';
  const selfKm = trackDistanceKm(state.selfTracks);
  const peerKm = Object.keys(state.peerTracks || {}).reduce((sum, sid) => sum + trackDistanceKm(state.peerTracks[sid]), 0);
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Pich2Pich 活動報告</title>
<style>
body{font-family:system-ui,sans-serif;margin:24px;color:#111;font-size:13px}
h1{font-size:20px}h2{font-size:15px;margin-top:20px;border-bottom:1px solid #ccc}
table{width:100%;border-collapse:collapse;margin:8px 0}th,td{border:1px solid #ddd;padding:6px;text-align:left;vertical-align:top}
th{background:#f5f5f5}
</style></head><body>
<h1>Pich2Pich 活動結束報告</h1>
<p>匯出：${reportEsc(now.toLocaleString())} · ${reportEsc(state.name)} · ${reportEsc(state.relayRoom || '')}</p>
<h2>軌跡摘要</h2>
<p>我的軌跡 ${(state.selfTracks || []).length} 點，約 ${selfKm.toFixed(2)} km · 隊友 ${peerKm.toFixed(2)} km · 路線 ${(state.route || []).length} 點</p>
<h2>集合點</h2><p>${rallyHtml}</p>
<h2>SOS</h2><table><thead><tr><th>時間</th><th>類型</th><th>內容</th></tr></thead><tbody>${sosRows}</tbody></table>
<h2>聊天</h2><table><thead><tr><th>時間</th><th>發送者</th><th>訊息</th></tr></thead><tbody>${chatRows}</tbody></table>
</body></html>`;
}

function exportActivityReport() {
  const html = buildActivityReportHtml();
  const w = window.open('', '_blank');
  if (!w) return alert('請允許彈出視窗以產生報告');
  w.document.write(html.replace('</style></head>', '@media print{body{margin:12px}}</style></head>'));
  w.document.close();
  setTimeout(() => { try { w.print(); } catch {} }, 400);
}

async function exportActivityReportEmail() {
  const smtp = getReportSmtpPayload();
  if (!smtp) {
    return alert('請在設定→活動匯出填 Gmail 與應用程式密碼，並按「儲存 Email 設定」');
  }
  const to = ($('reportEmailTo') && $('reportEmailTo').value || store.get('p2p_report_email_to', '')).trim();
  if (!to) return alert('請填收件 Email');
  if (!navigator.onLine) return alert('寄信需要網路');
  setReportEmailStatus('寄送中…', 'busy');
  try {
    const r = await apiFetch('/api/report/email', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        to,
        subject: `Pich2Pich 活動報告 — ${state.name} — ${state.relayRoom || ''}`,
        html: buildActivityReportHtml(),
        smtp,
        attachments: buildEmailAttachments(),
      }),
    });
    const j = await r.json();
    if (!r.ok || !j.ok) throw new Error(j.error || '寄送失敗');
    saveReportSmtpSettings();
    setReportEmailStatus('已寄出（含 GPX' + (state.sosVoiceBlob ? '+SOS語音' : '') + '）', 'ok');
    alert('報告與附件已寄至 ' + to);
  } catch (e) {
    setReportEmailStatus('失敗：' + e.message, 'bad');
    alert('Email 失敗：' + e.message + '\n\nGmail 請用「應用程式密碼」而非登入密碼。');
  }
}

function parseGpxRoutePoints(xml) {
  const doc = new DOMParser().parseFromString(xml, 'application/xml');
  if (doc.querySelector('parsererror')) throw new Error('GPX 格式無法解析');
  const pts = [];
  const add = (lat, lon) => {
    const la = parseFloat(lat);
    const lo = parseFloat(lon);
    if (isFinite(la) && isFinite(lo)) pts.push({ lat: la, lon: lo });
  };
  doc.querySelectorAll('trkpt').forEach(el => add(el.getAttribute('lat'), el.getAttribute('lon')));
  if (!pts.length) doc.querySelectorAll('rtept').forEach(el => add(el.getAttribute('lat'), el.getAttribute('lon')));
  if (!pts.length) {
    doc.querySelectorAll('wpt').forEach(el => add(el.getAttribute('lat'), el.getAttribute('lon')));
  }
  if (pts.length < 2) throw new Error('GPX 需至少 2 個軌跡點');
  return pts;
}

async function importGpxGuardRoute(file, autoEnable) {
  if (!file) return;
  const xml = await file.text();
  const pts = parseGpxRoutePoints(xml);
  state.route = pts;
  store.setJSON('p2p_route', state.route);
  if (typeof redrawRoute === 'function') redrawRoute();
  if (typeof resetGuard === 'function') resetGuard();
  if (autoEnable !== false) {
    state.guardEnabled = true;
    store.set('p2p_guard', '1');
    const sw = $('guardSwitch');
    if (sw) sw.checked = true;
    if (typeof updateWakeLock === 'function') updateWakeLock();
  }
  if (typeof updateGuardStatus === 'function') updateGuardStatus();
  const ab = $('abStatus');
  const guardNote = autoEnable !== false ? '，偏航守護已啟用' : '';
  if (ab) ab.textContent = `已匯入 GPX 路線 ${pts.length} 點${guardNote}`;
  if (typeof showMapHint === 'function') showMapHint(`GPX ${pts.length} 點已載入${guardNote}`);
  if (map && pts.length) {
    const lngs = pts.map(p => p.lon);
    const lats = pts.map(p => p.lat);
    map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 40, maxZoom: 15 });
  }
}

async function importPmtilesFile(file) {
  if (!file) return;
  if (!/\.pmtiles$/i.test(file.name)) return alert('請選擇 .pmtiles 檔');
  setMapDownloadStatus('匯入 ' + file.name + '…', 'busy');
  try {
    const blob = file.slice(0, file.size, 'application/octet-stream');
    await idbSavePmtiles(blob);
    store.set('p2p_pmtiles_cached', '1');
    if (typeof window.applyPmtilesBlob === 'function') await window.applyPmtilesBlob(blob, true);
    setMapDownloadStatus('已匯入 ' + file.name, 'ok');
  } catch (e) {
    setMapDownloadStatus('匯入失敗：' + e.message, 'bad');
    alert('PMTiles 匯入失敗：' + e.message);
  }
}

/* —— 離線 PMTiles：有網下載、斷網使用 —— */
function openMapIdb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('pich2pich_maps', 1);
    req.onupgradeneeded = () => req.result.createObjectStore('files');
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function idbSavePmtiles(blob) {
  const db = await openMapIdb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('files', 'readwrite');
    tx.objectStore('files').put(blob, 'local.pmtiles');
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function idbLoadPmtiles() {
  const db = await openMapIdb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction('files', 'readonly');
    const r = tx.objectStore('files').get('local.pmtiles');
    r.onsuccess = () => resolve(r.result || null);
    r.onerror = () => reject(r.error);
  });
}

function getMapViewBbox() {
  if (!map) return null;
  const b = map.getBounds();
  const pad = 0.02;
  return [
    b.getWest() - pad,
    b.getSouth() - pad,
    b.getEast() + pad,
    b.getNorth() + pad,
  ].map(v => +v.toFixed(5)).join(',');
}

function setMapDownloadStatus(msg, kind) {
  ['mapDownloadStatus', 'installMapDownloadStatus'].forEach((id) => {
    const el = $(id);
    if (!el) return;
    el.textContent = msg || '';
    el.className = 'map-download-status small ' + (kind || '');
  });
}

function paintRegionMapList(el, regions, offline) {
  if (!el) return;
  if (offline) {
    el.innerHTML = '<span class="muted small mode-hint" data-offline="無網路：請用下方「匯入 .pmtiles」或已載入的本機離線圖。" data-online="有網時可從伺服器下載區域圖。">無網路：請用下方「匯入 .pmtiles」或已載入的本機離線圖。</span>';
    return;
  }
  if (!regions || !regions.length) {
    el.innerHTML = '<span class="muted small">無法載入區域列表</span>';
    return;
  }
  el.innerHTML = '';
  regions.forEach(r => {
    const b = document.createElement('button');
    b.className = 'btn ghost small region-map-btn';
    b.type = 'button';
    const sz = r.size ? ` (${Math.round(r.size / 1048576)}MB)` : '';
    const tag = r.status === 'ready' ? '✓' : (r.status === 'building' ? '…' : '＋');
    b.textContent = `${tag} ${r.name}${sz}`;
    b.onclick = () => downloadRegionMap(r.id);
    el.appendChild(b);
  });
}

async function renderRegionMapList() {
  const els = ['regionMapList', 'installRegionMapList'].map(id => $(id)).filter(Boolean);
  if (!els.length) return;
  const offline = typeof isOfflineMode === 'function' && isOfflineMode();
  if (offline) {
    els.forEach(el => paintRegionMapList(el, null, true));
    return;
  }
  els.forEach(el => { el.innerHTML = '<span class="muted small">載入區域列表…</span>'; });
  try {
    const j = await (await apiFetch('/api/offline-map/regions')).json();
    els.forEach(el => paintRegionMapList(el, j.regions || [], false));
  } catch {
    els.forEach(el => paintRegionMapList(el, null, false));
  }
}

async function loadCachedPmtilesOnBoot() {
  if (store.get('p2p_mapsrc', '') !== 'offline' && store.get('p2p_pmtiles_cached', '0') !== '1') return;
  try {
    const blob = await idbLoadPmtiles();
    if (blob && typeof window.applyPmtilesBlob === 'function') {
      await window.applyPmtilesBlob(blob, false);
      setMapDownloadStatus('已載入本機離線圖', 'ok');
    }
  } catch {}
}

async function downloadRegionMap(regionId) {
  if (typeof isOfflineMode === 'function' && isOfflineMode()) {
    return alert('無網路：請用「匯入 .pmtiles」或事先載入離線圖');
  }
  if (!navigator.onLine) return alert('需要網路才能從伺服器下載；無網請用匯入 .pmtiles');
  setMapDownloadStatus(`下載 ${regionId}…`, 'busy');
  try {
    let st = await (await apiFetch('/api/offline-map/status?region=' + encodeURIComponent(regionId))).json();
    if (st.status === 'missing') {
      st = await (await apiFetch('/api/offline-map/build', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ region: regionId }),
      })).json();
    }
    if (st.status === 'building') {
      setMapDownloadStatus((st.hint || '伺服器建立中…') + ' 請稍後再按', 'warn');
      return;
    }
    if (st.status !== 'ready' || !st.url) throw new Error(st.error || '區域圖尚未就緒');
    const resp = await fetch(apiAbsoluteUrl(st.url));
    if (!resp.ok) throw new Error('HTTP ' + resp.status);
    const blob = await resp.blob();
    await idbSavePmtiles(blob);
    await window.applyPmtilesBlob(blob, true);
    setMapDownloadStatus(`已載入 ${regionId} 離線圖`, 'ok');
  } catch (e) {
    setMapDownloadStatus('失敗：' + e.message, 'bad');
    alert('區域離線圖：' + e.message);
  }
}


async function applyRemoteConfig() {
  let cfg = null;
  try {
    const r = await apiFetch('/api/config');
    if (r.ok) cfg = await r.json();
  } catch {}
  if (!cfg) {
    try {
      const r = await fetch('/config.defaults.json', { cache: 'no-cache' });
      if (r.ok) cfg = await r.json();
    } catch {}
  }
  applyConfigObject(cfg);
  if (typeof connectRelay === 'function') connectRelay(true);
}

async function downloadOfflineMapForView(startBuild) {
  if (typeof isOfflineMode === 'function' && isOfflineMode()) {
    const cached = await idbLoadPmtiles();
    if (cached && typeof window.applyPmtilesBlob === 'function') {
      await window.applyPmtilesBlob(cached, true);
      setMapDownloadStatus('已載入本機離線圖', 'ok');
      return;
    }
    return alert('無網路：請用「匯入 .pmtiles」選擇檔案');
  }
  if (!navigator.onLine) return alert('需要網路才能下載；無網請用匯入 .pmtiles');
  if (typeof ensureMap === 'function') ensureMap();
  await new Promise(r => setTimeout(r, 300));
  const bbox = getMapViewBbox();
  if (!bbox) return alert('請先開啟地圖頁並移動到活動區域');
  setMapDownloadStatus(startBuild ? '向伺服器請求建立離線圖…' : '檢查離線圖…', 'busy');
  try {
    if (!startBuild) {
      const cached = await idbLoadPmtiles();
      if (cached) {
        await window.applyPmtilesBlob(cached, true);
        setMapDownloadStatus('已使用本機快取離線圖', 'ok');
        return;
      }
    }
    let st = await (await apiFetch('/api/offline-map/status?bbox=' + encodeURIComponent(bbox))).json();
    if (st.status === 'missing' && startBuild) {
      st = await (await apiFetch('/api/offline-map/build', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ bbox }),
      })).json();
    }
    if (st.status === 'building') {
      setMapDownloadStatus('伺服器建立中…' + (st.hint || ' 請幾分鐘後再按「檢查並下載」'), 'warn');
      return;
    }
    if (st.status === 'error') {
      setMapDownloadStatus('建立失敗：' + (st.error || ''), 'bad');
      return;
    }
    if (st.status !== 'ready' || !st.url) {
      setMapDownloadStatus('尚無離線圖，請按「下載此區離線圖」', 'warn');
      return;
    }
    setMapDownloadStatus('下載中…', 'busy');
    const resp = await fetch(apiAbsoluteUrl(st.url));
    if (!resp.ok) throw new Error('HTTP ' + resp.status);
    const blob = await resp.blob();
    await idbSavePmtiles(blob);
    await window.applyPmtilesBlob(blob, true);
    setMapDownloadStatus('離線圖已存本機，斷網可切換「離線 PMTiles」', 'ok');
  } catch (e) {
    setMapDownloadStatus('失敗：' + e.message, 'bad');
    alert('下載離線圖失敗：' + e.message);
  }
}

function bindOfflineMapUI() {
  const bindDl = (id, fn) => { const b = $(id); if (b) b.onclick = fn; };
  bindDl('mapDownloadBtn', () => downloadOfflineMapForView(true));
  bindDl('installMapDownloadBtn', () => downloadOfflineMapForView(true));
  bindDl('mapDownloadCheckBtn', () => downloadOfflineMapForView(false));
  bindDl('installMapCheckBtn', () => downloadOfflineMapForView(false));
  const mds = $('mapDownloadSettingsBtn');
  if (mds) mds.onclick = () => {
    const sec = $('installDownloadSection');
    if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
    else showPage('settings');
  };
  const bindImport = (btnId, inputId) => {
    const pmi = $(inputId);
    const pmb = $(btnId);
    if (!pmb || !pmi) return;
    pmb.onclick = () => pmi.click();
    pmi.onchange = () => {
      const f = pmi.files && pmi.files[0];
      pmi.value = '';
      if (f) importPmtilesFile(f);
    };
  };
  bindImport('pmtilesImportBtn', 'pmtilesImportInput');
  bindImport('installPmtilesImportBtn', 'installPmtilesImportInput');
  const gpxIn = $('gpxRouteImportInput');
  const gpxBtn = $('gpxRouteImportBtn');
  if (gpxBtn && gpxIn) {
    gpxBtn.onclick = () => gpxIn.click();
    gpxIn.onchange = () => {
      const f = gpxIn.files && gpxIn.files[0];
      gpxIn.value = '';
      if (f) importGpxGuardRoute(f).catch(e => alert('GPX 匯入：' + e.message));
    };
  }
  renderRegionMapList();
  loadCachedPmtilesOnBoot();
}

function updateGeoAccuracyUI(acc) {
  const el = $('geoAccuracy');
  if (!el) return;
  if (acc == null || !isFinite(acc)) { el.textContent = '定位：—'; el.className = 'geo-accuracy'; return; }
  const rounded = Math.round(acc);
  let kind = 'good';
  let src = 'GPS';
  let coarse = '';
  if (rounded > 500) { kind = 'bad'; src = '網路/IP'; coarse = '粗'; }
  else if (rounded > 80) { kind = 'warn'; src = '混合'; coarse = '粗'; }
  else if (rounded > 50) { kind = 'warn'; src = 'GPS'; coarse = '粗'; }
  el.textContent = coarse
    ? `定位 ${coarse}±${rounded}m（${src}）`
    : `定位 ±${rounded}m（${src}）`;
  el.className = 'geo-accuracy ' + kind;
  if (rounded > 150) {
    showMapHint('定位偏差大：請到戶外開闊處，按「高精準定位」');
  }
}

function requestHighAccuracyGeo() {
  if (!navigator.geolocation) return;
  showMapHint('高精準定位中…');
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      if (typeof applyGeoPosition === 'function') applyGeoPosition(pos);
      else {
        state.self = { lat: pos.coords.latitude, lon: pos.coords.longitude, accuracy: pos.coords.accuracy };
        setSelfOnMap(state.self.lat, state.self.lon);
      }
      if (map) map.easeTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 17 });
    },
    (err) => showMapHint('高精準失敗：' + err.message),
    { enableHighAccuracy: true, maximumAge: 0, timeout: 25000 }
  );
}

function bindExtrasUI() {
  loadExtrasState();
  applyFixedRelayOnBoot();
  applyRemoteConfig();

  const appUrl = $('fixedAppUrl');
  const relayFix = $('fixedRelayUrl');
  const saveFix = $('saveFixedUrlBtn');
  if (appUrl) appUrl.value = state.fixedAppUrl || '';
  if (relayFix) relayFix.value = state.fixedRelayUrl || '';
  if (saveFix) saveFix.onclick = () => {
    state.fixedAppUrl = (appUrl && appUrl.value.trim()) || '';
    state.fixedRelayUrl = (relayFix && relayFix.value.trim()) || '';
    store.set('p2p_fixed_app_url', state.fixedAppUrl);
    store.set('p2p_fixed_relay', state.fixedRelayUrl);
    applyFixedRelayOnBoot();
    if (state.fixedRelayUrl) connectRelay(true);
    alert('已儲存固定網址');
  };

  const gpx = $('exportGpxBtn');
  if (gpx) gpx.onclick = exportActivityGpx;
  const shareGpx = $('shareGpxBtn');
  if (shareGpx) shareGpx.onclick = () => shareGpxViaPhone();
  const shareRpt = $('shareReportBtn');
  if (shareRpt) shareRpt.onclick = () => shareReportViaPhone();
  const shareBundle = $('shareActivityBundleBtn');
  if (shareBundle) shareBundle.onclick = () => shareActivityBundle();
  const mailSum = $('mailSummaryBtn');
  if (mailSum) mailSum.onclick = openMailAppWithSummary;
  const baroCal = $('baroCalBtn');
  if (baroCal) baroCal.onclick = () => { if (typeof calibrateBarometerFloor === 'function') calibrateBarometerFloor(); };
  const rpt = $('exportReportBtn');
  if (rpt) rpt.onclick = exportActivityReport;
  const rptE = $('exportReportEmailBtn');
  if (rptE) rptE.onclick = exportActivityReportEmail;
  const saveSmtp = $('saveReportSmtpBtn');
  if (saveSmtp) saveSmtp.onclick = saveReportSmtpSettings;
  loadReportSmtpUI();

  const pasteBtn = $('pasteCodeBtn');
  const pasteWalkie = $('pasteCodeWalkieBtn');
  const pasteClose = $('pasteCodeClose');
  const pasteSubmit = $('pasteCodeSubmit');
  if (pasteBtn) pasteBtn.onclick = () => openPasteCodeModal('auto');
  if (pasteWalkie) pasteWalkie.onclick = () => openPasteCodeModal('auto');
  if (pasteClose) pasteClose.onclick = () => $('pasteCodeModal').classList.add('hidden');
  if (pasteSubmit) pasteSubmit.onclick = submitPasteCode;

  bindGeofenceUI();
  bindOfflineMapUI();
  updateSosQueueUI();

  const hiGeo = $('highAccuracyBtn');
  if (hiGeo) hiGeo.onclick = requestHighAccuracyGeo;

  preloadMapTiles();
  setInterval(updateVoiceQualityLights, 3000);
  updateVoiceQualityLights();
  startMapBuildPoll();
}

const APP_VERSION = 123;
let mapBuildPollTimer = null;

function showAppUpdateBannerIfNeeded() {
  /* 已關閉版本更新橫幅 */
  store.set('p2p_app_ver', String(APP_VERSION));
}

async function pollMapRegionsOnce() {
  try {
    const j = await (await apiFetch('/api/offline-map/regions')).json();
    let anyBuilding = false;
    (j.regions || []).forEach(r => {
      if (r.status === 'building') anyBuilding = true;
      if (r.status === 'ready' && store.get('p2p_map_notified_' + r.id, '') !== '1') {
        store.set('p2p_map_notified_' + r.id, '1');
        const msg = `${r.name || r.id} 離線圖可下載`;
        if (typeof showMapHint === 'function') showMapHint('🗺️ ' + msg);
        if (typeof setMapDownloadStatus === 'function') setMapDownloadStatus(msg, 'ok');
        if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
          try { new Notification('🗺️ 離線圖可下載', { body: msg, tag: 'map-' + r.id }); } catch {}
        }
      }
    });
    if (!anyBuilding && mapBuildPollTimer) {
      clearInterval(mapBuildPollTimer);
      mapBuildPollTimer = null;
    }
  } catch {}
}

function startMapBuildPoll() {
  if (typeof isOfflineMode === 'function' && isOfflineMode()) return;
  if (!navigator.onLine) return;
  if (mapBuildPollTimer) return;
  pollMapRegionsOnce();
  mapBuildPollTimer = setInterval(pollMapRegionsOnce, 25000);
}

function initExtras() {
  bindExtrasUI();
}

window.rallyLetter = rallyLetter;
window.scanOrPaste = scanOrPaste;
window.openPasteCodeModal = openPasteCodeModal;
window.recordSelfTrack = recordSelfTrack;
window.updateGeoAccuracyUI = updateGeoAccuracyUI;
window.initExtras = initExtras;
window.exportActivityGpx = exportActivityGpx;
window.exportActivityReport = exportActivityReport;
window.queueSosOrBroadcast = queueSosOrBroadcast;
window.flushSosQueue = flushSosQueue;
window.checkGeofence = checkGeofence;
window.updateGeofenceMapLayer = updateGeofenceMapLayer;
window.downloadOfflineMapForView = downloadOfflineMapForView;
window.downloadRegionMap = downloadRegionMap;
window.applyRemoteConfig = applyRemoteConfig;
window.exportActivityReportEmail = exportActivityReportEmail;
window.shareGpxViaPhone = shareGpxViaPhone;
window.shareActivityBundle = shareActivityBundle;
window.importGpxGuardRoute = importGpxGuardRoute;
window.importPmtilesFile = importPmtilesFile;
window.shareReportViaPhone = shareReportViaPhone;
