'use strict';
// ═══════════════════════════════════════════════
// js/sync.js — Sprint 0: Data model migration
//              Sprint 2: Sync engine (manifest, diff, merge)
//              Sprint 3: Sync triggers (save, load, heartbeat, manual)
// ═══════════════════════════════════════════════

// Fields that are sync metadata — excluded from checksum computation
const _SYNC_META_FIELDS = new Set(['deviceId', 'updatedAt', 'version', 'checksum', 'sig']);

// ── Checksum ──────────────────────────────────────────────────────────────────
// SHA-256 of entry content (sync meta fields excluded, keys sorted for determinism)
async function computeEntryChecksum(entry) {
  const keys = Object.keys(entry).filter(k => !_SYNC_META_FIELDS.has(k)).sort();
  const content = keys.reduce((o, k) => { o[k] = entry[k]; return o; }, {});
  const json = JSON.stringify(content);
  const buf  = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(json));
  return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}

// ── Device ID ─────────────────────────────────────────────────────────────────
// Persistent per-device UUID stored in IDB meta. Created once, never changes.
async function getDeviceId() {
  let did = await idbMetaGet('frj_device_id');
  if (!did) {
    did = uuid();
    await idbMetaPut('frj_device_id', did);
  }
  return did;
}

// ── Stamp sync fields ─────────────────────────────────────────────────────────
// Call this after building/patching an entry before the final saveEntries() call.
// Mutates entry in-place and returns it.
async function stampSyncFields(entry) {
  const deviceId = await getDeviceId();
  entry.deviceId  = deviceId;
  entry.updatedAt = Date.now();
  entry.version   = (entry.version || 0) + 1;
  entry.checksum  = await computeEntryChecksum(entry);
  // Sign entry metadata with ECDSA — proves entry came from a device with this identity
  try {
    entry.sig = await signEntry({ id: entry.id, version: entry.version, updatedAt: entry.updatedAt, checksum: entry.checksum });
  } catch(e) {
    console.warn('[sync] entry signing failed:', e);
  }
  return entry;
}

// ── Sprint 0 helpers ──────────────────────────────────────────────────────────

// Silently snapshot all entries to the emergency IDB store
async function _createEmergencyBackup() {
  try {
    const snapshot = JSON.stringify({
      backedUpAt:  new Date().toISOString(),
      entries:     getEntries(),
      contacts:    getContacts(),
      profile:     getProfile(),
      weeklyStats: getWeeklyStats()
    });
    await idbEmergencyPut('sprint0_snapshot', snapshot);
  } catch(e) {
    console.warn('[sync] Emergency backup failed:', e);
  }
}

// Show mandatory backup modal — resolves only when user has downloaded backup and clicked Continue
function _showMigrationBackupModal() {
  return new Promise(resolve => {
    const old = document.getElementById('_s0-migration-modal');
    if (old) old.remove();

    const overlay = document.createElement('div');
    overlay.id        = '_s0-migration-modal';
    overlay.className = 'enc-setup-overlay active';
    overlay.innerHTML = `
      <div class="enc-setup-box">
        <div class="enc-setup-icon">🔄</div>
        <h2 class="enc-setup-title">One-time data update</h2>
        <p class="enc-setup-sub">Forestorers is preparing your journal for cross-device sync. Before updating your data format, please download a backup. This is a one-time step.</p>
        <button class="btn btn-primary enc-setup-submit" id="_s0-backup-btn">Download Backup</button>
        <p id="_s0-dl-msg" style="display:none;color:var(--green,#22c55e);font-family:var(--mono);font-size:13px;margin:10px 0 0">Backup downloaded ✓</p>
        <button class="btn btn-primary enc-setup-submit" id="_s0-proceed-btn" disabled style="opacity:.45;margin-top:10px">Continue</button>
        <p style="font-size:11px;color:var(--muted2);margin-top:10px">Your entries are not changed until you click Continue.</p>
      </div>`;
    document.body.appendChild(overlay);

    const backupBtn  = overlay.querySelector('#_s0-backup-btn');
    const proceedBtn = overlay.querySelector('#_s0-proceed-btn');
    const dlMsg      = overlay.querySelector('#_s0-dl-msg');

    backupBtn.addEventListener('click', async () => {
      backupBtn.disabled    = true;
      backupBtn.textContent = 'Downloading…';
      try {
        // Use plaintext export so the backup is always readable regardless of encryption state
        await _exportPlaintext(/* warned= */ true);
      } catch(e) { /* ignore — user can retry */ }
      dlMsg.style.display  = '';
      proceedBtn.disabled  = false;
      proceedBtn.style.opacity = '';
      backupBtn.textContent = 'Download Again';
      backupBtn.disabled    = false;
    });

    proceedBtn.addEventListener('click', () => {
      overlay.remove();
      resolve();
    });
  });
}

// Migrate a single entry in-place (additive only — never deletes fields)
async function _migrateEntry(entry, deviceId) {
  if (!entry.id)       entry.id       = uuid();
  if (!entry.deviceId) entry.deviceId = deviceId;
  if (!entry.updatedAt) {
    // Derive from entry date if available, else now
    entry.updatedAt = entry.date
      ? new Date(entry.date + 'T12:00:00').getTime()
      : Date.now();
  }
  if (!entry.version)  entry.version  = 1;
  // Always recompute checksum from current content
  entry.checksum = await computeEntryChecksum(entry);
  return entry;
}

async function _runSprint0Migration() {
  const entries = getEntries();

  if (!entries.length) {
    // Fresh install — nothing to migrate, just advance schema
    await idbMetaPut('syncSchemaVersion', 2);
    console.log('[sync] Sprint 0: fresh install, syncSchemaVersion → 2');
    return;
  }

  // 1. Silent emergency snapshot (before modal, before any mutation)
  await _createEmergencyBackup();

  // 2. Mandatory backup modal — user cannot proceed without downloading
  await _showMigrationBackupModal();

  // 3. Migrate
  const deviceId = await getDeviceId();
  const migrated = [];

  for (const entry of entries) {
    try {
      const updated = { ...entry };
      await _migrateEntry(updated, deviceId);

      // Verify all 5 sync fields are present and non-null
      if (!updated.id || !updated.deviceId || !updated.updatedAt || !updated.version || !updated.checksum) {
        throw new Error(`Verification failed for entry id=${entry.id || '?'}`);
      }
      migrated.push(updated);
    } catch(e) {
      console.error('[sync] Migration aborted:', e);
      toast('Data update failed. Your entries are unchanged. Check the browser console.', true);
      // Do NOT advance syncSchemaVersion; emergency backup intact
      return;
    }
  }

  // 4. Save migrated entries
  saveEntries(migrated);

  // 5. Only advance syncSchemaVersion after successful save
  await idbMetaPut('syncSchemaVersion', 2);

  // 6. Record migration log
  await idbMetaPut('sprint0_log', JSON.stringify({
    migratedAt: new Date().toISOString(),
    count:      migrated.length
  }));

  console.log(`[sync] Sprint 0 complete — ${migrated.length} entries migrated, syncSchemaVersion → 2`);
  toast(`Journal ready for sync ✓ (${migrated.length} entries updated)`);
}

// ── Entry point — called from init() in app.js ────────────────────────────────
async function sprint0Init() {
  try {
    const sv = await idbMetaGet('syncSchemaVersion');
    if ((sv || 0) >= 2) return; // Already on schema v2
    await _runSprint0Migration();
  } catch(e) {
    console.error('[sync] sprint0Init error:', e);
  }
}

// ═══════════════════════════════════════════════
// Sprint 2 — Sync Engine
// ═══════════════════════════════════════════════

// ── Sync channel encryption ───────────────────────────────────────────────────
// Each device pair uses a deterministic syncKey derived from the shared ECDH
// private key + both device UUIDs (sorted for symmetry). Because both devices
// already share the same private key, they independently derive the same key —
// no need to transmit it, so no mismatch possible across multiple pairing attempts.

async function derivePairSyncKey(deviceId1, deviceId2) {
  const privRaw = await idbGet('frj_keypair_priv');
  if (!privRaw) throw new Error('No keypair');
  const ids  = [deviceId1, deviceId2].sort().join(':');
  const info = new TextEncoder().encode('frj-sync-v1:' + ids);
  const privBytes = new Uint8Array(privRaw instanceof ArrayBuffer ? privRaw : privRaw);
  // Use HMAC-SHA256 instead of raw concatenation (resistant to length-extension attacks)
  const hmacKey = await crypto.subtle.importKey('raw', privBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  const hash = await crypto.subtle.sign('HMAC', hmacKey, info);
  return btoa(String.fromCharCode(...new Uint8Array(hash)));
}

const _syncKeyCache = new Map(); // deviceId → CryptoKey

async function _getSyncCryptoKey(syncKeyB64) {
  if (_syncKeyCache.has(syncKeyB64)) return _syncKeyCache.get(syncKeyB64);
  const bytes = Uint8Array.from(atob(syncKeyB64), c => c.charCodeAt(0));
  const key = await crypto.subtle.importKey('raw', bytes, 'AES-GCM', false, ['encrypt', 'decrypt']);
  _syncKeyCache.set(syncKeyB64, key);
  return key;
}

// Chunk-safe btoa — avoids "Maximum call stack exceeded" when spreading large Uint8Arrays
function _u8ToB64(u8) {
  let s = '';
  const chunk = 0x8000; // 32KB — safe limit for String.fromCharCode.apply
  for (let i = 0; i < u8.length; i += chunk) {
    s += String.fromCharCode.apply(null, u8.subarray(i, i + chunk));
  }
  return btoa(s);
}

const _SYNC_REPLAY_WINDOW_MS = 300000; // 5 minutes — reject messages older than this

async function _syncEncrypt(obj, syncKeyB64) {
  const key = await _getSyncCryptoKey(syncKeyB64);
  const iv  = crypto.getRandomValues(new Uint8Array(12));
  // Embed timestamp for replay protection
  obj._syncTs = Date.now();
  const ct  = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(obj))
  );
  // Encode as base64 string for WS transport: iv_b64|ciphertext_b64
  return _u8ToB64(iv) + '|' + _u8ToB64(new Uint8Array(ct));
}

async function _syncDecrypt(payload, syncKeyB64) {
  const key = await _getSyncCryptoKey(syncKeyB64);
  const [ivB64, ctB64] = payload.split('|');
  if (!ivB64 || !ctB64) throw new Error('Malformed sync payload');
  const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
  const ct = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0));
  const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
  const obj = JSON.parse(new TextDecoder().decode(pt));
  // Replay protection: reject messages outside the acceptable window
  if (obj._syncTs && Math.abs(Date.now() - obj._syncTs) > _SYNC_REPLAY_WINDOW_MS) {
    throw new Error('Sync message rejected - timestamp outside replay window');
  }
  delete obj._syncTs; // strip internal field before returning
  return obj;
}

// ── Manifest ──────────────────────────────────────────────────────────────────
// Lightweight metadata-only snapshot of all local entries (no content).
function generateManifest() {
  return getEntries().map(e => ({
    id:        e.id,
    version:   e.version   || 0,
    updatedAt: e.updatedAt || 0,
    checksum:  e.checksum  || ''
  }));
}

// ── Diff ──────────────────────────────────────────────────────────────────────
// Compare local manifest against a remote manifest.
// Returns { need: [ids to request from remote], send: [local entries to push] }
function diffManifests(localManifest, remoteManifest) {
  const localMap = new Map(localManifest.map(e => [e.id, e]));
  const remoteMap = new Map(remoteManifest.map(e => [e.id, e]));
  const need = [];
  const sendIds = [];

  // Entries the remote has that we may need
  for (const [id, remote] of remoteMap) {
    const local = localMap.get(id);
    if (!local) {
      need.push(id); // we don't have it at all
    } else if (remote.checksum === local.checksum) {
      // identical — skip
    } else if (_winnerIsRemote(local, remote)) {
      need.push(id); // remote is newer
    }
  }

  // Entries we have that the remote may need
  for (const [id, local] of localMap) {
    const remote = remoteMap.get(id);
    if (!remote) {
      sendIds.push(id); // remote doesn't have it
    } else if (remote.checksum !== local.checksum && !_winnerIsRemote(local, remote)) {
      sendIds.push(id); // we are newer
    }
  }

  const allEntries = getEntries();
  const entryMap = new Map(allEntries.map(e => [e.id, e]));
  const send = sendIds.map(id => entryMap.get(id)).filter(Boolean);

  return { need, send };
}

// Conflict resolution: version first (monotonic), then updatedAt fallback, then deviceId
function _winnerIsRemote(local, remote) {
  if ((remote.version   || 0) !== (local.version   || 0)) return (remote.version   || 0) > (local.version   || 0);
  if ((remote.updatedAt || 0) !== (local.updatedAt || 0)) return (remote.updatedAt || 0) > (local.updatedAt || 0);
  return (remote.deviceId || '') > (local.deviceId || '');
}

// ── Validate checksum ─────────────────────────────────────────────────────────
async function validateChecksum(entry) {
  if (!entry || !entry.checksum) return false;
  const expected = await computeEntryChecksum(entry);
  return expected === entry.checksum;
}

// ── Deletion sync helpers ──────────────────────────────────────────────────────
// Apply a remote {id: timestamp} deleted-entries map.
// Returns true if any local entries were removed.
function _applyRemoteDeletes(remoteDeletedIds) {
  if (!remoteDeletedIds || typeof remoteDeletedIds !== 'object') return false;
  const ids = Object.keys(remoteDeletedIds);
  if (!ids.length) return false;

  const localDeleted = lsGet('frj_deleted', {});
  const entries = getEntries();
  let entriesChanged = false;
  let tombstonesChanged = false;

  const toKeep = entries.filter(e => {
    if (remoteDeletedIds[e.id] !== undefined) {
      // Remote deleted this entry — remove blobs and drop it
      if (e.imgFront) idbDel(e.imgFront);
      entriesChanged = true;
      return false;
    }
    return true;
  });

  // Merge tombstone records (even for entries we don't have locally)
  for (const [id, ts] of Object.entries(remoteDeletedIds)) {
    if (!localDeleted[id]) { localDeleted[id] = ts; tombstonesChanged = true; }
  }

  if (entriesChanged) saveEntries(toKeep);
  if (entriesChanged || tombstonesChanged) lsSet('frj_deleted', localDeleted);
  if (entriesChanged) console.log(`[sync] ← deleted ${ids.length} entr${ids.length === 1 ? 'y' : 'ies'} (remote tombstone)`);
  return entriesChanged;
}

// ── Merge incoming entries ────────────────────────────────────────────────────
// Applies an array of remote entries to local storage using conflict resolution.
// Returns count of entries actually written.
async function mergeEntries(incoming) {
  if (!incoming || !incoming.length) return 0;
  const local   = getEntries();
  const localMap = new Map(local.map(e => [e.id, e]));
  const localDeleted = lsGet('frj_deleted', {});
  let written = 0;
  let rejected = 0;

  for (const remote of incoming) {
    if (!remote || !remote.id) continue;

    // Never resurrect a locally-deleted entry
    if (localDeleted[remote.id]) continue;

    // Schema upgrade: backfill missing fields from older app versions
    if (typeof upgradeEntrySchema === 'function') upgradeEntrySchema(remote);

    // Integrity check — reject entries with invalid checksums (tamper detection)
    // Accept entries without checksums for backwards compatibility with older versions
    if (!remote.checksum) {
      remote.checksum = await computeEntryChecksum(remote);
    } else {
      const valid = await validateChecksum(remote);
      if (!valid) {
        console.warn(`[sync] checksum mismatch for entry ${remote.id} — rejecting (possible tampering)`);
        rejected++;
        continue;
      }
    }

    // Signature verification — proves entry came from a device with the identity
    if (remote.sig) {
      try {
        const identity = await getIdentity();
        if (identity) {
          const sigValid = await verifyEntrySignature(
            { id: remote.id, version: remote.version, updatedAt: remote.updatedAt, checksum: remote.checksum },
            remote.sig, identity.pubKeyB64
          );
          if (!sigValid) {
            console.warn(`[sync] signature verification failed for entry ${remote.id} — rejecting`);
            rejected++;
            continue;
          }
        }
      } catch(e) {
        console.warn(`[sync] signature verification error for entry ${remote.id}:`, e);
        rejected++;
        continue;
      }
    } else {
      console.warn(`[sync] unsigned entry ${remote.id} — accepting (backward compat)`);
    }

    const existing = localMap.get(remote.id);
    if (!existing || _winnerIsRemote(existing, remote)) {
      const toWrite = { ...remote };
      // Never drop a photo reference via sync — if remote has no imgFront but local does, preserve it
      if (!toWrite.imgFront && existing?.imgFront) {
        toWrite.imgFront = existing.imgFront;
        toWrite.updatedAt = Math.max(toWrite.updatedAt || 0, existing.updatedAt || 0);
        toWrite.checksum = await computeEntryChecksum(toWrite);
      }
      localMap.set(remote.id, toWrite);
      written++;
    }
  }

  console.log(`[sync] mergeEntries: ${incoming.length} received, ${written} written, ${rejected} rejected`);
  if (written) {
    // Re-read entries to catch any user edits that happened during async checksum operations.
    // Merge: user-modified entries (different updatedAt) take priority over our merge result.
    const freshLocal = getEntries();
    const freshMap   = new Map(freshLocal.map(e => [e.id, e]));
    for (const [id, fresh] of freshMap) {
      const merged = localMap.get(id);
      if (!merged) { localMap.set(id, fresh); continue; } // user added an entry during merge
      if ((fresh.updatedAt || 0) > (merged.updatedAt || 0)) localMap.set(id, fresh); // user edit wins
    }
    saveEntries([...localMap.values()]);
  }
  return written;
}

// ── Photo sync helpers (Sprint 4) ────────────────────────────────────────────
const _PHOTO_FIELDS = ['imgFront']; // only active photo field in the data model

// Blob → plain base64 (no data-URL prefix)
function _blobToB64(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload  = () => resolve(reader.result.split(',')[1]);
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

// Return IDB photo keys referenced by entries that don't exist locally
async function _getMissingPhotoKeys(entries) {
  // Fetch all existing IDB keys once instead of one lookup per photo
  const existing = new Set(await idbGetAllKeys().catch(() => []));
  const missing = new Set();
  for (const entry of (entries || [])) {
    for (const field of _PHOTO_FIELDS) {
      const key = entry[field];
      if (key && typeof key === 'string' && key.startsWith('img_') && !existing.has(key)) {
        missing.add(key);
      }
    }
  }
  return [...missing];
}

// Return IDB buddy photo keys (c_ prefix) that we don't have locally
async function _getMissingBuddyPhotoKeys() {
  const existing = new Set(await idbGetAllKeys().catch(() => []));
  const missing = new Set();
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  for (const c of contacts) {
    if (!c.journal?.entries) continue;
    if (!c.senderAllowsCache && !c.trusted) continue;
    const prefix = 'c_' + (c.pubKeyB64 || '').slice(0, 16) + '_';
    for (const e of c.journal.entries) {
      for (const field of ['hasImgFront', 'hasImgLeft', 'hasImgRight']) {
        if (e[field]) {
          const suffix = field.replace('hasImg', '').toLowerCase();
          const key = prefix + e.id + '_' + suffix;
          if (!existing.has(key)) missing.add(key);
        }
      }
    }
  }
  return [...missing];
}

// Send a photo_request to a peer asking for specific IDB keys
async function _requestPhotos(deviceId, keys, syncKey) {
  if (!keys.length) return;
  const enc = await _syncEncrypt({ type: 'photo_request', keys }, syncKey);
  p2pSendDeviceMsg(deviceId, enc);
  console.log(`[sync] → photo_request ${keys.length} key(s) to ${deviceId.slice(0,8)}…`);
}

// Compress a photo blob before sending over the signal relay.
// Target ≤120KB blob so the encrypted+base64 WS payload stays ≤220KB,
// safely under the signal server's 512KB hard limit.
// 600px JPEG 0.75 is conservative enough for even high-detail photos.
function _compressForSync(blob) {
  if (!(blob instanceof Blob)) return Promise.resolve(blob);
  return new Promise(resolve => {
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      const maxDim = 600, quality = 0.75;
      const scale = Math.min(1, maxDim / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
      const w = Math.round((img.naturalWidth || 1) * scale);
      const h = Math.round((img.naturalHeight || 1) * scale);
      const c = document.createElement('canvas');
      c.width = w; c.height = h;
      c.getContext('2d').drawImage(img, 0, 0, w, h);
      c.toBlob(b => resolve(b || blob), 'image/jpeg', quality);
    };
    img.onerror = () => { URL.revokeObjectURL(url); resolve(blob); };
    img.src = url;
  });
}

// Respond to a photo_request — compress + encrypt in parallel, then send
async function _handlePhotoRequest(fromDeviceId, keys, syncKey) {
  const capped = (keys || []).slice(0, 100);
  if (!capped.length) return;

  // Fetch + compress + encrypt all photos concurrently (IDB reads are non-blocking)
  const results = await Promise.all(capped.map(async key => {
    let blob;
    try { blob = await idbGet(key); } catch(e) { return null; }
    if (!(blob instanceof Blob)) return null;
    try { blob = await _compressForSync(blob); } catch(e) { /* use original */ }
    let b64;
    try { b64 = await _blobToB64(blob); } catch(e) { return null; }
    let enc;
    try { enc = await _syncEncrypt({ type: 'photo_deliver', key, data: b64 }, syncKey); } catch(e) { return null; }
    return { key, enc, approxKB: Math.round(b64.length * 0.75 / 1024) };
  }));

  let sent = 0, missing = 0;
  for (const r of results) {
    if (!r) { missing++; continue; }
    const ok = p2pSendDeviceMsg(fromDeviceId, r.enc);
    if (ok) { sent++; console.log(`[sync] → photo_deliver ${r.key} (~${r.approxKB}KB) to ${fromDeviceId.slice(0,8)}…`); }
  }
  console.log(`[sync] photo_deliver summary: ${sent} sent, ${missing} not found, ${capped.length} requested`);
}

// Store an incoming photo blob in IDB and update the in-memory URL cache
async function _handlePhotoDeliver(key, b64) {
  if (!key || !b64) return;
  try {
    const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
    const blob  = new Blob([bytes], { type: 'image/jpeg' });
    await idbPut(key, blob);
    // Update imgCache so the UI sees the photo without a reload
    if (typeof imgCache !== 'undefined') {
      if (imgCache[key]) URL.revokeObjectURL(imgCache[key]);
      imgCache[key] = URL.createObjectURL(blob);
    }
    console.log(`[sync] ← photo stored: ${key}`);
    // Re-render any live canvases that are waiting for this key
    if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(key);
    // Refresh any UI that renders this specific key, then re-render dashboard/journal
    if (typeof refreshImg === 'function') refreshImg(key);
    // Debounced full re-render so rapid photo deliveries don't hammer the DOM
    clearTimeout(_photoDeliverRenderTimer);
    _photoDeliverRenderTimer = setTimeout(() => {
      if (typeof renderDash === 'function') renderDash();
      if (typeof renderEntryList === 'function') renderEntryList();
      if (key.startsWith('gal_') && typeof renderGallery === 'function') renderGallery();
      if ((key.startsWith('chatimg_') || key.startsWith('quickpic_')) && typeof refreshChatPopup === 'function') {
        // Re-render open chat views so the photo replaces the "Loading..." placeholder
        if (typeof _chatPopupKey !== 'undefined' && _chatPopupKey) refreshChatPopup(_chatPopupKey);
        if (typeof refreshMessenger === 'function') refreshMessenger();
      }
    }, 800);
  } catch(e) {
    console.warn('[sync] photo_deliver store error:', e);
  }
}
let _photoDeliverRenderTimer = null;

// ── Profile sync helpers ──────────────────────────────────────────────────────

function _getSyncProfile() {
  const p = typeof getProfile === 'function' ? getProfile() : {};
  const displayName = typeof getDisplayNameSync === 'function' ? getDisplayNameSync() : '';
  return { ...p, displayName: displayName || p.displayName || '', updatedAt: p.updatedAt || 0 };
}

// Returns true if local profile was updated
const _PROFILE_FIELDS = ['startingCI', 'goalCI', 'startDate', 'ageCircumcised', 'ageRange',
  'circumStyle', 'circumMethod', 'fullyRestoredOn', 'notes', 'avatar'];

function _isDefaultProfile(p) {
  const name = (p.displayName || '').trim();
  const isDefaultName = !name || name === 'Restorer';
  const hasAnyField = _PROFILE_FIELDS.some(f => p[f] != null && p[f] !== '');
  return isDefaultName && !hasAnyField;
}

function _mergeProfile(remote) {
  if (!remote || typeof getProfile !== 'function' || typeof saveProfile !== 'function') return false;
  const local = _getSyncProfile();

  // Prefer whichever side has real (non-default) data, regardless of timestamp.
  const localDefault = _isDefaultProfile(local);
  const remoteDefault = _isDefaultProfile(remote);
  if (!localDefault && remoteDefault) return false;  // keep local custom profile

  let accept;
  if (localDefault && !remoteDefault) {
    accept = true; // accept remote custom profile regardless of timestamp
  } else {
    // Both custom or both default — fall back to LWW
    if ((remote.updatedAt || 0) < (local.updatedAt || 0)) return false;
    if ((remote.updatedAt || 0) > 0 && (remote.updatedAt || 0) === (local.updatedAt || 0)) return false;
    accept = true;
  }
  if (!accept) return false;

  // Field merge: remote wins for fields it has, local keeps fields remote doesn't
  const merged = { ...local };
  for (const key of Object.keys(remote)) {
    if (key === 'displayName') continue; // handled separately below
    if (remote[key] != null && remote[key] !== '') merged[key] = remote[key];
  }
  // Stamp a non-zero timestamp so future syncs don't repeatedly re-merge
  if (!merged.updatedAt) merged.updatedAt = Date.now();
  else merged.updatedAt = Math.max(merged.updatedAt, remote.updatedAt || 0);
  delete merged.displayName;
  saveProfile(merged);
  // Display name: a real name always wins over default, regardless of timestamp
  const remoteName = (remote.displayName || '').trim();
  const localName = (typeof lsGet === 'function' ? lsGet('frj_displayname', '') : '').trim();
  const localIsDefault = !localName || localName === 'Restorer';
  const remoteIsDefault = !remoteName || remoteName === 'Restorer';
  if (!remoteIsDefault && localIsDefault && typeof setDisplayName === 'function') {
    setDisplayName(remoteName);
  } else if (!remoteIsDefault && !localIsDefault && (remote.updatedAt || 0) > (local.updatedAt || 0) && typeof setDisplayName === 'function') {
    // Both custom — LWW
    setDisplayName(remoteName);
  }
  console.log(`[sync] ← profile merged (remote updatedAt=${remote.updatedAt})`);
  return true;
}

// ── Settings sync helpers ─────────────────────────────────────────────────────
// ── Timer sessions sync helpers ──────────────────────────────────────────────
// Sessions are identified by UUID, so we do a union merge (add any session IDs
// we don't have locally). Active timer is per-device, not synced — but stopping
// from another device should be possible via a stop command.

function recordSessionDeletion(id) {
  const deleted = lsGet('frj_deleted_sessions', {});
  deleted[id] = Date.now();
  lsSet('frj_deleted_sessions', deleted);
}

function _getSyncTimerData() {
  return {
    sessions: typeof getTimerSessions === 'function' ? getTimerSessions() : [],
    deletedSessionIds: lsGet('frj_deleted_sessions', {}),
    // Send the raw timer state (including stop markers) for LWW
    activeTimer: typeof getActiveTimerRaw === 'function' ? getActiveTimerRaw() : null,
    deviceSel: (() => { try { return JSON.parse(localStorage.getItem('frj_timer_device_sel') || '{}'); } catch(e) { return {}; } })()
  };
}

// ── LWW timer state resolution ──────────────────────────────────────────────
// Compares changedAt timestamps and applies the newest state.
// Returns true if local state was changed.
function _applyRemoteTimerState(remoteTimer) {
  const localRaw = typeof getActiveTimerRaw === 'function' ? getActiveTimerRaw() : null;
  const localTs  = (localRaw && localRaw.changedAt) || 0;
  const remoteTs = (remoteTimer && remoteTimer.changedAt) || 0;

  // Remote is not newer — keep local state
  if (remoteTs <= localTs) return false;

  // Remote is newer — adopt it
  if (remoteTimer && !remoteTimer.stopped) {
    // Remote has a running timer — adopt it
    // But first, if we have a different running timer, save it as a session
    const localActive = typeof getActiveTimer === 'function' ? getActiveTimer() : null;
    if (localActive && localActive.id !== remoteTimer.id) {
      // Stop local timer silently (save session for it)
      const now = Date.now();
      const dur = now - localActive.startedAt;
      if (dur > 5000) {
        const sessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
        if (!sessions.some(s => s.id === localActive.id)) {
          sessions.push({
            id: localActive.id, type: localActive.type,
            startedAt: localActive.startedAt, endedAt: now,
            durationMs: dur, note: '', deviceId: localActive.deviceId
          });
          if (typeof saveTimerSessions === 'function') saveTimerSessions(sessions);
        }
      }
    }
    setActiveTimer(remoteTimer);
    if (typeof _startTimerTick === 'function') _startTimerTick();
  } else {
    // Remote stopped — apply stop marker
    setActiveTimer(remoteTimer || { stopped: true, changedAt: remoteTs });
    if (typeof _timerInterval !== 'undefined' && _timerInterval) {
      clearInterval(_timerInterval);
      _timerInterval = null;
    }
  }
  if (typeof _renderDashTimerRemote === 'function') _renderDashTimerRemote(null);
  if (typeof _renderDashTimer === 'function') _renderDashTimer();
  if (typeof _timerRemoteFlash === 'function') _timerRemoteFlash();
  return true;
}

// Handle a real-time device_activity broadcast from a paired device.
function _applyRemoteTimerActivity(activity, session) {
  // If the broadcast includes a completed session, merge it immediately
  if (session && session.id) {
    const sessions = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
    if (!sessions.some(s => s.id === session.id)) {
      sessions.push(session);
      if (typeof saveTimerSessions === 'function') saveTimerSessions(sessions);
    }
  }
  // Sync device selection from remote device (direct set, not toggle)
  if (activity && !activity.stopped && activity.type && activity.device !== undefined) {
    try {
      const sel = JSON.parse(localStorage.getItem('frj_timer_device_sel') || '{}');
      const key = activity.type === 'tugging' ? 'tug' : 'ret';
      sel[key] = activity.device || null;
      localStorage.setItem('frj_timer_device_sel', JSON.stringify(sel));
    } catch(e) {}
  }
  _applyRemoteTimerState(activity);
}

function _mergeTimerData(remote) {
  if (!remote || !Array.isArray(remote.sessions)) return false;
  const local = typeof getTimerSessions === 'function' ? getTimerSessions() : [];
  const localIds = new Set(local.map(s => s.id));
  let changed = false;

  // ── Apply remote session deletions (tombstones) ──
  const remoteDeleted = remote.deletedSessionIds || {};
  const localDeleted  = lsGet('frj_deleted_sessions', {});
  let tombstonesChanged = false;

  // Remove locally any sessions the remote has deleted
  const beforeLen = local.length;
  const filtered = local.filter(s => !remoteDeleted[s.id]);
  if (filtered.length < beforeLen) {
    changed = true;
    local.length = 0;
    local.push(...filtered);
    localIds.clear();
    for (const s of local) localIds.add(s.id);
    console.log(`[sync] ← deleted ${beforeLen - filtered.length} session(s) (remote tombstone)`);
  }

  // Merge remote tombstone records
  for (const [id, ts] of Object.entries(remoteDeleted)) {
    if (!localDeleted[id]) { localDeleted[id] = ts; tombstonesChanged = true; }
  }
  if (tombstonesChanged) lsSet('frj_deleted_sessions', localDeleted);

  // ── Merge remote sessions (skip tombstoned ones) ──
  for (const rs of remote.sessions) {
    if (!rs.id) continue;
    if (localDeleted[rs.id]) continue; // locally deleted — don't resurrect
    if (localIds.has(rs.id)) {
      // Field-level merge: take the newer endedAt and merge notes independently
      const li = local.findIndex(s => s.id === rs.id);
      if (li >= 0) {
        let updated = false;
        const merged = { ...local[li] };
        if ((rs.endedAt || 0) > (merged.endedAt || 0)) {
          merged.endedAt = rs.endedAt;
          merged.durationMs = rs.durationMs || merged.durationMs;
          updated = true;
        }
        if (rs.note && rs.note !== merged.note &&
            (rs.noteUpdatedAt || 0) > (merged.noteUpdatedAt || 0)) {
          merged.note = rs.note;
          merged.noteUpdatedAt = rs.noteUpdatedAt;
          updated = true;
        }
        if (updated) { local[li] = merged; changed = true; }
      }
      continue;
    }
    local.push(rs);
    changed = true;
  }
  if (changed) {
    local.sort((a, b) => b.startedAt - a.startedAt);
    if (typeof saveTimerSessions === 'function') saveTimerSessions(local);
  }
  // LWW resolution for active timer state
  if (_applyRemoteTimerState(remote.activeTimer)) changed = true;
  // Sync device selection — adopt remote if we have an active timer
  if (remote.deviceSel && typeof remote.deviceSel === 'object') {
    try {
      const local = JSON.parse(localStorage.getItem('frj_timer_device_sel') || '{}');
      let selChanged = false;
      for (const key of ['tug', 'ret']) {
        if (remote.deviceSel[key] !== undefined && remote.deviceSel[key] !== local[key]) {
          local[key] = remote.deviceSel[key];
          selChanged = true;
        }
      }
      if (selChanged) { localStorage.setItem('frj_timer_device_sel', JSON.stringify(local)); changed = true; }
    } catch(e) {}
  }
  return changed;
}

// Stamp a timestamp any time app settings change so LWW can work across devices.
function stampSettingsTs() {
  lsSet('frj_settings_updatedAt', Date.now());
}

async function _getSyncSettings() {
  let encEnabled = null, encSetupDone = null;
  try {
    if (typeof idbMetaGet === 'function') {
      encEnabled   = await idbMetaGet('encryptionEnabled');
      encSetupDone = await idbMetaGet('encryptionSetupDone');
    }
  } catch(e) { /* non-critical — don't block sync */ }
  return {
    vanish:            typeof getVanishSettings    === 'function' ? getVanishSettings()    : null,
    watermark:         typeof getWatermarkSettings === 'function' ? getWatermarkSettings() : null,
    buddyPermissions:  lsGet('frj_buddy_permissions', null),
    publicProfile:     lsGet('frj_public_profile', null),
    blockedHashes:     lsGet('frj_blocked_hashes', null),
    buddiesEnabled:    localStorage.getItem('frj_buddies_enabled'),
    publicEnabled:     localStorage.getItem('frj_public_enabled'),
    wsZones:           typeof getWsZones    === 'function' ? getWsZones()    : null,
    wsSettings:        typeof getWsSettings === 'function' ? getWsSettings() : null,
    encryptionOn:      !!(encEnabled && encSetupDone),
    updatedAt:         lsGet('frj_settings_updatedAt', 0)
  };
}

function _mergeSettings(remote) {
  if (!remote) return false;
  const localTs = lsGet('frj_settings_updatedAt', 0);
  if ((remote.updatedAt || 0) <= localTs) return false;
  if (remote.vanish    && typeof saveVanishSettings    === 'function') saveVanishSettings(remote.vanish);
  if (remote.watermark && typeof saveWatermarkSettings === 'function') saveWatermarkSettings(remote.watermark);
  if (remote.buddyPermissions != null) lsSet('frj_buddy_permissions', remote.buddyPermissions);
  if (remote.publicProfile   != null) lsSet('frj_public_profile',    remote.publicProfile);
  if (remote.blockedHashes   != null) lsSet('frj_blocked_hashes',    remote.blockedHashes);
  // Sync buddy/public toggle (network toggle is per-device, buddy/public toggle is shared)
  if (remote.buddiesEnabled  != null) {
    const oldBuddies = localStorage.getItem('frj_buddies_enabled');
    const oldPublic  = localStorage.getItem('frj_public_enabled');
    localStorage.setItem('frj_buddies_enabled', remote.buddiesEnabled);
    if (remote.publicEnabled != null) localStorage.setItem('frj_public_enabled', remote.publicEnabled);
    // Apply presence state change if this device is online
    if ((remote.buddiesEnabled !== oldBuddies || remote.publicEnabled !== oldPublic)
        && typeof getNetworkToggles === 'function' && typeof setPresenceState === 'function') {
      const t = getNetworkToggles();
      if (t.network) {
        const desired = t.buddies && t.public ? 'public' : t.buddies ? 'private' : 'offline';
        setPresenceState(desired);
      } else if (remote.buddiesEnabled === '0') {
        // Buddy network turned off on remote — deregister presence locally
        if (typeof p2pDeregisterPresenceOnly === 'function') p2pDeregisterPresenceOnly();
      }
    }
    if (typeof updateNetworkToggleUI === 'function') updateNetworkToggleUI();
  }
  if (remote.wsZones    != null && typeof saveWsZones    === 'function') saveWsZones(remote.wsZones);
  if (remote.wsSettings != null && typeof saveWsSettings === 'function') saveWsSettings(remote.wsSettings);
  // If the paired device has encryption enabled but we don't, flag it so the UI can prompt
  if (remote.encryptionOn) lsSet('frj_peer_uses_encryption', true);
  lsSet('frj_settings_updatedAt', remote.updatedAt);
  console.log(`[sync] ← settings merged (remote updatedAt=${remote.updatedAt})`);

  // Re-broadcast updated permissions to connected buddies so they see changes immediately.
  // This covers the case: Phone changes permissions → syncs to Computer → Computer tells buddies.
  if (remote.buddyPermissions != null && typeof p2pBroadcastSettingsPush === 'function') {
    p2pBroadcastSettingsPush();
  }
  return true;
}

// ── Contacts sync helpers ─────────────────────────────────────────────────────
// Only sync address-book fields — never cached journal data or ephemeral state.

function _getSyncContacts() {
  const TOMBSTONE_TTL = 30 * 86400000; // keep tombstones 30 days for sync propagation
  const now = Date.now();
  const all = getContacts();
  // Prune tombstones older than 30 days
  const pruned = all.filter(c => !c.removedAt || (now - c.removedAt) < TOMBSTONE_TTL);
  if (pruned.length !== all.length) saveContacts(pruned);
  return pruned.map(c => {
    if (c.removedAt || c.forgottenAt) return { pubKeyB64: c.pubKeyB64, removed: true };
    const out = {
      pubKeyB64: c.pubKeyB64,
      nickname:  c.nickname  || '',
      trusted:   !!c.trusted,
      updatedAt: c.updatedAt || 0
    };
    if (c.pendingRemoval) {
      out.pendingRemoval    = true;
      out.pendingRemovalAt  = c.pendingRemovalAt || now;
      out.pendingRemovalNonce = c.pendingRemovalNonce || null;
    }
    // Include buddy journal + scope metadata so paired devices get buddy data
    if (c.journal && c.journal.entries && c.journal.entries.length) {
      out.journal = {
        entries: c.journal.entries.map(e => {
          const s = { ...e };
          delete s._raw;
          return s;
        }),
        displayName: c.journal.displayName || '',
        avatar:      c.journal.avatar || null
      };
      out.senderTrusts      = !!c.senderTrusts;
      out.senderAllowsCache = !!c.senderAllowsCache;
      out.senderCacheScope  = c.senderCacheScope || 'none';
      out.senderViewScope   = c.senderViewScope  || 'none';
      out.lastSeen          = c.lastSeen || null;
    }
    return out;
  });
}

// Merge remote contact list into local. Additive + LWW trust/nickname per contact.
// Also propagates pendingRemoval and full removal between paired devices.
function _mergeContacts(remoteList) {
  if (!Array.isArray(remoteList) || !remoteList.length) return false;
  const local    = getContacts();
  const localMap = new Map(local.map(c => [c.pubKeyB64, c]));
  let changed    = false;

  // Build set of remote pubKeys to detect removals
  const remotePubKeys = new Set(remoteList.map(r => r.pubKeyB64).filter(Boolean));

  for (const remote of remoteList) {
    if (!remote.pubKeyB64) continue;

    // Remote fully removed this contact — remove locally too
    if (remote.removed) {
      const existing = localMap.get(remote.pubKeyB64);
      if (existing && !existing.forgottenAt && !existing.pendingRemoval && !existing.removedAt) {
        // Send forget_me from this device too (we may have an active WS the phone didn't)
        if (typeof p2pSendForgetMe === 'function') p2pSendForgetMe(remote.pubKeyB64);
        if (typeof p2pRemoveContact === 'function') p2pRemoveContact(remote.pubKeyB64);
        if (typeof clearContactCachedData === 'function') clearContactCachedData(remote.pubKeyB64);
        // Leave tombstone so this device can also propagate the removal to further paired devices
        localMap.set(remote.pubKeyB64, { pubKeyB64: remote.pubKeyB64, removedAt: Date.now() });
        changed = true;
      }
      continue;
    }

    // Remote marked pendingRemoval — apply locally
    if (remote.pendingRemoval) {
      const existing = localMap.get(remote.pubKeyB64);
      if (existing && !existing.pendingRemoval) {
        existing.pendingRemoval = true;
        existing.pendingRemovalAt = remote.pendingRemovalAt || Date.now();
        existing.pendingRemovalNonce = remote.pendingRemovalNonce || null;
        // Try to send forget_relay from this device
        if (typeof p2pSendForgetMe === 'function') p2pSendForgetMe(remote.pubKeyB64);
        if (typeof clearContactCachedData === 'function') clearContactCachedData(remote.pubKeyB64);
        changed = true;
      }
      continue;
    }

    const existing = localMap.get(remote.pubKeyB64);
    // Local contact is removed/pending removal — don't let a non-removed remote version resurrect it
    if (existing && (existing.removedAt || existing.pendingRemoval || existing.forgottenAt)) continue;
    if (!existing) {
      // Don't re-add blocked contacts
      const _blockedHashes = typeof getBlockedHashes === 'function' ? getBlockedHashes() : [];
      const _remoteSl16 = remote.pubKeyB64.slice(0, 16);
      if (_blockedHashes.some(h => h === _remoteSl16 || remote.pubKeyB64.startsWith(h))) continue;
      // Contact added on remote — add with journal + scope if available
      const newContact = {
        pubKeyB64: remote.pubKeyB64,
        nickname:  remote.nickname || ('Buddy-' + remote.pubKeyB64.slice(0, 6)),
        trusted:   remote.trusted || false,
        lastSeen:  remote.lastSeen || null,
        journal:   remote.journal || null,
        updatedAt: remote.updatedAt || 0
      };
      if (remote.journal) {
        newContact.senderTrusts      = !!remote.senderTrusts;
        newContact.senderAllowsCache = !!remote.senderAllowsCache;
        newContact.senderCacheScope  = remote.senderCacheScope || 'none';
        newContact.senderViewScope   = remote.senderViewScope  || 'none';
      }
      localMap.set(remote.pubKeyB64, newContact);
      changed = true;
    } else {
      // Existing contact — LWW for trust/nickname
      if ((remote.updatedAt || 0) > (existing.updatedAt || 0)) {
        existing.trusted  = remote.trusted;
        existing.nickname = remote.nickname || existing.nickname;
        existing.updatedAt = remote.updatedAt;
        changed = true;
      }
      // Merge buddy journal entries (LWW per entry by updatedAt)
      if (remote.journal && remote.journal.entries && remote.journal.entries.length) {
        if (!existing.journal) existing.journal = { entries: [], displayName: '', avatar: null };
        const localEntryMap = new Map((existing.journal.entries || []).map(e => [e.id, e]));
        for (const re of remote.journal.entries) {
          if (!re.id) continue;
          const le = localEntryMap.get(re.id);
          if (!le || (re.updatedAt || 0) > (le.updatedAt || 0)) {
            localEntryMap.set(re.id, re);
            changed = true;
          }
        }
        existing.journal.entries = [...localEntryMap.values()];
        if (remote.journal.displayName) existing.journal.displayName = remote.journal.displayName;
        if (remote.journal.avatar != null) existing.journal.avatar = remote.journal.avatar;
        // Update scope metadata
        existing.senderTrusts      = !!remote.senderTrusts;
        existing.senderAllowsCache = !!remote.senderAllowsCache;
        existing.senderCacheScope  = remote.senderCacheScope || existing.senderCacheScope || 'none';
        existing.senderViewScope   = remote.senderViewScope  || existing.senderViewScope  || 'none';
        if (remote.lastSeen) existing.lastSeen = remote.lastSeen;
      }
    }
  }

  if (changed) {
    saveContacts([...localMap.values()]);
    console.log('[sync] ← contacts merged');
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
  }
  return changed;
}

// ── Chat sync helpers ─────────────────────────────────────────────────────────
// Only delta since lastSyncedAt, capped at 100 msgs per contact to keep payload small.
function _getSyncChatData(sinceTs) {
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const result = {};
  const cutoff = sinceTs || (Date.now() - 7 * 86400000); // fallback: last 7 days
  for (const c of contacts) {
    if (!c.pubKeyB64) continue;
    const msgs = getChatHistory(c.pubKeyB64).filter(m => {
      const t = m.ts ? new Date(m.ts).getTime() : 0;
      return t > cutoff;
    }).slice(-100);
    if (msgs.length) result[c.pubKeyB64] = msgs;
  }
  // Include unread counts — always include all buddies (even 0) so read-receipts propagate
  const unread = {};
  for (const c of contacts) {
    if (!c.pubKeyB64) continue;
    const n = typeof getChatUnread === 'function' ? getChatUnread(c.pubKeyB64) : 0;
    unread[c.pubKeyB64] = n;
  }
  result._unread = unread;
  return result;
}

function _mergeChatData(remote) {
  if (!remote || typeof remote !== 'object') return false;
  let changed = false;

  // Merge unread counts: only propagate READ state (remote=0 clears local).
  // Never increase unread via sync — each device handles its own incoming notifications.
  if (remote._unread !== undefined && typeof getChatUnread === 'function' && typeof lsSet === 'function') {
    const remoteUnread = remote._unread || {};
    const contacts = typeof getContacts === 'function' ? getContacts() : [];
    for (const c of contacts) {
      if (!c.pubKeyB64) continue;
      const local = getChatUnread(c.pubKeyB64);
      const remoteCount = remoteUnread[c.pubKeyB64] || 0;
      if (remoteCount === 0 && local > 0) {
        // User read messages on the other device — clear local
        lsSet('frj_chat_unread_' + c.pubKeyB64.slice(0, 16), 0);
        changed = true;
      }
      // Don't adopt higher remote counts — that causes phantom notifications
    }
  }

  for (const [pubKeyB64, msgs] of Object.entries(remote)) {
    if (pubKeyB64 === '_unread') continue; // handled above
    if (!Array.isArray(msgs) || !msgs.length) continue;
    const hist = getChatHistory(pubKeyB64);
    const localMap = new Map(hist.filter(m => m.id).map(m => [m.id, m]));
    let modified = false;
    for (const msg of msgs) {
      if (!msg.id) continue;
      if (typeof msg.text !== 'string' || msg.text.length > 2000) continue;
      if (!['me', 'them'].includes(msg.from)) continue;
      const existing = localMap.get(msg.id);
      if (existing) {
        // Merge delivery status — if remote says delivered and local doesn't, update
        if (msg.delivered && !existing.delivered) {
          existing.delivered = true;
          modified = true;
          changed = true;
        }
      } else {
        hist.push(msg);
        modified = true;
        changed = true;
      }
    }
    if (modified) {
      hist.sort((a, b) => (a.ts || '') < (b.ts || '') ? -1 : 1);
      saveChatHistory(pubKeyB64, hist.slice(-500));
      if (typeof refreshChatPopup === 'function') refreshChatPopup(pubKeyB64);
      if (typeof refreshMessenger === 'function') refreshMessenger(pubKeyB64);
    }
  }
  if (changed && typeof updatePeerStatus === 'function') updatePeerStatus();
  return changed;
}

// ── Gallery sync helpers ──────────────────────────────────────────────────────
// Galleries sync metadata only via the main sync messages; blobs use photo_request/deliver.
function _getSyncGalleries() {
  return typeof getGalleries === 'function' ? getGalleries() : [];
}

// Merge galleries: add remote galleries/items we don't have locally, respecting deletions.
function _mergeGalleries(remote) {
  if (!Array.isArray(remote) || !remote.length) return false;
  if (typeof getGalleries !== 'function' || typeof saveGalleries !== 'function') return false;
  const local = getGalleries();
  const localMap = new Map(local.map(g => [g.id, g]));
  // Load deletion tracker so we don't resurrect deleted galleries/items
  const _del = typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] };
  const _delGalIds = new Set((_del.galleries || []).map(d => d.galleryId));
  const _delItemKeys = new Set((_del.items || []).map(d => `${d.galleryId}:${d.itemId}`));
  let changed = false;
  for (const rg of remote) {
    if (!rg || !rg.id) continue;
    // Skip galleries we explicitly deleted
    if (_delGalIds.has(rg.id)) continue;
    const lg = localMap.get(rg.id);
    if (!lg) {
      // Filter out items we explicitly deleted before adding the gallery
      if (rg.items) rg.items = rg.items.filter(i => !_delItemKeys.has(`${rg.id}:${i.id}`));
      local.push(rg);
      changed = true;
    } else {
      // Sync gallery metadata: title, permissions, updatedAt — LWW by createdAt or updatedAt
      const rgTs = rg.updatedAt || rg.createdAt || '';
      const lgTs = lg.updatedAt || lg.createdAt || '';
      if (rgTs > lgTs) {
        lg.title = rg.title || lg.title;
        lg.permissions = rg.permissions || lg.permissions;
        if (rg.updatedAt) lg.updatedAt = rg.updatedAt;
        changed = true;
      }
      // Union items by item.id — add remote items we don't have, LWW update existing
      const localItemMap = new Map((lg.items || []).map(i => [i.id, i]));
      for (const ri of (rg.items || [])) {
        if (!ri.id) continue;
        // Skip items we explicitly deleted
        if (_delItemKeys.has(`${rg.id}:${ri.id}`)) continue;
        const li = localItemMap.get(ri.id);
        if (!li) {
          lg.items.push(ri);
          changed = true;
        } else if ((ri.updatedAt || ri.addedAt || '') > (li.updatedAt || li.addedAt || '')) {
          // Update existing item metadata (title, note) if remote is newer
          li.title = ri.title ?? li.title;
          li.note = ri.note ?? li.note;
          li.updatedAt = ri.updatedAt || li.updatedAt;
          changed = true;
        }
      }
    }
  }
  if (changed) {
    saveGalleries(local);
    console.log('[sync] ← galleries merged');
  }
  return changed;
}

// IDB keys referenced by own gallery items that we don't have locally
async function _getMissingGalleryPhotoKeys() {
  const existing = new Set(await idbGetAllKeys().catch(() => []));
  const missing = new Set();
  const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
  for (const g of galleries) {
    for (const item of (g.items || [])) {
      if (item.imgKey && item.imgKey.startsWith('img_') && !existing.has(item.imgKey)) {
        missing.add(item.imgKey);
      }
    }
  }
  return [...missing];
}

// ── Gallery extended sync helpers ──────────────────────────────────────────────
// Received galleries, notes, KOTs, revoke queue, left-list — all sync between paired devices.

function _getSyncGalleryExtended() {
  return {
    received:   typeof getGalleryReceived   === 'function' ? getGalleryReceived()   : {},
    buddyNotes: typeof getGalleryBuddyNotes === 'function' ? getGalleryBuddyNotes() : {},
    kots:       typeof getGalleryKots       === 'function' ? getGalleryKots()       : {},
    kotsSent:   typeof getGalleryKotsSent   === 'function' ? getGalleryKotsSent()   : {},
    notesSent:  typeof getGallerySentNotes  === 'function' ? getGallerySentNotes()  : {},
    revokeQ:    typeof getGalleryRevokeQueue === 'function' ? getGalleryRevokeQueue() : {},
    left:       typeof getGalleryLeft       === 'function' ? getGalleryLeft()       : {},
    notes:      typeof getGalleryNotes      === 'function' ? getGalleryNotes()      : {},
    bookmarks:  typeof getBookmarks === 'function' ? getBookmarks() : {},
    bookmarksRcvd: typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {}
  };
}

function _mergeGalleryExtended(remote) {
  if (!remote || typeof remote !== 'object') return false;
  let changed = false;

  // 1. Received galleries — union by sender + galleryId, LWW per item by updatedAt
  if (remote.received && typeof getGalleryReceived === 'function' && typeof saveGalleryReceived === 'function') {
    const local = getGalleryReceived();
    for (const [sender, galleries] of Object.entries(remote.received)) {
      if (!local[sender]) { local[sender] = galleries; changed = true; continue; }
      for (const [galId, rGal] of Object.entries(galleries)) {
        if (!local[sender][galId]) { local[sender][galId] = rGal; changed = true; continue; }
        const lGal = local[sender][galId];
        if (rGal.title) lGal.title = rGal.title;
        if (rGal.senderPubKey) lGal.senderPubKey = rGal.senderPubKey;
        if (!lGal.items) lGal.items = {};
        for (const [itemId, rItem] of Object.entries(rGal.items || {})) {
          if (!lGal.items[itemId] || (rItem.updatedAt || 0) > (lGal.items[itemId].updatedAt || 0)) {
            lGal.items[itemId] = rItem;
            changed = true;
          }
        }
      }
    }
    if (changed) saveGalleryReceived(local);
  }

  // 2. Buddy notes — union by (senderSlice16, sentAt) per gallery+item
  if (remote.buddyNotes && typeof getGalleryBuddyNotes === 'function' && typeof saveGalleryBuddyNotes === 'function') {
    const local = getGalleryBuddyNotes();
    let notesChanged = false;
    for (const [galId, items] of Object.entries(remote.buddyNotes)) {
      if (!local[galId]) { local[galId] = items; notesChanged = true; continue; }
      for (const [itemId, notes] of Object.entries(items)) {
        if (!local[galId][itemId]) { local[galId][itemId] = notes; notesChanged = true; continue; }
        const existing = new Set(local[galId][itemId].map(n => n.senderSlice16 + ':' + n.sentAt));
        for (const n of notes) {
          if (!existing.has(n.senderSlice16 + ':' + n.sentAt)) {
            local[galId][itemId].push(n);
            notesChanged = true;
          }
        }
      }
    }
    if (notesChanged) { saveGalleryBuddyNotes(local); changed = true; }
  }

  // 3. KOTs received — union by (senderSlice16, timestamp) per compositeKey
  if (remote.kots && typeof getGalleryKots === 'function' && typeof saveGalleryKots === 'function') {
    const local = getGalleryKots();
    let kotsChanged = false;
    for (const [key, entries] of Object.entries(remote.kots)) {
      if (!local[key]) { local[key] = entries; kotsChanged = true; continue; }
      const existing = new Set(local[key].map(k => k.senderSlice16 + ':' + k.timestamp));
      for (const k of entries) {
        if (!existing.has(k.senderSlice16 + ':' + k.timestamp)) {
          local[key].push(k);
          kotsChanged = true;
        }
      }
    }
    if (kotsChanged) { saveGalleryKots(local); changed = true; }
  }

  // 4. KOTs sent — union (idempotent records)
  if (remote.kotsSent && typeof getGalleryKotsSent === 'function' && typeof saveGalleryKotsSent === 'function') {
    const local = getGalleryKotsSent();
    let ksChanged = false;
    for (const [key, val] of Object.entries(remote.kotsSent)) {
      if (!local[key]) { local[key] = val; ksChanged = true; }
    }
    if (ksChanged) { saveGalleryKotsSent(local); changed = true; }
  }

  // 5. Notes sent — union (idempotent records)
  if (remote.notesSent && typeof getGallerySentNotes === 'function' && typeof saveGallerySentNotes === 'function') {
    const local = getGallerySentNotes();
    let nsChanged = false;
    for (const [key, val] of Object.entries(remote.notesSent)) {
      if (!local[key] || (val.sentAt || 0) > (local[key].sentAt || 0)) {
        local[key] = val; nsChanged = true;
      }
    }
    if (nsChanged) { saveGallerySentNotes(local); changed = true; }
  }

  // 6. Revoke queue — union + coalesce
  if (remote.revokeQ && typeof getGalleryRevokeQueue === 'function' && typeof saveGalleryRevokeQueue === 'function') {
    const local = getGalleryRevokeQueue();
    let rqChanged = false;
    for (const [peer, entries] of Object.entries(remote.revokeQ)) {
      if (!local[peer]) { local[peer] = entries; rqChanged = true; continue; }
      // Merge entries: whole-gallery revokes supersede item revokes
      const localGalSet = new Set(local[peer].filter(e => !e.itemIds).map(e => e.galleryId));
      for (const re of entries) {
        if (!re.itemIds) {
          // Whole-gallery revoke — add if not already present
          if (!localGalSet.has(re.galleryId)) {
            // Remove any item-level revokes for this gallery
            local[peer] = local[peer].filter(e => e.galleryId !== re.galleryId);
            local[peer].push(re);
            rqChanged = true;
          }
        } else if (!localGalSet.has(re.galleryId)) {
          // Item-level revoke — merge if no whole-gallery revoke exists
          const existing = local[peer].find(e => e.galleryId === re.galleryId && e.itemIds);
          if (existing) {
            const existingSet = new Set(existing.itemIds);
            for (const id of re.itemIds) {
              if (!existingSet.has(id)) { existing.itemIds.push(id); rqChanged = true; }
            }
          } else {
            local[peer].push(re);
            rqChanged = true;
          }
        }
      }
    }
    if (rqChanged) { saveGalleryRevokeQueue(local); changed = true; }
  }

  // 7. Left galleries — union by (senderSlice16, galleryId)
  if (remote.left && typeof getGalleryLeft === 'function' && typeof saveGalleryLeft === 'function') {
    const local = getGalleryLeft();
    let leftChanged = false;
    for (const [sender, galleries] of Object.entries(remote.left)) {
      if (!local[sender]) { local[sender] = galleries; leftChanged = true; continue; }
      for (const [galId, val] of Object.entries(galleries)) {
        if (!local[sender][galId]) { local[sender][galId] = val; leftChanged = true; }
      }
    }
    if (leftChanged) { saveGalleryLeft(local); changed = true; }
  }

  // 8. Gallery notes (own captions) — LWW by key
  if (remote.notes && typeof getGalleryNotes === 'function' && typeof saveGalleryNotes === 'function') {
    const local = getGalleryNotes();
    let notesChanged = false;
    for (const [key, val] of Object.entries(remote.notes)) {
      if (local[key] === undefined || local[key] === null) {
        local[key] = val; notesChanged = true;
      }
    }
    if (notesChanged) { saveGalleryNotes(local); changed = true; }
  }

  // Bookmarks — union by key
  if (remote.bookmarks && typeof getBookmarks === 'function' && typeof saveBookmarks === 'function') {
    const local = getBookmarks();
    let bmChanged = false;
    for (const [key, val] of Object.entries(remote.bookmarks)) {
      if (!local[key]) { local[key] = val; bmChanged = true; }
    }
    if (bmChanged) { saveBookmarks(local); changed = true; }
  }

  // Bookmarks received — union by key + senderSlice16
  if (remote.bookmarksRcvd && typeof getBookmarksReceived === 'function' && typeof saveBookmarksReceived === 'function') {
    const local = getBookmarksReceived();
    let bmrChanged = false;
    for (const [key, arr] of Object.entries(remote.bookmarksRcvd)) {
      if (!local[key]) { local[key] = arr; bmrChanged = true; continue; }
      const existing = new Set(local[key].map(s => s.senderSlice16));
      for (const s of arr) {
        if (!existing.has(s.senderSlice16)) { local[key].push(s); bmrChanged = true; }
      }
    }
    if (bmrChanged) { saveBookmarksReceived(local); changed = true; }
  }

  if (changed) console.log('[sync] ← gallery extended data merged');
  return changed;
}

// ── Gallery deletion tracker merge ────────────────────────────────────────────
function _mergeGalleryDeleted(remote) {
  if (!remote || typeof remote !== 'object') return;
  const local = typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] };
  let changed = false;
  // Union gallery deletions
  const localGalIds = new Set((local.galleries || []).map(d => d.galleryId));
  for (const rd of (remote.galleries || [])) {
    if (rd.galleryId && !localGalIds.has(rd.galleryId)) {
      local.galleries.push(rd);
      changed = true;
    }
  }
  // Union item deletions
  const localItemKeys = new Set((local.items || []).map(d => `${d.galleryId}:${d.itemId}`));
  for (const rd of (remote.items || [])) {
    if (rd.galleryId && rd.itemId && !localItemKeys.has(`${rd.galleryId}:${rd.itemId}`)) {
      local.items.push(rd);
      changed = true;
    }
  }
  // Cap to prevent unbounded growth (30-day TTL)
  const cutoff = Date.now() - 30 * 86400000;
  local.galleries = (local.galleries || []).filter(d => (d.ts || 0) > cutoff).slice(-200);
  local.items = (local.items || []).filter(d => (d.ts || 0) > cutoff).slice(-500);
  if (changed) {
    if (typeof saveGalleryDeleted === 'function') saveGalleryDeleted(local);
    // Also apply: remove locally any galleries/items that the other device deleted
    const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
    let galChanged = false;
    const delGalIds = new Set(local.galleries.map(d => d.galleryId));
    const delItemKeys = new Set(local.items.map(d => `${d.galleryId}:${d.itemId}`));
    for (let i = galleries.length - 1; i >= 0; i--) {
      if (delGalIds.has(galleries[i].id)) { galleries.splice(i, 1); galChanged = true; continue; }
      const before = galleries[i].items?.length || 0;
      if (galleries[i].items) galleries[i].items = galleries[i].items.filter(it => !delItemKeys.has(`${galleries[i].id}:${it.id}`));
      if ((galleries[i].items?.length || 0) < before) galChanged = true;
    }
    if (galChanged && typeof saveGalleries === 'function') saveGalleries(galleries);
  }
}

// ── Quick Check merge ─────────────────────────────────────────────────────────
function _mergeQuickChecks(remote) {
  if (!Array.isArray(remote) || !remote.length) return false;
  const local = typeof getQuickChecks === 'function' ? getQuickChecks() : [];
  const localMap = new Map(local.map(q => [q.id, q]));
  let changed = false;
  for (const rq of remote) {
    if (!rq?.id) continue;
    const lq = localMap.get(rq.id);
    if (!lq) {
      local.push(rq);
      changed = true;
    } else {
      // Merge: prefer higher spree, later acceptedAt, merge history
      if (rq.acceptedAt && !lq.acceptedAt) { lq.acceptedAt = rq.acceptedAt; lq.currentPeriodStart = rq.currentPeriodStart; changed = true; }
      if ((rq.spree || 0) > (lq.spree || 0)) { lq.spree = rq.spree; changed = true; }
      if ((rq.bestSpree || 0) > (lq.bestSpree || 0)) { lq.bestSpree = rq.bestSpree; changed = true; }
      if (rq.mySubmittedAt && !lq.mySubmittedAt) { lq.mySubmittedAt = rq.mySubmittedAt; lq.myImgKey = rq.myImgKey; changed = true; }
      if (rq.peerSubmittedAt && !lq.peerSubmittedAt) { lq.peerSubmittedAt = rq.peerSubmittedAt; lq.peerImgKey = rq.peerImgKey; changed = true; }
      if (rq.revealed && !lq.revealed) { lq.revealed = true; changed = true; }
      if (!rq.active && lq.active) { lq.active = false; changed = true; }
      if (rq.cancelledAt && !lq.cancelledAt) { lq.cancelledAt = rq.cancelledAt; lq.active = false; changed = true; }
      // Merge history entries by period
      const localPeriods = new Set((lq.history || []).map(h => h.period));
      for (const rh of (rq.history || [])) {
        if (!localPeriods.has(rh.period)) { lq.history = lq.history || []; lq.history.push(rh); changed = true; }
      }
    }
  }
  if (changed) {
    if (typeof saveQuickChecks === 'function') saveQuickChecks(local);
    console.log('[sync] ← quick checks merged');
  }
  return changed;
}

// IDB keys referenced by received galleries that we don't have locally
async function _getMissingReceivedGalleryPhotoKeys() {
  const received = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
  const missing = new Set();
  for (const galleries of Object.values(received)) {
    for (const gal of Object.values(galleries)) {
      for (const item of Object.values(gal.items || {})) {
        if (item.idbKey) missing.add(item.idbKey);
      }
    }
  }
  if (!missing.size) return [];
  const existing = new Set(await idbGetAllKeys().catch(() => []));
  return [...missing].filter(k => !existing.has(k));
}

// IDB keys referenced by chat photo messages that we don't have locally
async function _getMissingChatPhotoKeys() {
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const missing = new Set();
  for (const c of contacts) {
    const hist = typeof getChatHistory === 'function' ? getChatHistory(c.pubKeyB64) : [];
    for (const m of hist) {
      if (m.imgKey && (m.imgKey.startsWith('chatimg_') || m.imgKey.startsWith('quickpic_'))) missing.add(m.imgKey);
    }
  }
  if (!missing.size) return [];
  const existing = new Set(await idbGetAllKeys().catch(() => []));
  return [...missing].filter(k => !existing.has(k));
}

// ── Per-device sync state ─────────────────────────────────────────────────────
async function getLastSyncedAt(deviceId) {
  const state = (await idbMetaGet('sync_state')) || {};
  return state[deviceId] || null;
}
async function setLastSyncedAt(deviceId, ts) {
  const state = (await idbMetaGet('sync_state')) || {};
  state[deviceId] = ts;
  await idbMetaPut('sync_state', state);
}

// ── In-progress lock ──────────────────────────────────────────────────────────
let _syncInProgress = false;

// ── Pending message correlation ───────────────────────────────────────────────
// When we send sync_start, we wait for sync_response from the same device.
const _syncWaiters = new Map(); // deviceId → { resolve, reject, timer }

// Called from p2p.js ws.onclose — reject all pending sync waiters so promises
// don't hang or resolve stale data after WS reconnects.
function _clearSyncWaiters() {
  for (const [deviceId, waiter] of _syncWaiters) {
    clearTimeout(waiter.timer);
    waiter.reject(new Error('WS closed'));
  }
  _syncWaiters.clear();
}

function _waitForSyncResponse(deviceId, timeoutMs = 30000) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      _syncWaiters.delete(deviceId);
      reject(new Error('Sync timeout'));
    }, timeoutMs);
    _syncWaiters.set(deviceId, { resolve, reject, timer });
  });
}

// Per-device in-progress lock — prevents two concurrent syncWithDevice calls for the same device
const _syncInProgressDevices = new Set();

// ── Core sync with one device ─────────────────────────────────────────────────
async function syncWithDevice(deviceId) {
  // Guard: skip if already syncing with this specific device
  if (_syncInProgressDevices.has(deviceId)) {
    console.log(`[sync] sync already in progress for ${deviceId.slice(0,8)}… — skipping`);
    return;
  }
  _syncInProgressDevices.add(deviceId);
  try {
    await _syncWithDeviceInner(deviceId);
  } finally {
    _syncInProgressDevices.delete(deviceId);
  }
}

async function _syncWithDeviceInner(deviceId) {
  const paired = await getPairedDevices();
  const device = paired.find(p => p.deviceId === deviceId);
  if (!device) return;

  const myId    = await getDeviceId();
  const syncKey = await derivePairSyncKey(myId, deviceId);
  const local   = generateManifest();
  const lastSync = await getLastSyncedAt(deviceId);

  // Build sync_start payload
  const payload = {
    type:       'sync_start',
    from:       myId,
    manifest:   local,
    profile:    _getSyncProfile(),
    settings:   await _getSyncSettings(),
    contacts:   _getSyncContacts(),
    deletedIds: lsGet('frj_deleted', {}),
    pendingPwChange: await idbMetaGet('pending_pw_change').catch(() => null) || null,
    timerSessions: _getSyncTimerData(),
    chat:       _getSyncChatData(lastSync),
    galleries:  _getSyncGalleries(),
    galleryExt: _getSyncGalleryExtended(),
    comparisons: typeof _getSyncComparisons === 'function' ? _getSyncComparisons() : [],
    quickChecks: typeof getQuickChecks === 'function' ? getQuickChecks() : [],
    galleryDeleted: typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] }
  };

  const encrypted = await _syncEncrypt(payload, syncKey);
  let sent = p2pSendDeviceMsg(deviceId, encrypted);
  if (!sent) {
    // WS not open yet — wait up to 3 seconds for it to connect, then retry once
    console.warn(`[sync] ${deviceId.slice(0,8)}… WS not open, waiting…`);
    await new Promise(r => setTimeout(r, 3000));
    sent = p2pSendDeviceMsg(deviceId, encrypted);
    if (!sent) {
      throw new Error('WS not open');
    }
  }
  // Register waiter AFTER confirmed send so the 30s timeout starts from actual delivery,
  // not from before the (potentially retried) send.
  const responsePromise = _waitForSyncResponse(deviceId);

  console.log(`[sync] → sync_start to ${deviceId.slice(0,8)}… (${local.length} entries)`);

  // Wait for sync_response — throw on timeout/close so _retryInitialSync can retry
  let response;
  try {
    response = await responsePromise;
  } catch(e) {
    console.warn(`[sync] ${deviceId.slice(0,8)}… no response: ${e.message}`);
    if (e.message !== 'WS closed') {
      if (typeof syncWarnRipple === 'function') syncWarnRipple();
    }
    throw e; // re-throw so _retryInitialSync knows to retry
  }

  // Device responded — mark it online so the header dot shows
  _markDeviceOnline(deviceId);

  // Suppress auto-sync during incoming merges (writes to sync keys shouldn't re-trigger sync)
  _syncMerging = true;
  let deletesApplied, merged, profileMerged, settingsMerged, contactsMerged, timerMerged, chatMerged, galleriesMerged;
  try {
    deletesApplied = _applyRemoteDeletes(response.deletedIds);
    merged = await mergeEntries(response.entries || []);
    console.log(`[sync] ← ${merged} entries merged from ${deviceId.slice(0,8)}…`);
    profileMerged    = _mergeProfile(response.profile);
    settingsMerged   = _mergeSettings(response.settings);
    contactsMerged   = _mergeContacts(response.contacts);
    timerMerged      = _mergeTimerData(response.timerSessions);
    chatMerged       = _mergeChatData(response.chat);
    if (response.galleryDeleted) _mergeGalleryDeleted(response.galleryDeleted);
    galleriesMerged  = _mergeGalleries(response.galleries);
    if (response.galleryExt) galleriesMerged = _mergeGalleryExtended(response.galleryExt) || galleriesMerged;
    if (response.comparisons && typeof _mergeComparisons === 'function') _mergeComparisons(response.comparisons);
    if (response.quickChecks) _mergeQuickChecks(response.quickChecks);
  } finally { _syncMerging = false; }
  if (merged || deletesApplied || profileMerged || settingsMerged || contactsMerged || timerMerged) {
    if (typeof renderDash === 'function') renderDash();
    if (typeof renderEntryList === 'function') renderEntryList();
    if (contactsMerged && typeof updatePeerStatus === 'function') updatePeerStatus();
    if ((settingsMerged || contactsMerged) && typeof renderContactsPage === 'function') renderContactsPage();
  }
  if (galleriesMerged && typeof renderGallery === 'function') renderGallery();

  // Request any photos we're missing — own entries + buddy cached photos + own gallery blobs + received gallery blobs
  const missingOwn     = await _getMissingPhotoKeys(getEntries());
  const missingBuddy   = await _getMissingBuddyPhotoKeys();
  const missingGallery = await _getMissingGalleryPhotoKeys();
  const missingRecvGal = await _getMissingReceivedGalleryPhotoKeys();
  const missingChat    = await _getMissingChatPhotoKeys();
  const allMissing     = [...missingOwn, ...missingBuddy, ...missingGallery, ...missingRecvGal, ...missingChat];
  if (allMissing.length) {
    await _requestPhotos(deviceId, allMissing, syncKey);
  }

  // Apply pending password change from peer if delivered
  if (response.pendingPwChange && typeof receivePeerPwChange === 'function') {
    await receivePeerPwChange(response.pendingPwChange);
  }

  // Send the entries the remote requested
  if ((response.need || []).length) {
    const allEntries = getEntries();
    const entryMap   = new Map(allEntries.map(e => [e.id, e]));
    const toSend     = response.need.map(id => entryMap.get(id)).filter(Boolean);

    const deliverPayload = {
      type:    'sync_deliver',
      from:    myId,
      entries: toSend
    };
    const encDeliver = await _syncEncrypt(deliverPayload, syncKey);
    p2pSendDeviceMsg(deviceId, encDeliver);
    console.log(`[sync] → sync_deliver ${toSend.length} entries to ${deviceId.slice(0,8)}…`);
  }

  await setLastSyncedAt(deviceId, Date.now());
  _updateSyncStatusUI();
  if (typeof syncRipple === 'function') syncRipple(merged);
}

// ── Incoming message handler (called by p2p.js → onDeviceMessage) ─────────────
async function onDeviceMessage(fromDeviceId, encryptedPayload) {
  const paired = await getPairedDevices();
  const device = paired.find(p => p.deviceId === fromDeviceId);
  if (!device) return; // unknown device — ignore

  const myId = await getDeviceId();
  let syncKey;
  try {
    syncKey = await derivePairSyncKey(myId, fromDeviceId);
  } catch(e) {
    return; // keypair not ready yet — drop message silently
  }

  let msg;
  try {
    msg = await _syncDecrypt(encryptedPayload, syncKey);
  } catch(e) {
    console.warn(`[sync] decrypt failed from ${fromDeviceId.slice(0,8)}…`, e);
    return;
  }

  if (msg.type === 'sync_start') {
    // Remote is initiating — diff their manifest vs ours and respond
    _markDeviceOnline(fromDeviceId); // device is reachable — light up the header dot
    console.log(`[sync] ← sync_start from ${fromDeviceId.slice(0,8)}… (${(msg.manifest||[]).length} entries)`);
    const { need, send } = diffManifests(generateManifest(), msg.manifest || []);

    // Suppress auto-sync during incoming merges
    _syncMerging = true;
    let profileMerged, settingsMerged, contactsMerged, timerMerged, chatMerged, galleriesMerged;
    try {
      _applyRemoteDeletes(msg.deletedIds);
      profileMerged   = _mergeProfile(msg.profile);
      settingsMerged  = _mergeSettings(msg.settings);
      contactsMerged  = _mergeContacts(msg.contacts);
      timerMerged     = _mergeTimerData(msg.timerSessions);
      chatMerged      = _mergeChatData(msg.chat);
      if (msg.galleryDeleted) _mergeGalleryDeleted(msg.galleryDeleted);
      galleriesMerged = _mergeGalleries(msg.galleries);
      if (msg.galleryExt) galleriesMerged = _mergeGalleryExtended(msg.galleryExt) || galleriesMerged;
      if (msg.comparisons && typeof _mergeComparisons === 'function') _mergeComparisons(msg.comparisons);
      if (msg.quickChecks) _mergeQuickChecks(msg.quickChecks);
    } finally { _syncMerging = false; }

    // Apply pending password change from peer if included
    if (msg.pendingPwChange && typeof receivePeerPwChange === 'function') {
      await receivePeerPwChange(msg.pendingPwChange);
    }

    const lastSyncForChat = await getLastSyncedAt(fromDeviceId);
    const response = {
      type:       'sync_response',
      from:       myId,
      need,
      entries:    send,
      profile:    _getSyncProfile(),
      settings:   await _getSyncSettings(),
      contacts:   _getSyncContacts(),
      deletedIds: lsGet('frj_deleted', {}),
      pendingPwChange: await idbMetaGet('pending_pw_change').catch(() => null) || null,
      timerSessions: _getSyncTimerData(),
      chat:       _getSyncChatData(lastSyncForChat),
      galleries:  _getSyncGalleries(),
      galleryExt: _getSyncGalleryExtended(),
      comparisons: typeof _getSyncComparisons === 'function' ? _getSyncComparisons() : [],
      quickChecks: typeof getQuickChecks === 'function' ? getQuickChecks() : [],
      galleryDeleted: typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] }
    };
    const enc = await _syncEncrypt(response, syncKey);
    p2pSendDeviceMsg(fromDeviceId, enc);
    console.log(`[sync] → sync_response: need=${need.length} send=${send.length}`);

    if (profileMerged || settingsMerged || contactsMerged || timerMerged || chatMerged) {
      if (typeof renderDash === 'function') renderDash();
      if (typeof renderEntryList === 'function') renderEntryList();
      if ((settingsMerged || contactsMerged) && typeof renderContactsPage === 'function') renderContactsPage();
      if (contactsMerged && typeof updatePeerStatus === 'function') updatePeerStatus();
    }
    if (galleriesMerged && typeof renderGallery === 'function') renderGallery();

    // Request missing photos after merge — buddy photos + own gallery blobs + received gallery blobs
    const missingBuddyAfterSync = contactsMerged ? await _getMissingBuddyPhotoKeys() : [];
    const missingGalleryAfterSync = galleriesMerged ? await _getMissingGalleryPhotoKeys() : [];
    const missingRecvGalAfterSync = await _getMissingReceivedGalleryPhotoKeys();
    const missingChatAfterSync = chatMerged ? await _getMissingChatPhotoKeys() : [];
    const allMissingAfterSync = [...missingBuddyAfterSync, ...missingGalleryAfterSync, ...missingRecvGalAfterSync, ...missingChatAfterSync];
    if (allMissingAfterSync.length) await _requestPhotos(fromDeviceId, allMissingAfterSync, syncKey);

    await setLastSyncedAt(fromDeviceId, Date.now());
    _updateSyncStatusUI();

  } else if (msg.type === 'sync_response') {
    // Response to our sync_start — resolve the waiter
    const waiter = _syncWaiters.get(fromDeviceId);
    if (waiter) {
      clearTimeout(waiter.timer);
      _syncWaiters.delete(fromDeviceId);
      waiter.resolve(msg);
    }

  } else if (msg.type === 'sync_deliver') {
    // Remote delivering entries we requested
    console.log(`[sync] ← sync_deliver ${(msg.entries||[]).length} entries from ${fromDeviceId.slice(0,8)}…`);
    _syncMerging = true;
    let merged;
    try { merged = await mergeEntries(msg.entries || []); } finally { _syncMerging = false; }
    // Request photos referenced by those entries that we don't have
    if (msg.entries && msg.entries.length) {
      const missingPhotos = await _getMissingPhotoKeys(msg.entries);
      await _requestPhotos(fromDeviceId, missingPhotos, syncKey);
    }
    if (merged) {
      if (typeof renderDash === 'function') renderDash();
      if (typeof renderEntryList === 'function') {
        // Preserve scroll position so new entries roll in without jumping
        const listEl = document.getElementById('entry-list');
        const scrollY = listEl ? listEl.scrollTop : 0;
        renderEntryList();
        if (listEl) listEl.scrollTop = scrollY;
      }
      if (typeof syncRipple === 'function') syncRipple(merged);
    }
    await setLastSyncedAt(fromDeviceId, Date.now());
    _updateSyncStatusUI();

  } else if (msg.type === 'photo_request') {
    await _handlePhotoRequest(fromDeviceId, msg.keys, syncKey);

  } else if (msg.type === 'photo_deliver') {
    await _handlePhotoDeliver(msg.key, msg.data);
    if (typeof refreshImg === 'function') refreshImg(msg.key);

  } else if (msg.type === 'device_activity') {
    // Real-time timer state from other device — apply via LWW (changedAt).
    // If the remote action is newer than our local state, adopt it.
    _applyRemoteTimerActivity(msg.activity, msg.session);

  } else if (msg.type === 'device_removed') {
    // The peer has removed us from their paired list — reciprocate
    _pendingRemovalToIds.delete(fromDeviceId); // clean up if removal_confirm was also lost
    const remaining = (await getPairedDevices()).filter(p => p.deviceId !== fromDeviceId);
    await savePairedDevices(remaining);
    _syncKeyCache.clear();
    if (!remaining.length) _stopHeartbeat();
    if (typeof renderDevicesList === 'function') renderDevicesList();
    if (typeof toast === 'function') toast(`"${device.label}" removed this device`);

  } else if (msg.type === 'removal_request') {
    // Peer is asking us to confirm removal — show confirmation modal on this device
    _showRemovalRequestModal(fromDeviceId, msg.fromLabel || device.label);

  } else if (msg.type === 'removal_confirm') {
    // The target device confirmed — remove it from our paired list now
    _pendingRemovalToIds.delete(fromDeviceId);
    const remaining = (await getPairedDevices()).filter(p => p.deviceId !== fromDeviceId);
    await savePairedDevices(remaining);
    _syncKeyCache.clear();
    if (!remaining.length) _stopHeartbeat();
    if (typeof renderDevicesList === 'function') renderDevicesList();
    if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
    if (typeof toast === 'function') toast(`"${device.label}" removed ✓`);

  }
}

// Per-device pending-sync timers — deduplicate rapid connect events
const _peerOnlineTimers = new Map();
// Set of deviceIds currently reachable on the signal server
const _onlineDeviceIds = new Set();
function getOnlineDeviceIds() { return _onlineDeviceIds; }

// Mark a device as online and refresh the header dot — called on any successful sync exchange
function _markDeviceOnline(deviceId) {
  if (_onlineDeviceIds.has(deviceId)) return;
  _onlineDeviceIds.add(deviceId);
  if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
  _updateSyncStatusUI();
}

// Hook for p2p.js to call when a paired device comes online
async function onDevicePeerOnline(deviceId) {
  console.log(`[sync] device peer online: ${deviceId.slice(0,8)}…`);
  _onlineDeviceIds.add(deviceId);
  if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
  _updateSyncStatusUI();
  // Cancel any already-pending sync for this device (rapid reconnects)
  if (_peerOnlineTimers.has(deviceId)) {
    clearTimeout(_peerOnlineTimers.get(deviceId));
  }
  // Small delay so both sides have settled their WS registration
  const t = setTimeout(async () => {
    _peerOnlineTimers.delete(deviceId);
    try {
      await syncWithDevice(deviceId);
    } catch(e) {
      if (e.message === 'WS closed') {
        // WS will auto-reconnect → onDevicePeerOnline fires again; nothing to do
        return;
      }
      if (e.message === 'Sync timeout' || e.message === 'No keypair') {
        // Transient: mobile radio throttle, or IDB not yet open on startup.
        // One silent retry after a short pause — no toast.
        setTimeout(async () => {
          try { await syncWithDevice(deviceId); } catch(_) {}
        }, 5000);
        return;
      }
      console.error('[sync] syncWithDevice error:', e);
      if (typeof toast === 'function') toast('Sync error: ' + e.message);
    }
  }, 800);
  _peerOnlineTimers.set(deviceId, t);
}

function onDevicePeerOffline(deviceId) {
  console.log(`[sync] device peer offline: ${deviceId.slice(0,8)}…`);
  _onlineDeviceIds.delete(deviceId);
  if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
  _updateSyncStatusUI();
}

// ── Sync status UI ────────────────────────────────────────────────────────────
function _updateSyncStatusUI() {
  const wrap = document.getElementById('sync-status');
  const txt  = document.getElementById('sync-status-text');
  const dot  = document.getElementById('sync-dot');
  if (!wrap || !txt) return;

  const offline = (typeof p2pIsOfflineMode === 'function') ? p2pIsOfflineMode() : true;
  const noneOnline = _onlineDeviceIds.size === 0;

  if (offline || noneOnline) {
    // Show gray "not syncing" — but only if there are paired devices (otherwise hide)
    getPairedDevices().then(paired => {
      if (!paired.length) { wrap.style.display = 'none'; return; }
      txt.textContent = 'not syncing';
      if (dot) dot.style.background = 'var(--muted2)';
      wrap.style.display = 'flex';
    }).catch(() => {});
    return;
  }

  idbMetaGet('sync_state').then(state => {
    if (!state || !Object.keys(state).length) {
      txt.textContent = 'not syncing';
      if (dot) dot.style.background = 'var(--muted2)';
      wrap.style.display = 'flex';
      return;
    }
    const latest = Math.max(...Object.values(state).filter(Number));
    if (!latest) {
      txt.textContent = 'not syncing';
      if (dot) dot.style.background = 'var(--muted2)';
      wrap.style.display = 'flex';
      return;
    }
    txt.textContent = 'Synced ' + timeAgo(new Date(latest));
    if (dot) dot.style.background = 'var(--green)';
    wrap.style.display = 'flex';
  }).catch(() => {});
}

// ── Prune old tombstones (> 90 days) ────────────────────────────────────────
function _pruneTombstones() {
  // Tombstones are tiny (~50 bytes each). Aggressive pruning risks entry
  // resurrection when a device is offline longer than the retention window.
  // Keep tombstones for 5 years — effectively indefinite for practical use.
  const deleted = lsGet('frj_deleted', {});
  const cutoff = Date.now() - 5 * 365 * 86400000;
  let pruned = 0;
  for (const [id, ts] of Object.entries(deleted)) {
    if (ts < cutoff) { delete deleted[id]; pruned++; }
  }
  if (pruned) { lsSet('frj_deleted', deleted); console.log(`[sync] pruned ${pruned} old entry tombstones`); }
  // Also prune session tombstones
  const deletedSessions = lsGet('frj_deleted_sessions', {});
  let sPruned = 0;
  for (const [id, ts] of Object.entries(deletedSessions)) {
    if (ts < cutoff) { delete deletedSessions[id]; sPruned++; }
  }
  if (sPruned) { lsSet('frj_deleted_sessions', deletedSessions); console.log(`[sync] pruned ${sPruned} old session tombstones`); }
}

// ── broadcastActivityToDevices — real-time timer state to paired devices ─────
// activity: timer object (with changedAt) or stop marker { id, stopped, changedAt }
// session: optional completed session to deliver with stop events
async function broadcastActivityToDevices(activity, session) {
  const paired = await getPairedDevices();
  if (!paired.length) return;
  const myId = await getDeviceId();
  for (const device of paired) {
    if (!_onlineDeviceIds.has(device.deviceId)) continue;
    try {
      const syncKey = await derivePairSyncKey(myId, device.deviceId);
      const msg = { type: 'device_activity', activity };
      if (session) msg.session = session;
      const enc = await _syncEncrypt(msg, syncKey);
      p2pSendDeviceMsg(device.deviceId, enc);
    } catch (e) {
      console.warn('[sync] broadcastActivityToDevices error:', e);
    }
  }
}

// ── syncNow — sync with all paired devices ────────────────────────────────────
async function syncNow() {
  if (_syncInProgress) {
    // Queue a follow-up sync so changes made during this sync aren't dropped
    clearTimeout(_syncDebounceTimer);
    _syncDebounceTimer = setTimeout(syncNow, 3000);
    return;
  }
  _syncInProgress = true;
  _setSyncIndicator('syncing');
  _pruneTombstones();
  try {
    const paired = await getPairedDevices();
    if (!paired.length) { _setSyncIndicator('idle'); return; }
    for (const d of paired) {
      try { await syncWithDevice(d.deviceId); }
      catch(e) { console.warn(`[sync] syncWithDevice ${d.deviceId.slice(0,8)}… error:`, e); }
    }
    _setSyncIndicator('synced');
  } catch(e) {
    console.error('[sync] syncNow error:', e);
    _setSyncIndicator('error');
  } finally {
    _syncInProgress = false;
  }
}

// ── Sync indicator (sidebar dot) ──────────────────────────────────────────────
function _setSyncIndicator(state) {
  const dot  = document.getElementById('sync-dot');
  const wrap = document.getElementById('sync-status');
  const txt  = document.getElementById('sync-status-text');
  if (!dot) return;
  const colors = { idle: 'transparent', syncing: '#f59e0b', synced: '#22c55e', error: '#ef4444' };
  dot.style.background = colors[state] || 'transparent';
  if (wrap) wrap.style.display = state === 'idle' ? 'none' : 'flex';
  if (txt) txt.textContent = state === 'synced' ? 'Synced' : state === 'syncing' ? 'Syncing…' : state === 'error' ? 'Sync error' : '';
  if (state === 'synced') setTimeout(() => { _setSyncIndicator('idle'); _updateSyncStatusUI(); }, 3000);
}

// Expose for p2p.js hooks (functions are declared above, assigned here to globals)
// p2p.js calls onDeviceMessage, onDevicePeerOnline, onDevicePeerOffline directly by name

// ═══════════════════════════════════════════════
// Sprint 3 — Sync Triggers
// ═══════════════════════════════════════════════

// ── Trigger 1: Post-save debounce (called from saveEntryForm) ─────────────────
let _syncDebounceTimer = null;
function syncAfterSave() {
  clearTimeout(_syncDebounceTimer);
  _syncDebounceTimer = setTimeout(syncNow, 2000);
}

// ── Trigger 2: Page-load sync ─────────────────────────────────────────────────
// Called from syncInit() after signaling starts — non-blocking
function _syncOnLoad() {
  // Signal server will send device_peers_online if paired devices are already connected.
  // onDevicePeerOnline handles that. Additionally, schedule a proactive sync after 4s
  // so we don't miss the event if it fired during the encryption unlock window.
  _updateSyncStatusUI();
  setTimeout(() => {
    if (!_syncInProgress) syncNow().catch(e => console.warn('[sync] on-load sync error:', e));
  }, 4000);
}

// ── Trigger 4: 1-minute heartbeat ────────────────────────────────────────────
// Trigger 3 (manual button) is wired in the UI via syncNow() directly.
let _heartbeatTimer = null;
function _startHeartbeat() {
  if (_heartbeatTimer) return;
  _heartbeatTimer = setInterval(async () => {
    if (document.visibilityState === 'hidden') return;
    if (_syncInProgress) return;
    const paired = await getPairedDevices().catch(() => []);
    if (!paired.length) return;
    syncNow();
  }, 60_000);
}
function _stopHeartbeat() {
  if (!_heartbeatTimer) return;
  clearInterval(_heartbeatTimer);
  _heartbeatTimer = null;
}

// ═══════════════════════════════════════════════
// Sprint 5 — Device removal notification + syncKey rotation
// ═══════════════════════════════════════════════

// Notify a device that we've removed it before we delete the local pairing record.
// Best-effort — if the device is offline the signal server just drops it silently.
async function sendDeviceRemovedMsg(deviceId) {
  const paired = await getPairedDevices();
  if (!paired.find(p => p.deviceId === deviceId)) return;
  const myId = await getDeviceId();
  try {
    const syncKey = await derivePairSyncKey(myId, deviceId);
    const enc = await _syncEncrypt({ type: 'device_removed', from: myId }, syncKey);
    p2pSendDeviceMsg(deviceId, enc);
  } catch(e) { /* offline or already gone — fine */ }
}

// Send a removal_request to a device — requires confirmation from that device.
async function sendDeviceRemovalRequest(deviceId) {
  const myId    = await getDeviceId();
  const myLabel = typeof getDeviceLabel === 'function' ? getDeviceLabel() : 'Other device';
  try {
    const syncKey = await derivePairSyncKey(myId, deviceId);
    const enc = await _syncEncrypt({ type: 'removal_request', from: myId, fromLabel: myLabel }, syncKey);
    _pendingRemovalToIds.add(deviceId); // remember we sent this, for mutual-removal detection
    p2pSendDeviceMsg(deviceId, enc);
    console.log(`[sync] → removal_request to ${deviceId.slice(0,8)}…`);
  } catch(e) {
    console.warn('[sync] sendDeviceRemovalRequest error:', e);
    if (typeof toast === 'function') toast('Could not send removal request. Try again');
  }
}

// Track which device is awaiting our confirmation so the modal button can look it up
let _pendingRemovalFromId = null;
// Track outgoing removal requests so we can auto-confirm when the other side also requested removal
const _pendingRemovalToIds = new Set();

// Show a confirmation modal when this device receives a removal_request from a peer
function _showRemovalRequestModal(fromDeviceId, fromLabel) {
  // Both sides requested removal simultaneously — auto-confirm, no modal needed
  if (_pendingRemovalToIds.has(fromDeviceId)) {
    _pendingRemovalToIds.delete(fromDeviceId);
    _pendingRemovalFromId = fromDeviceId;
    _confirmDeviceRemoval();
    return;
  }
  _pendingRemovalFromId = fromDeviceId;
  let overlay = document.getElementById('_removal-request-modal');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = '_removal-request-modal';
    overlay.className = 'modal-overlay';
    document.body.appendChild(overlay);
  }
  const safeLabel = typeof esc === 'function' ? esc(fromLabel) : fromLabel.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
  overlay.innerHTML = `<div class="modal-box" style="max-width:340px">
    <div class="modal-hdr"><span>Unpair this device?</span></div>
    <div class="modal-body" style="padding:16px 18px">
      <p style="font-size:13px;color:var(--muted);margin:0 0 16px;line-height:1.5">
        <strong>${safeLabel}</strong> has requested to unpair from this device.<br><br>
        Confirming will remove the sync connection on both sides and this device will be wiped from the paired list.
      </p>
      <div style="display:flex;gap:8px;justify-content:flex-end">
        <button class="btn btn-ghost btn-sm" data-action="cancelSyncDeviceRemoval">Cancel</button>
        <button class="btn btn-sm" style="background:var(--red,#ef4444);color:#fff;border:none" data-action="confirmDeviceRemoval">Unpair this device</button>
      </div>
    </div>
  </div>`;
  overlay.classList.add('open');
}

// Called when this device cancels the removal request (exposed for events.js)
function _cancelDeviceRemovalModal() {
  const overlay = document.getElementById('_removal-request-modal');
  if (overlay) overlay.classList.remove('open');
  _pendingRemovalFromId = null;
}

// Called when this device confirms the removal request
async function _confirmDeviceRemoval() {
  const fromDeviceId = _pendingRemovalFromId;
  _pendingRemovalFromId = null;
  const overlay = document.getElementById('_removal-request-modal');
  if (overlay) overlay.classList.remove('open');
  if (!fromDeviceId) return;

  // Notify the requesting device — send both removal_confirm and device_removed so the
  // initiator cleans up even if the WS drops between the two messages (Render flakiness).
  const myId = await getDeviceId();
  try {
    const syncKey = await derivePairSyncKey(myId, fromDeviceId);
    const enc  = await _syncEncrypt({ type: 'removal_confirm', from: myId }, syncKey);
    const enc2 = await _syncEncrypt({ type: 'device_removed',  from: myId }, syncKey);
    p2pSendDeviceMsg(fromDeviceId, enc);
    p2pSendDeviceMsg(fromDeviceId, enc2); // fallback in case removal_confirm is dropped
    console.log(`[sync] → removal_confirm + device_removed to ${fromDeviceId.slice(0,8)}…`);
  } catch(e) { /* best-effort — remove locally regardless */ }

  // Wipe the paired record on this device
  const remaining = (await getPairedDevices()).filter(p => p.deviceId !== fromDeviceId);
  await savePairedDevices(remaining);
  _syncKeyCache.clear();
  if (!remaining.length) _stopHeartbeat();
  if (typeof renderDevicesList === 'function') renderDevicesList();
  if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
  if (typeof toast === 'function') toast('This device has been unlinked');
}

// ── Retry initial sync with exponential backoff ──────────────────────────────
async function _retryInitialSync(deviceId, attempt = 0) {
  const maxAttempts = 7;
  if (attempt >= maxAttempts) { console.warn('[sync] initial sync gave up after', maxAttempts, 'attempts'); return; }
  // Wait for WS registration before first attempt (avoids burning attempts while WS connects)
  if (typeof _regReady !== 'undefined') {
    try { await Promise.race([_regReady, new Promise(r => setTimeout(r, 15000))]); } catch(e) {}
  }
  // Extra grace period on first attempt: Device A needs time to receive peer_paired,
  // add us to its paired list, and re-register with the signal server before we can relay.
  const delays = [2000, 3000, 5000, 10000, 15000, 20000, 30000];
  await new Promise(r => setTimeout(r, delays[attempt] || 30000));
  try {
    await syncWithDevice(deviceId);
    if (typeof renderDash === 'function') renderDash();
    if (typeof renderEntryList === 'function') renderEntryList();
  } catch(e) {
    console.warn(`[sync] initial pull attempt ${attempt + 1} failed:`, e.message);
    _retryInitialSync(deviceId, attempt + 1);
  }
}

// ── syncInit — called from app.js init() after sprint0Init() ─────────────────
async function syncInit() {
  const paired = await getPairedDevices().catch(() => []);
  if (!paired.length) return; // no paired devices — nothing to do

  // Check if we just paired and need a full pull from the source device
  const pendingSourceId = await idbMetaGet('pendingInitialSync').catch(() => null);
  if (pendingSourceId && paired.some(p => p.deviceId === pendingSourceId)) {
    await idbMetaPut('pendingInitialSync', null);
    if (typeof toast === 'function') toast('Downloading your data…');
    _retryInitialSync(pendingSourceId);
  }

  _syncOnLoad();
  _startHeartbeat();
}
