'use strict';
// ═══════════════════════════════════════════════
// js/storage.js — IDB + localStorage helpers, image cache
// ═══════════════════════════════════════════════

// ── Extensible device lists ──
// Edit these arrays to add/remove device options globally.
const TUGGING_DEVICES = [
  'Manual',
  'T-Tape',
  'DTR', 'CAT II Q', 'TLC Tugger', 'TLC-X', 'PUD', 'HyperRestore',
  'Supercanister', 'Mantor', 'foreGestalt', 'FMD',
  'Priva', 'CAR-1',
  'Foreskin Weights', 'Other'
];
const RETAINING_DEVICES = [
  'RCI', 'CAT II R', 'Foreskin Guard', 'O-ring',
  'Your-Skin Cone', 'Stealth Retainer', 'Tape Retainer', 'String Retainer',
  'Priva Retainer', 'Other'
];

// ── CI level descriptions ──
const CI_LABELS = {
  0: 'Extremely Tight',
  1: 'Tight Cut',
  2: 'Average Cut',
  3: 'Loose Cut',
  4: 'Intermittent Rollover',
  5: 'Permanent Coverage',
  6: 'Majority Coverage',
  7: 'Full Coverage',
  8: 'Flaccid Overhang',
  9: 'Loose Overhang',
  10: 'Generous Overhang'
};

// One-line descriptions for the illustrated CI reference (img/CIref/CI-n.png).
// Kept in sync with the public coverage-index.html page.
const CI_DESCRIPTIONS = {
  0: 'Very tight circumcision. No slack skin at all; erections may feel tight or uncomfortable.',
  1: 'Very little slack skin when flaccid. A typical tight circumcision.',
  2: 'Some skin movement along the shaft, still well short of the glans rim.',
  3: 'Noticeable slack; skin bunches up behind the glans rim. A loose circumcision.',
  4: 'Skin starts reaching the coronal rim when flaccid. The first big milestone.',
  5: 'Skin rolls over the rim; less than half the glans covered when flaccid.',
  6: 'More than half the glans covered when flaccid.',
  7: 'Glans fully covered when flaccid, though the shape may still show through.',
  8: 'Full, reliable flaccid coverage; some coverage begins to hold when erect.',
  9: 'Full flaccid coverage with overhang forming; near-complete coverage when erect.',
  10: 'Full coverage flaccid and erect, with overhang beyond the tip.'
};

function getDeviceList(type) {
  return type === 'tugging' ? TUGGING_DEVICES : RETAINING_DEVICES;
}

function timeAgo(dateStr) {
  const d = typeof dateStr === 'string' && dateStr.length === 10
    ? new Date(dateStr + 'T00:00:00')
    : new Date(dateStr);
  const diff = Date.now() - d.getTime();
  const days = Math.floor(diff / 86400000);
  if (days === 0) return 'today';
  if (days === 1) return 'yesterday';
  if (days < 7) return days + 'd ago';
  if (days < 30) return Math.floor(days / 7) + 'w ago';
  if (days < 365) return Math.floor(days / 30) + 'mo ago';
  return Math.floor(days / 365) + 'yr ago';
}

function safeColor(c) {
  if (!c || typeof c !== 'string') return '#888';
  return /^#[0-9a-fA-F]{3,6}$/.test(c) ? c : '#888';
}

const IDB_NAME      = 'frj_db';
const IDB_ST        = 'frj_blobs';       // plaintext blobs (schema v1)
const IDB_META      = 'frj_meta';        // schema version, salt, verifier
const IDB_EMERGENCY = 'frj_emergency';   // plaintext safety-net snapshot
const IDB_DATA_ENC  = 'frj_data_enc';   // encrypted LS data (schema v2)
const IDB_BLOBS_ENC = 'frj_blobs_enc';  // encrypted blobs (schema v2)
const IDB_MIG_LOG   = 'frj_migration_log'; // per-record migration state
const IDB_CHAT      = 'frj_chat';          // ephemeral buddy chat messages (30-day TTL)

let _idb = null;
const imgCache = {};

function closeIDB() { if (_idb) { _idb.close(); _idb = null; } }

function openIDB() {
  return new Promise((res, rej) => {
    if (_idb) return res(_idb);
    const r = indexedDB.open(IDB_NAME, 5);
    r.onupgradeneeded = e => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(IDB_ST))        db.createObjectStore(IDB_ST);
      if (!db.objectStoreNames.contains(IDB_META))      db.createObjectStore(IDB_META);
      if (!db.objectStoreNames.contains(IDB_EMERGENCY)) db.createObjectStore(IDB_EMERGENCY);
      if (!db.objectStoreNames.contains(IDB_DATA_ENC))  db.createObjectStore(IDB_DATA_ENC);
      if (!db.objectStoreNames.contains(IDB_BLOBS_ENC)) db.createObjectStore(IDB_BLOBS_ENC);
      if (!db.objectStoreNames.contains(IDB_MIG_LOG))   db.createObjectStore(IDB_MIG_LOG);
      if (!db.objectStoreNames.contains(IDB_CHAT))      db.createObjectStore(IDB_CHAT);
    };
    r.onsuccess = e => { _idb = e.target.result; res(_idb); };
    r.onerror = e => rej(e.target.error);
  });
}

// ── Generic IDB store helpers ────────────────────────────────────────────────
function _idbStorePut(store) {
  return async (k, v) => {
    const db = await openIDB();
    return new Promise((res, rej) => {
      const tx = db.transaction(store, 'readwrite');
      tx.objectStore(store).put(v, k);
      tx.oncomplete = res; tx.onerror = e => rej(e.target.error);
    });
  };
}
function _idbStoreGet(store) {
  return async (k) => {
    const db = await openIDB();
    return new Promise((res, rej) => {
      const r = db.transaction(store, 'readonly').objectStore(store).get(k);
      r.onsuccess = e => res(e.target.result ?? null); r.onerror = e => rej(e.target.error);
    });
  };
}
function _idbStoreDel(store) {
  return async (k) => {
    const db = await openIDB();
    return new Promise((res, rej) => {
      const tx = db.transaction(store, 'readwrite');
      tx.objectStore(store).delete(k);
      tx.oncomplete = res; tx.onerror = e => rej(e.target.error);
    });
  };
}
function _idbStoreGetAllKeys(store) {
  return async () => {
    const db = await openIDB();
    return new Promise((res, rej) => {
      const r = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
      r.onsuccess = e => res(e.target.result || []); r.onerror = e => rej(e.target.error);
    });
  };
}
function _idbStoreGetAll(store) {
  return async () => {
    const db = await openIDB();
    return new Promise((res, rej) => {
      const r = db.transaction(store, 'readonly').objectStore(store).getAll();
      r.onsuccess = e => res(e.target.result || []); r.onerror = e => rej(e.target.error);
    });
  };
}

const idbDataEncPut      = _idbStorePut(IDB_DATA_ENC);
const idbDataEncGet      = _idbStoreGet(IDB_DATA_ENC);
const idbDataEncDel      = _idbStoreDel(IDB_DATA_ENC);
const idbDataEncGetAllKeys = _idbStoreGetAllKeys(IDB_DATA_ENC);

const idbBlobsEncPut      = _idbStorePut(IDB_BLOBS_ENC);
const idbBlobsEncGet      = _idbStoreGet(IDB_BLOBS_ENC);
const idbBlobsEncDel      = _idbStoreDel(IDB_BLOBS_ENC);
const idbBlobsEncGetAllKeys = _idbStoreGetAllKeys(IDB_BLOBS_ENC);

const idbMigLogPut = _idbStorePut(IDB_MIG_LOG);
const idbMigLogGet = _idbStoreGet(IDB_MIG_LOG);

const _idbChatPut    = _idbStorePut(IDB_CHAT);
const _idbChatGet    = _idbStoreGet(IDB_CHAT);
const _idbChatDel    = _idbStoreDel(IDB_CHAT);
const _idbChatGetAll = _idbStoreGetAll(IDB_CHAT);

const idbMetaPut = _idbStorePut(IDB_META);
const idbMetaGet = _idbStoreGet(IDB_META);

const idbEmergencyPut = _idbStorePut(IDB_EMERGENCY);
const idbEmergencyGet = _idbStoreGet(IDB_EMERGENCY);
const idbEmergencyDel = _idbStoreDel(IDB_EMERGENCY);

async function idbPut(k, blob) {
  // Block writes when encryption is enabled but session is locked
  if (typeof isEncryptionLocked === 'function' && isEncryptionLocked()) {
    console.warn('[storage] idbPut blocked — encryption locked:', k);
    return;
  }
  const db = await openIDB();
  // Schema v2: encrypt and store in frj_blobs_enc
  if (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) {
    if (typeof _frjEncryptBlobRecord !== 'function') throw new Error('Encrypt function unavailable');
    const enc = await _frjEncryptBlobRecord(blob);
    return new Promise((res, rej) => {
      const tx = db.transaction(IDB_BLOBS_ENC, 'readwrite');
      tx.objectStore(IDB_BLOBS_ENC).put(enc, k);
      tx.oncomplete = res; tx.onerror = e => rej(e.target.error);
    });
  }
  // Safari's IDB structured-clone rejects Blob objects ("BlobURLs are not yet supported").
  // Store as {buffer, type} so ArrayBuffer is cloned instead; idbGet reconstructs the Blob.
  const val = (blob instanceof Blob)
    ? { buffer: await blob.arrayBuffer(), type: blob.type || '' }
    : blob;
  return new Promise((res, rej) => {
    const tx = db.transaction(IDB_ST, 'readwrite');
    tx.objectStore(IDB_ST).put(val, k);
    tx.oncomplete = res;
    tx.onerror = e => rej(e.target.error);
  });
}

async function idbGet(k) {
  const db = await openIDB();
  // Schema v2: decrypt from frj_blobs_enc
  if (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) {
    if (typeof _frjDecryptBlobRecord !== 'function') throw new Error('Decrypt function unavailable');
    return new Promise((res, rej) => {
      const r = db.transaction(IDB_BLOBS_ENC, 'readonly').objectStore(IDB_BLOBS_ENC).get(k);
      r.onsuccess = async e => {
        if (e.target.result == null) { res(null); return; }
        try { res(await _frjDecryptBlobRecord(e.target.result)); } catch(err) { rej(err); }
      };
      r.onerror = e => rej(e.target.error);
    });
  }
  return new Promise((res, rej) => {
    const r = db.transaction(IDB_ST, 'readonly').objectStore(IDB_ST).get(k);
    r.onsuccess = e => {
      const result = e.target.result;
      if (!result) { res(null); return; }
      // Reconstruct Blob from {buffer, type} (Safari-compat format written by idbPut)
      if (!(result instanceof Blob) && result.buffer instanceof ArrayBuffer) {
        res(new Blob([result.buffer], { type: result.type || 'image/jpeg' }));
        return;
      }
      res(result);
    };
    r.onerror = e => rej(e.target.error);
  });
}

async function idbGetAllKeys() {
  const db = await openIDB();
  const store = (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) ? IDB_BLOBS_ENC : IDB_ST;
  return new Promise((res, rej) => {
    const r = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
    r.onsuccess = e => res(e.target.result || []);
    r.onerror = e => rej(e.target.error);
  });
}

async function idbDel(k) {
  if (!k || typeof k !== 'string') return;
  if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
  const db = await openIDB();
  const store = (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) ? IDB_BLOBS_ENC : IDB_ST;
  return new Promise((res, rej) => {
    const tx = db.transaction(store, 'readwrite');
    tx.objectStore(store).delete(k);
    tx.oncomplete = res;
    tx.onerror = rej;
  });
}

async function idbClear() {
  Object.values(imgCache).forEach(u => URL.revokeObjectURL(u));
  Object.keys(imgCache).forEach(k => delete imgCache[k]);
  const db = await openIDB();
  const store = (typeof _frjBlobSchemaV2 !== 'undefined' && _frjBlobSchemaV2) ? IDB_BLOBS_ENC : IDB_ST;
  return new Promise((res, rej) => {
    const tx = db.transaction(store, 'readwrite');
    tx.objectStore(store).clear();
    tx.oncomplete = res;
    tx.onerror = rej;
  });
}

// Compress an image blob to max dimension maxPx at JPEG quality (0–1).
// Targets ≤200 KB per photo. Returns a new Blob.
async function compressImage(blob, maxPx = 1200, quality = 0.82) {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      let w = img.naturalWidth, h = img.naturalHeight;
      if (w > maxPx || h > maxPx) {
        if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; }
        else        { w = Math.round(w * maxPx / h); h = maxPx; }
      }
      const canvas = document.createElement('canvas');
      canvas.width = w; canvas.height = h;
      canvas.getContext('2d').drawImage(img, 0, 0, w, h);
      canvas.toBlob(b => b ? resolve(b) : reject(new Error('Compression failed')), 'image/jpeg', quality);
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Image load failed')); };
    img.src = url;
  });
}

async function storeBlob(blob) {
  // Use random key only — no timestamp to avoid leaking when photos were taken
  const rnd = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(36).padStart(2, '0')).join('').slice(0, 24);
  const k = 'img_' + rnd;
  await idbPut(k, blob);
  imgCache[k] = URL.createObjectURL(blob);
  return k;
}

const _IMG_CACHE_MAX = 80; // max object URLs in memory
const _imgLru = [];        // most-recently-used keys (front = newest)
const _imgFetching = new Set(); // keys currently being fetched

function _imgEvict() {
  while (_imgLru.length > _IMG_CACHE_MAX) {
    const old = _imgLru.pop();
    if (imgCache[old]) { URL.revokeObjectURL(imgCache[old]); delete imgCache[old]; }
  }
}

function _imgTouch(k) {
  const i = _imgLru.indexOf(k);
  if (i > 0) _imgLru.splice(i, 1);
  if (i !== 0) _imgLru.unshift(k);
}

function imgURL(k) {
  if (!k) return null;
  if (imgCache[k]) { _imgTouch(k); return imgCache[k]; }
  // Lazy fetch — returns null now, loads in background
  if (!_imgFetching.has(k)) {
    _imgFetching.add(k);
    idbGet(k).then(b => {
      _imgFetching.delete(k);
      if (b) {
        imgCache[k] = URL.createObjectURL(b);
        _imgTouch(k);
        _imgEvict();
        if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(k);
        if (typeof _cmpImgReady === 'function') _cmpImgReady(k);
        // Buddy photo keys (c_<slice16>_<entryId>_front) drive dashboard buddy
        // cards via <img src> -- rerenderPhotoCanvases only repaints <canvas>,
        // so without this the dashboard sits stale until next nav. Fixes the
        // "one buddy's image doesn't appear until I navigate away and back" bug.
        if (k.startsWith('c_') && typeof renderDash === 'function') renderDash();
        // Own gallery item keys (img_...) feed the linked-gallery thumbnail row
        // in the currently-viewed journal entry. Same problem as above -- <img>
        // tags don't get the rerenderPhotoCanvases treatment, so without this
        // hook the thumbnails sit as 📷 placeholders until the user navigates.
        if (k.startsWith('img_') && typeof _selEntryId !== 'undefined' && _selEntryId
            && typeof renderEntryViewer === 'function') {
          renderEntryViewer(_selEntryId);
        }
      }
    }).catch(e => { _imgFetching.delete(k); console.error('[storage] imgURL fetch/decrypt failed:', k, e); });
  }
  return null;
}

async function preloadImgs() {
  // Preload only the most recent entries (visible on dashboard/journal first page)
  const entries = getEntries();
  const keys = new Set();
  const recentEntries = entries.slice(0, 30);
  recentEntries.forEach(e => {
    if (e.imgFront) keys.add(e.imgFront);
  });
  // Also preload contact images — but only if the contact allows caching
  const contactKeysToEvict = [];
  getContacts().forEach(c => {
    if (!c.journal) return;
    const mutualTrust = !!(c.trusted && c.senderTrusts);
    const senderAllowsCache = !!c.senderAllowsCache;
    const prefix = `c_${(c.pubKeyB64 || '').slice(0, 16)}_`;
    if (mutualTrust || senderAllowsCache) {
      // Only preload the most recent buddy entry thumbnail
      const latest = (c.journal.entries || [])[0];
      if (latest) keys.add(`${prefix}${latest.id}_front`);
    } else {
      // Not allowed to cache — mark for eviction
      (c.journal.entries || []).forEach(e => {
        contactKeysToEvict.push(`${prefix}${e.id}_front`);
      });
    }
  });
  await Promise.all([...keys].map(async k => {
    if (!imgCache[k]) {
      const b = await idbGet(k);
      if (b) {
        imgCache[k] = URL.createObjectURL(b);
        _imgTouch(k);
      }
    }
  }));
  _imgEvict();
  // Clean up any buddy photos that shouldn't be persisted (permission was revoked
  // or sender doesn't allow caching). Delete from IDB so they don't survive next reload.
  if (contactKeysToEvict.length) {
    for (const k of contactKeysToEvict) {
      try {
        const b = await idbGet(k);
        if (b) {
          await idbDel(k);
          if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
        }
      } catch(e) { /* non-critical */ }
    }
  }
}

// ── Startup consistency check: detect orphan references & orphan blobs ────────
// Runs once per session after preloadImgs. Non-destructive: clears dangling
// references from entries and logs orphan blob keys (doesn't auto-delete blobs).
async function runConsistencyCheck() {
  try {
    const entries = getEntries();
    const allBlobKeys = new Set(await idbGetAllKeys());
    let orphanRefs = 0;
    let dirty = false;

    // Phase 1: entries referencing blobs that don't exist → clear the reference
    for (const e of entries) {
      if (e.imgFront && !allBlobKeys.has(e.imgFront)) {
        console.warn(`[consistency] entry ${e.id} references missing blob ${e.imgFront} — clearing`);
        e.imgFront = null;
        orphanRefs++;
        dirty = true;
      }
      if (e.wsScan && typeof e.wsScan === 'object') {
        for (const field of ['photoKey', 'displayKey', 'edgeKey', 'overlayKey']) {
          if (e.wsScan[field] && !allBlobKeys.has(e.wsScan[field])) {
            console.warn(`[consistency] entry ${e.id} wsScan.${field} references missing blob — clearing`);
            e.wsScan[field] = null;
            orphanRefs++;
            dirty = true;
          }
        }
      }
    }
    if (dirty) saveEntries(entries);

    // Phase 2: blobs not referenced by any entry or contact → log (don't delete)
    const referencedKeys = new Set();
    // Keypair and meta keys — always referenced
    referencedKeys.add('frj_keypair_pub');
    referencedKeys.add('frj_keypair_priv');
    for (const e of entries) {
      if (e.imgFront) referencedKeys.add(e.imgFront);
      if (e.wsScan) {
        for (const f of ['photoKey', 'displayKey', 'edgeKey', 'overlayKey']) {
          if (e.wsScan[f]) referencedKeys.add(e.wsScan[f]);
        }
      }
    }
    // Contact blob keys (c_pubkey_entryId_field)
    const contacts = typeof getContacts === 'function' ? getContacts() : [];
    for (const c of contacts) {
      if (!c.journal?.entries) continue;
      const prefix = `c_${(c.pubKeyB64 || '').slice(0, 16)}_`;
      for (const e of c.journal.entries) {
        referencedKeys.add(`${prefix}${e.id}_front`);
      }
    }
    let orphanBlobs = 0;
    for (const k of allBlobKeys) {
      if (!referencedKeys.has(k) && k.startsWith('img_')) orphanBlobs++;
    }
    if (orphanRefs) console.log(`[consistency] cleared ${orphanRefs} orphan photo references`);
    if (orphanBlobs) console.log(`[consistency] found ${orphanBlobs} unreferenced blobs (run cleanup from settings to free space)`);
  } catch(e) {
    console.warn('[consistency] check failed (non-critical):', e);
  }
}

function uuid() {
  return crypto.randomUUID ? crypto.randomUUID()
    : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
        const r = Math.random() * 16 | 0;
        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
      });
}

// ── localStorage helpers ──
function lsGet(k, def) {
  const dflt = def !== undefined ? def : null;
  // Schema v2: read from in-memory decrypted cache
  if (typeof _frjEncCache !== 'undefined' && _frjEncCache !== null) {
    return Object.prototype.hasOwnProperty.call(_frjEncCache, k) ? _frjEncCache[k] : dflt;
  }
  try {
    const v = localStorage.getItem(k);
    if (v === null) return dflt;
    return JSON.parse(v);
  } catch { return dflt; }
}
let _syncMerging = false; // suppresses auto-sync during incoming merge writes

const _SYNC_KEYS = new Set([
  'frj_entries', 'frj_contacts', 'frj_profile', 'frj_displayname',
  'frj_vanish_settings', 'frj_buddy_permissions', 'frj_watermark_settings',
  'frj_timer_sessions', 'frj_active_timer', 'frj_weekly_stats',
  'frj_deleted', 'frj_public_profile',
  'frj_galleries', 'frj_gallery_notes',
  'frj_gallery_received', 'frj_gallery_buddy_notes', 'frj_gallery_kots',
  'frj_gallery_kots_sent', 'frj_gallery_notes_sent',
  'frj_gallery_revoke_q', 'frj_gallery_left',
  'frj_lineups'
]);

function lsSet(k, v) {
  // Block writes when encryption is enabled but session is locked
  if (typeof isEncryptionLocked === 'function' && isEncryptionLocked()) {
    console.warn('[storage] lsSet blocked — encryption locked:', k);
    return;
  }
  // Schema v2: write to cache + async flush to IDB
  if (typeof _frjEncCache !== 'undefined' && _frjEncCache !== null) {
    _frjEncCache[k] = v;
    if (typeof _frjEncFlushKey === 'function') _frjEncFlushKey(k, v); // fire-and-forget
  } else {
    try { localStorage.setItem(k, JSON.stringify(v)); } catch(e) { console.error('LS error', e); }
  }
  // Auto-sync: debounced sync for data-relevant keys (suppressed during incoming merges)
  if (_SYNC_KEYS.has(k) && !_syncMerging && typeof syncAfterSave === 'function') syncAfterSave();
}

// ── Domain getters/setters ──
function getEntries() { return lsGet('frj_entries', []); }
function saveEntries(arr) { lsSet('frj_entries', arr); }
function getContacts() { return lsGet('frj_contacts', []); }
function saveContacts(arr) { lsSet('frj_contacts', arr); if (typeof invalidateContactHashCache === 'function') invalidateContactHashCache(); }
function getWsZones() { return lsGet('frj_ws_zones', []); }
function saveWsZones(z) { lsSet('frj_ws_zones', z); }
function getWsSettings() {
  return lsGet('frj_ws_settings', { t1: 50, t2: 130, minLen: 40, dilRadius: 5 });
}
function saveWsSettings(s) { lsSet('frj_ws_settings', s); }

// ── Gallery ──────────────────────────────────────────────────────────────────
function getGalleries()         { return lsGet('frj_galleries', []); }
function saveGalleries(g)       { lsSet('frj_galleries', g); if (typeof syncAfterSave === 'function') syncAfterSave(); }
function getGalleryNotes()      { return lsGet('frj_gallery_notes', {}); }
function saveGalleryNotes(n)    { lsSet('frj_gallery_notes', n); }
function getGalleryReceived()      { return lsGet('frj_gallery_received', {}); }
function saveGalleryReceived(r)    { lsSet('frj_gallery_received', r); }
function getGalleryLeft()           { return lsGet('frj_gallery_left', {}); }
function saveGalleryLeft(l)        { lsSet('frj_gallery_left', l); }
// ── Saved comparisons ──
function getSavedComparisons()     { return lsGet('frj_comparisons', []); }
function saveSavedComparisons(c)   { lsSet('frj_comparisons', c); if (typeof syncAfterSave === 'function') syncAfterSave(); }

// ── Timeline Lineups ──
// Each lineup = a curated horizontal row of gallery items from the user's own
// galleries, used to compare the same perspective across time. Stored as an
// array so order is meaningful (lineups stack top-to-bottom in the order
// they appear in this array).
//   { id, name, createdAt, updatedAt, items: [{ galleryId, itemId, addedAt }] }
function getLineups()          { return lsGet('frj_lineups', []); }
function saveLineups(arr)      { lsSet('frj_lineups', arr); if (typeof syncAfterSave === 'function') syncAfterSave(); }

// ── Bookmarks ──
// Own bookmarks: { "type:id": { bookmarkedAt } } — type is 'journal', 'gal', or 'recv'
function getBookmarks()            { return lsGet('frj_bookmarks', {}); }
function saveBookmarks(b)          { lsSet('frj_bookmarks', b); if (typeof syncAfterSave === 'function') syncAfterSave(); }
// Received bookmark notifications: { "galId:itemId": [{ senderSlice16, senderName, bookmarkedAt }] }
function getBookmarksReceived()    { return lsGet('frj_bookmarks_rcvd', {}); }
function saveBookmarksReceived(b)  { lsSet('frj_bookmarks_rcvd', b); if (typeof syncAfterSave === 'function') syncAfterSave(); }

// ── Check-Ins ──
// Own: [{ id, imgKey, createdAt }]
function getCheckIns()             { return lsGet('frj_checkins', []); }
function saveCheckIns(c)           { lsSet('frj_checkins', c); if (typeof syncAfterSave === 'function') syncAfterSave(); }
// Received: { pubKeySlice16: [{ id, imgKey, createdAt, senderName }] }
function getCheckInsReceived()     { return lsGet('frj_checkins_rcvd', {}); }
function saveCheckInsReceived(c)   { lsSet('frj_checkins_rcvd', c); if (typeof syncAfterSave === 'function') syncAfterSave(); }

// ── Gallery deletion tracking (prevents device sync from resurrecting deleted items) ──
function getGalleryDeleted()       { return lsGet('frj_gallery_deleted', { galleries: [], items: [] }); }
function saveGalleryDeleted(d)     { lsSet('frj_gallery_deleted', d); }

// ── Quick Checks ──
function getQuickChecks()          { return lsGet('frj_quickchecks', []); }
function saveQuickChecks(arr)      { lsSet('frj_quickchecks', arr); if (typeof syncAfterSave === 'function') syncAfterSave(); }
function getQuickCheckQueue()      { return lsGet('frj_quickcheck_queue', {}); }
function saveQuickCheckQueue(q)    { lsSet('frj_quickcheck_queue', q); }

function getGalleryRevokeQueue()   { return lsGet('frj_gallery_revoke_q', {}); }
function saveGalleryRevokeQueue(q) { lsSet('frj_gallery_revoke_q', q); }
function getGalleryBuddyNotes()    { return lsGet('frj_gallery_buddy_notes', {}); }
function saveGalleryBuddyNotes(n)  { lsSet('frj_gallery_buddy_notes', n); }

// ── Gallery sent notes (sender-side tracking) ──
function getGallerySentNotes()   { return lsGet('frj_gallery_notes_sent', {}); }
function saveGallerySentNotes(n) { lsSet('frj_gallery_notes_sent', n); }
function getGallerySentNote(ownerSlice16, galleryId, itemId) {
  return getGallerySentNotes()[ownerSlice16 + ':' + galleryId + ':' + itemId] || null;
}
function recordGallerySentNote(ownerSlice16, galleryId, itemId, text) {
  const s = getGallerySentNotes();
  s[ownerSlice16 + ':' + galleryId + ':' + itemId] = { text, sentAt: new Date().toISOString() };
  saveGallerySentNotes(s);
}

// ── Gallery KOT reactions ──
// frj_gallery_kots: { "galleryId:itemId": [ { senderSlice16, fromAvatar, fromName, timestamp } ] }
function getGalleryKots()          { return lsGet('frj_gallery_kots', {}); }
function saveGalleryKots(obj)      { lsSet('frj_gallery_kots', obj); }
function getGalleryKotsForItem(galleryId, itemId) { return getGalleryKots()[galleryId + ':' + itemId] || []; }
function addGalleryKot(galleryId, itemId, senderSlice16, fromAvatar, fromName) {
  const all = getGalleryKots();
  const key = galleryId + ':' + itemId;
  if (!all[key]) all[key] = [];
  if (!all[key].some(k => k.senderSlice16 === senderSlice16)) {
    all[key].push({ senderSlice16, fromAvatar: fromAvatar || null, fromName: fromName || null, timestamp: new Date().toISOString() });
    saveGalleryKots(all);
  }
}
// Sender-side tracking
function getGalleryKotsSent()                          { return lsGet('frj_gallery_kots_sent', {}); }
function saveGalleryKotsSent(obj)                      { lsSet('frj_gallery_kots_sent', obj); }
function getSentGalleryKot(ownerSlice16, galleryId, itemId) { return getGalleryKotsSent()[ownerSlice16 + ':' + galleryId + ':' + itemId] || null; }
function recordGalleryKotSent(ownerSlice16, galleryId, itemId) {
  const s = getGalleryKotsSent();
  s[ownerSlice16 + ':' + galleryId + ':' + itemId] = { timestamp: new Date().toISOString() };
  lsSet('frj_gallery_kots_sent', s);
}
// Queue for offline delivery
function getGalleryKotQueue()    { return lsGet('frj_gallery_kot_queue', {}); }
function saveGalleryKotQueue(q)  { lsSet('frj_gallery_kot_queue', q); }
function queueGalleryKot(toPubKey, galleryId, itemId) {
  const q = getGalleryKotQueue();
  if (!q[toPubKey]) q[toPubKey] = [];
  if (!q[toPubKey].some(k => k.galleryId === galleryId && k.itemId === itemId)) {
    q[toPubKey].push({ galleryId, itemId });
  }
  saveGalleryKotQueue(q);
}
function dequeueGalleryKotsFor(toPubKey) {
  const q = getGalleryKotQueue();
  const items = q[toPubKey] || [];
  delete q[toPubKey];
  saveGalleryKotQueue(q);
  return items;
}

function getEntry(id) {
  return getEntries().find(e => e.id === id) || null;
}

function updateEntry(id, patch) {
  const arr = getEntries();
  const i = arr.findIndex(e => e.id === id);
  if (i >= 0) {
    arr[i] = { ...arr[i], ...patch };
    saveEntries(arr);
    return arr[i];
  }
  return null;
}

function deleteEntry(id) {
  const arr = getEntries();
  const e = arr.find(x => x.id === id);
  if (e) {
    ['imgFront'].map(f => e[f]).filter(Boolean).forEach(idbDel);
    // Clean up wsScan photo blobs (wrinkle scan images)
    if (e.wsScan && typeof e.wsScan === 'object') {
      ['photoKey', 'displayKey', 'edgeKey', 'overlayKey']
        .map(f => e.wsScan[f]).filter(Boolean).forEach(idbDel);
    }
  }
  saveEntries(arr.filter(e => e.id !== id));
  // Record tombstone so deletion propagates to paired devices on next sync
  try {
    const del = lsGet('frj_deleted', {});
    del[id] = Date.now();
    lsSet('frj_deleted', del);
  } catch(e2) { /* non-critical */ }
  if (typeof syncAfterSave === 'function') syncAfterSave();
}

// Entry schema version — bump when adding/changing entry fields.
// Sync and import use this to upgrade older entries and accept newer ones.
const ENTRY_SCHEMA_VERSION = 2;

function createEntry(overrides) {
  const today = new Date().toISOString().slice(0, 10);
  return {
    id: uuid(),
    v: ENTRY_SCHEMA_VERSION,
    date: today,
    ci: 3,
    pctTugged: null,
    pctRetained: null,
    avgHrsTugged: null,
    avgHrsRetained: null,
    tuggingDevices: [],
    retainingDevices: [],
    imgFront: null,
    imgPrivate: false,
    linkedGalleries: [],
    supplements: '',
    bmi: null,
    notes: '',
...overrides
  };
}

// Upgrade an entry from an older schema version to current.
// Returns the entry with any missing fields backfilled.
function upgradeEntrySchema(entry) {
  if (!entry || typeof entry !== 'object') return entry;
  const v = entry.v || 1;
  if (v >= ENTRY_SCHEMA_VERSION) return entry;
  // v1 → v2: add missing fields with safe defaults
  if (v < 2) {
    if (entry.pctTugged === undefined)       entry.pctTugged = null;
    if (entry.pctRetained === undefined)     entry.pctRetained = null;
    if (entry.avgHrsTugged === undefined)    entry.avgHrsTugged = null;
    if (entry.avgHrsRetained === undefined)  entry.avgHrsRetained = null;
    if (!Array.isArray(entry.tuggingDevices))   entry.tuggingDevices = [];
    if (!Array.isArray(entry.retainingDevices)) entry.retainingDevices = [];
    if (entry.imgPrivate === undefined)      entry.imgPrivate = false;
    if (entry.supplements === undefined)     entry.supplements = '';
    if (entry.bmi === undefined)             entry.bmi = null;
    if (entry.notes === undefined)           entry.notes = '';
  }
  // Future: if (v < 3) { ... }
  entry.v = ENTRY_SCHEMA_VERSION;
  return entry;
}

// ── Weekly activity stats (separate from journal entries) ──
function getWeeklyStats() { return lsGet('frj_weekly_stats', []); }
function saveWeeklyStats(arr) { lsSet('frj_weekly_stats', arr); }


// ── User profile ──
function getProfile() { return lsGet('frj_profile', {}); }
function saveProfile(p) { lsSet('frj_profile', p); }

// ── Badge computation ─────────────────────────────────────────────────────────
// Badges are always derived fresh from current data — never stored as awarded.
// Returns an array of badge objects: { emoji, label, tooltip, large }
// Pass data explicitly to reuse for buddy profiles.
function computeBadgesFrom(entries, profile, contacts) {
  const badges = [];

  const sorted = [...entries].sort((a, b) => a.date.localeCompare(b.date));
  const oldest = sorted[0] || null;
  const latest = sorted[sorted.length - 1] || null;

  // ── Journey badge (years tugging) ──
  {
    const startStr = profile.startDate || oldest?.date;
    if (startStr) {
      const startMs = new Date(startStr + (startStr.length === 10 ? 'T00:00:00' : '')).getTime();
      const endMs = profile.fullyRestoredOn
        ? new Date(profile.fullyRestoredOn + 'T00:00:00').getTime()
        : Date.now();
      const yrs = (endMs - startMs) / (365.25 * 24 * 3600 * 1000);
      const JOURNEY = { 7: '🌲', 6: '🌳', 5: '🌿', 4: '🍀', 3: '☘️', 2: '🌱', 1: '💚' };
      const tier = Math.min(7, Math.floor(yrs));
      const emoji = JOURNEY[tier];
      if (emoji) {
        const yr = Math.floor(yrs);
        badges.push({ emoji, label: `Year ${yr}`, tooltip: `Tugging for ${yr} year${yr !== 1 ? 's' : ''}` });
      }
    }
  }

  // ── CI gained badge (transforming) ──
  const startCI = profile.startingCI ?? (oldest?.ci ?? null);
  const currentCI = latest?.ci ?? null;
  if (startCI != null && currentCI != null && currentCI > startCI) {
    const gained = currentCI - startCI;
    badges.push({ emoji: '📈', label: `+${gained}`, tooltip: `CI gained: ${startCI} → ${currentCI}` });
  }

  // ── Fully Restored (one-time, largest) ──
  if (profile.fullyRestoredOn) {
    const [yr, mo] = profile.fullyRestoredOn.split('-');
    const moName = new Date(+yr, +mo - 1, 1).toLocaleString('default', { month: 'long' });
    badges.push({ emoji: '👑', label: 'Restored', tooltip: `Fully restored ${moName} ${yr}`, large: true });
  }

  // ── Multi-Method (one-time) ──
  const allDevices = new Set();
  entries.forEach(e => {
    (e.tuggingDevices || []).forEach(d => allDevices.add(d));
    (e.retainingDevices || []).forEach(d => allDevices.add(d));
  });
  if (allDevices.size > 2) {
    badges.push({ emoji: '🔧', label: 'Multi-Method', tooltip: `Used ${allDevices.size} different devices` });
  }

  // ── Buddy badges (one-time) ──
  const connectedContacts = contacts.filter(c => !!c.journal);
  const trustedContacts = contacts.filter(c => c.trusted && c.senderTrusts);
  if (connectedContacts.length >= 5) {
    badges.push({ emoji: '🌐', label: '5 Buddies', tooltip: `${connectedContacts.length} buddies connected` });
  }
  if (trustedContacts.length >= 3) {
    badges.push({ emoji: '💛', label: '3 Trusted', tooltip: `${trustedContacts.length} trusted buddies` });
  }

  return badges;
}

function computeBadges() {
  return computeBadgesFrom(getEntries(), getProfile(), getContacts());
}

// ── CI Milestone entries ───────────────────────────────────────────────────────

function getMilestoneEntryIds(entries) {
  // Returns Set of entry IDs where CI reached a new personal best
  const sorted = [...entries].filter(e => e.ci != null).sort((a, b) => a.date.localeCompare(b.date));
  let maxCI = -1;
  const milestones = new Set();
  for (const e of sorted) {
    if (e.ci > maxCI) { maxCI = e.ci; milestones.add(e.id); }
  }
  return milestones;
}


// ── Buddy notes (received on my entries) ──────────────────────────────────────
// frj_buddy_notes: { entryId: [{ fromPubKey, fromAvatar, fromName, content, timestamp, editedAt }] }
function getBuddyNotes()     { return lsGet('frj_buddy_notes', {}); }
function saveBuddyNotes(n)   { lsSet('frj_buddy_notes', n); }
function getNotesForEntry(entryId) { return getBuddyNotes()[entryId] || []; }

function addReceivedNote(myEntryId, fromPubKey, fromAvatar, fromName, content, timestamp, editedAt) {
  const all = getBuddyNotes();
  const list = all[myEntryId] || [];
  const idx = list.findIndex(n => n.fromPubKey === fromPubKey);
  const note = { fromPubKey, fromAvatar, fromName, content, timestamp, editedAt: editedAt || null };
  if (idx >= 0) { list[idx] = note; } else { list.push(note); }
  all[myEntryId] = list;
  saveBuddyNotes(all);
  // Mark as unread
  const unread = lsGet('frj_notes_unread', []);
  if (!unread.includes(myEntryId)) { unread.push(myEntryId); lsSet('frj_notes_unread', unread); }
  return true;
}

function hasUnreadNotes() { return lsGet('frj_notes_unread', []).length > 0; }
function markNotesSeen(entryId) {
  const unread = lsGet('frj_notes_unread', []).filter(id => id !== entryId);
  lsSet('frj_notes_unread', unread);
}
function clearAllUnreadNotes() { lsSet('frj_notes_unread', []); }

// frj_notes_sent: { "pubKey|entryId": { content, timestamp } } — notes I've sent
function getNotesSent()       { return lsGet('frj_notes_sent', {}); }
function getSentNote(contactPubKey, entryId) { return getNotesSent()[contactPubKey + '|' + entryId] || null; }
function recordNoteSent(contactPubKey, entryId, content) {
  const s = getNotesSent();
  s[contactPubKey + '|' + entryId] = { content, timestamp: new Date().toISOString() };
  lsSet('frj_notes_sent', s);
}

// frj_notes_queue: { "pubKey|entryId": { content, timestamp } } — pending delivery
function getNotesQueue()           { return lsGet('frj_notes_queue', {}); }
function saveNotesQueue(q)         { lsSet('frj_notes_queue', q); }
function queueNote(toPubKey, entryId, content, editedAt = null) {
  const q = getNotesQueue();
  q[toPubKey + '|' + entryId] = { content, timestamp: new Date().toISOString(), editedAt };
  // Cap at 500 entries — evict oldest if exceeded
  const keys = Object.keys(q);
  if (keys.length > 500) {
    const sorted = keys.sort((a, b) => (q[a].timestamp || '') < (q[b].timestamp || '') ? -1 : 1);
    for (let i = 0; i < sorted.length - 500; i++) delete q[sorted[i]];
  }
  saveNotesQueue(q);
}
function dequeueNotesFor(toPubKey) {
  const q = getNotesQueue();
  const due = Object.entries(q)
    .filter(([k]) => k.startsWith(toPubKey + '|'))
    .map(([k, v]) => ({ entryId: k.split('|')[1], content: v.content, timestamp: v.timestamp }));
  const remaining = Object.fromEntries(Object.entries(q).filter(([k]) => !k.startsWith(toPubKey + '|')));
  saveNotesQueue(remaining);
  return due;
}


// ── KOT (Keep on Tugging) reactions ──
// frj_kots: { entryId: [ { fromPubKey, fromAvatar, fromName, timestamp } ] }
function getKots()                     { return lsGet('frj_kots', {}); }
function saveKots(obj)                 { lsSet('frj_kots', obj); }
function getKotsForEntry(entryId)      { return getKots()[entryId] || []; }
// frj_kots_sent: { "pubKey|entryId": { timestamp } }
function getKotsSent()                 { return lsGet('frj_kots_sent', {}); }
function getSentKot(pubKey, entryId)   { return getKotsSent()[pubKey + '|' + entryId] || null; }
function recordKotSent(pubKey, entryId) {
  const s = getKotsSent();
  s[pubKey + '|' + entryId] = { timestamp: new Date().toISOString() };
  lsSet('frj_kots_sent', s);
}
function addReceivedKot(entryId, fromPubKey, fromAvatar, fromName, timestamp) {
  const all = getKots();
  const list = all[entryId] || [];
  if (list.some(k => k.fromPubKey === fromPubKey)) return; // idempotent — no duplicates
  list.push({ fromPubKey, fromAvatar, fromName, timestamp });
  all[entryId] = list;
  saveKots(all);
}

// frj_kot_queue: { pubKey: [entryId, …] } — KOTs to deliver when peer comes online
function getKotQueue()         { return lsGet('frj_kot_queue', {}); }
function saveKotQueue(q)       { lsSet('frj_kot_queue', q); }
function queueKot(toPubKey, entryId) {
  const q = getKotQueue();
  if (!q[toPubKey]) q[toPubKey] = [];
  if (!q[toPubKey].includes(entryId)) q[toPubKey].push(entryId);
  saveKotQueue(q);
}
function dequeueKotsFor(toPubKey) {
  const q = getKotQueue();
  const due = q[toPubKey] || [];
  delete q[toPubKey];
  saveKotQueue(q);
  return due;
}

// ── Per-peer chat history ──
function _chatKey(pubKeyB64) { return 'frj_chat_' + pubKeyB64.slice(0, 16); }
function getChatHistory(pubKeyB64) { return lsGet(_chatKey(pubKeyB64), []); }
function saveChatHistory(pubKeyB64, msgs) { lsSet(_chatKey(pubKeyB64), msgs); }
function appendChat(pubKeyB64, msg) {
  const hist = getChatHistory(pubKeyB64);
  hist.push(msg);
  if (hist.length > 500) hist.splice(0, hist.length - 500);
  saveChatHistory(pubKeyB64, hist);
}
function _chatUnreadKey(pubKeyB64) { return 'frj_chat_unread_' + pubKeyB64.slice(0, 16); }
function getChatUnread(pubKeyB64) { return lsGet(_chatUnreadKey(pubKeyB64), 0); }
function incChatUnread(pubKeyB64) { lsSet(_chatUnreadKey(pubKeyB64), getChatUnread(pubKeyB64) + 1); }
function clearChatUnread(pubKeyB64) { lsSet(_chatUnreadKey(pubKeyB64), 0); }

// ── Vanish settings ─────────────────────────────────────────────────────────
// frj_vanish_settings: { enabled: true, ttl: 90 } — days before sender's data clears from non-trusted buddies' devices
function getVanishSettings() { return lsGet('frj_vanish_settings', { enabled: true, ttl: 90, trustedTTL: 0 }); }
function saveVanishSettings(s) { lsSet('frj_vanish_settings', s); }

// ── Buddy permissions v2 ──────────────────────────────────────────────────────
// frj_buddy_permissions v2: { v:2, buddy:{…}, trusted:{…}, showBuddyTimerStatus }
// Each tier: { timerStatus, viewLatestImg, cacheLatestImg, viewAllImgs, cacheAllImgs }
// Dependency invariants enforced at save time:
//   viewLatestImg  is required by  cacheLatestImg, viewAllImgs, cacheAllImgs
//   viewAllImgs    is required by  cacheAllImgs
const _BUDDY_PERM_DEFAULTS = {
  v: 4,
  buddy:   { timerStatus: true, viewLatestImg: false, cacheLatestImg: false, viewAllImgs: false, cacheAllImgs: false, noWatermark: false, shareBuddyList: true },
  trusted: { timerStatus: true, viewLatestImg: false, cacheLatestImg: false, viewAllImgs: false, cacheAllImgs: false, noWatermark: false, shareBuddyList: true },
  showBuddyTimerStatus: true,
  requireMutualPhoto: true,
  hideMeFromBuddyLists: false,
};

function _migrateBuddyPermsV1(old) {
  // v1 flat format → v4. Trusted buddies in v1 always had full photo access.
  const viewLatest  = !!old.allowBuddyPhotoView;
  const cacheLatest = viewLatest && !!old.allowBuddyPhoto;
  return {
    v: 4,
    buddy:   { timerStatus: true, viewLatestImg: viewLatest,  cacheLatestImg: cacheLatest, viewAllImgs: false, cacheAllImgs: false, shareBuddyList: true },
    trusted: { timerStatus: true, viewLatestImg: true,        cacheLatestImg: true,        viewAllImgs: true,  cacheAllImgs: true,  shareBuddyList: true },
    showBuddyTimerStatus: true,
    requireMutualPhoto: true,
    hideMeFromBuddyLists: false,
  };
}

function _enforceBuddyTierConstraints(tier) {
  if (!tier) return tier;
  if (!tier.viewLatestImg) { tier.cacheLatestImg = false; tier.viewAllImgs = false; tier.cacheAllImgs = false; }
  if (!tier.viewAllImgs)   { tier.cacheAllImgs = false; }
  return tier;
}

function getBuddyPermissions() {
  const raw = lsGet('frj_buddy_permissions', null);
  if (!raw || typeof raw !== 'object') return JSON.parse(JSON.stringify(_BUDDY_PERM_DEFAULTS));
  if (!raw.v || raw.v < 2) {
    const migrated = _migrateBuddyPermsV1(raw);
    lsSet('frj_buddy_permissions', migrated);
    return migrated;
  }
  if (raw.v === 2) {
    // v2→v3: enable timer status sharing by default (was always-off in v2)
    const migrated = Object.assign({}, raw, {
      v: 3,
      buddy:   Object.assign({}, raw.buddy,   { timerStatus: true }),
      trusted: Object.assign({}, raw.trusted, { timerStatus: true }),
      showBuddyTimerStatus: true,
    });
    lsSet('frj_buddy_permissions', migrated);
    // fall through to v3→v4
    raw = migrated;
  }
  if (raw.v === 3) {
    // v3→v4: add buddy list sharing (on by default)
    const migrated = Object.assign({}, raw, {
      v: 4,
      buddy:   Object.assign({}, raw.buddy,   { shareBuddyList: true }),
      trusted: Object.assign({}, raw.trusted, { shareBuddyList: true }),
      hideMeFromBuddyLists: false,
    });
    lsSet('frj_buddy_permissions', migrated);
    return migrated;
  }
  return raw;
}

function saveBuddyPermissions(p) {
  if (p.buddy)   _enforceBuddyTierConstraints(p.buddy);
  if (p.trusted) _enforceBuddyTierConstraints(p.trusted);
  lsSet('frj_buddy_permissions', p);
}

// ── Watermark settings ───────────────────────────────────────────────────────
// frj_watermark_settings: { style: 'none'|'center'|'tiled'|'corner'|'combined', opacity: 'light'|'medium'|'strong' }
function getWatermarkSettings() { return lsGet('frj_watermark_settings', { style: 'none', opacity: 'medium' }); }
function saveWatermarkSettings(s) { lsSet('frj_watermark_settings', s); }

// ── Public profile visibility ────────────────────────────────────────────────
// frj_public_profile: { showCurrentCI, showTargetCI, showStartingCI, showTimeRestoring, showAchievements }
// Controls what is shared when user is in Public mode in the buddy network.
const _PUBLIC_PROFILE_DEFAULTS = { showName: false, showCurrentCI: false, showTargetCI: false, showStartingCI: false, showTimeRestoring: false, showAchievements: false };
function getPublicProfileSettings() { return lsGet('frj_public_profile', _PUBLIC_PROFILE_DEFAULTS); }
function savePublicProfileSettings(s) { lsSet('frj_public_profile', s); }

// Clears all cached data for a contact (journal + IDB blobs) and marks them as vanished.
// Does NOT remove the contact card — they can reconnect naturally.
async function clearContactCachedData(pubKeyB64) {
  const contacts = getContacts();
  const i = contacts.findIndex(c => c.pubKeyB64 === pubKeyB64);
  if (i >= 0) {
    contacts[i].journal = null;
    contacts[i].vanished = true;
    delete contacts[i].pendingRemoval;
    saveContacts(contacts);
  }
  // Delete cached buddy photos and gallery images from IDB
  const prefix    = `c_${pubKeyB64.slice(0, 16)}_`;
  const galPrefix = `gal_${pubKeyB64.slice(0, 16)}_`;
  try {
    const allKeys = await idbGetAllKeys();
    await Promise.all(
      allKeys.filter(k => k.startsWith(prefix) || k.startsWith(galPrefix)).map(k => idbDel(k))
    );
  } catch(e) { console.warn('clearContactCachedData IDB error:', e); }
  // Revoke in-memory image URLs for this buddy (journal + gallery)
  if (typeof imgCache !== 'undefined') {
    for (const k of Object.keys(imgCache)) {
      if (k.startsWith(prefix) || k.startsWith(galPrefix)) {
        URL.revokeObjectURL(imgCache[k]); delete imgCache[k];
      }
    }
  }
  // Clear received galleries and left galleries from this contact
  try {
    const recv = getGalleryReceived();
    const slice16 = pubKeyB64.slice(0, 16);
    if (recv[slice16]) { delete recv[slice16]; saveGalleryReceived(recv); }
    const left = getGalleryLeft();
    if (left[slice16]) { delete left[slice16]; saveGalleryLeft(left); }
  } catch(e) { console.warn('clearContactCachedData gallery recv error:', e); }
  // Clear chat history for this buddy
  const chatKey = 'frj_chat_' + pubKeyB64.slice(0, 16);
  try { localStorage.removeItem(chatKey); } catch(e) {}
  // Clear any queued notes for this buddy
  const noteQ = lsGet('frj_notes_queue', {});
  let qChanged = false;
  for (const k of Object.keys(noteQ)) {
    if (k.startsWith(pubKeyB64 + '|') || k.startsWith(pubKeyB64.slice(0, 16) + '|')) {
      delete noteQ[k]; qChanged = true;
    }
  }
  if (qChanged) lsSet('frj_notes_queue', noteQ);
  // Clear sent notes referencing this buddy
  const noteS = lsGet('frj_notes_sent', {});
  let sChanged = false;
  for (const k of Object.keys(noteS)) {
    if (k.startsWith(pubKeyB64 + '|') || k.startsWith(pubKeyB64.slice(0, 16) + '|')) {
      delete noteS[k]; sChanged = true;
    }
  }
  if (sChanged) lsSet('frj_notes_sent', noteS);
  // Strip this buddy's traces from journal-side reaction stores so removal is
  // immediate -- without this, KOT chips and comments lingered on my journal
  // entries until the next page render after a fresh data fetch.
  const slice16 = pubKeyB64.slice(0, 16);
  // frj_kots: { entryId: [{ fromPubKey, ... }] } -- filter out KOTs from this buddy
  try {
    const kots = lsGet('frj_kots', {});
    let kChanged = false;
    for (const eid of Object.keys(kots)) {
      const filtered = (kots[eid] || []).filter(k => k.fromPubKey !== pubKeyB64);
      if (filtered.length !== (kots[eid] || []).length) kChanged = true;
      if (filtered.length) kots[eid] = filtered;
      else delete kots[eid];
    }
    if (kChanged) lsSet('frj_kots', kots);
  } catch(e) {}
  // frj_buddy_notes: { entryId: [{ fromPubKey, ... }] } -- filter comments
  try {
    const bn = lsGet('frj_buddy_notes', {});
    let bnChanged = false;
    for (const eid of Object.keys(bn)) {
      const filtered = (bn[eid] || []).filter(n => n.fromPubKey !== pubKeyB64);
      if (filtered.length !== (bn[eid] || []).length) bnChanged = true;
      if (filtered.length) bn[eid] = filtered;
      else delete bn[eid];
    }
    if (bnChanged) lsSet('frj_buddy_notes', bn);
  } catch(e) {}
  // frj_kots_sent: { "pubKey|entryId": ... }
  try {
    const ks = lsGet('frj_kots_sent', {});
    let ksChanged = false;
    for (const k of Object.keys(ks)) {
      if (k.startsWith(pubKeyB64 + '|')) { delete ks[k]; ksChanged = true; }
    }
    if (ksChanged) lsSet('frj_kots_sent', ks);
  } catch(e) {}
  // Per-buddy outgoing queues -- drop entire pubKey slot
  for (const qKey of ['frj_kot_queue', 'frj_gallery_kot_queue', 'frj_quickcheck_queue']) {
    try {
      const q = lsGet(qKey, {});
      if (q[pubKeyB64]) { delete q[pubKeyB64]; lsSet(qKey, q); }
    } catch(e) {}
  }
  // frj_bookmarks_rcvd / frj_checkins_rcvd: { bmKey: [{ senderSlice16, ... }] }
  for (const recvKey of ['frj_bookmarks_rcvd', 'frj_checkins_rcvd']) {
    try {
      const obj = lsGet(recvKey, {});
      let rChanged = false;
      for (const k of Object.keys(obj)) {
        const filtered = (obj[k] || []).filter(s => s.senderSlice16 !== slice16);
        if (filtered.length !== (obj[k] || []).length) rChanged = true;
        if (filtered.length) obj[k] = filtered;
        else delete obj[k];
      }
      if (rChanged) lsSet(recvKey, obj);
    } catch(e) {}
  }
  // frj_gallery_kots: { "galleryId:itemId": [{ senderSlice16, ... }] } -- filter
  try {
    const gk = lsGet('frj_gallery_kots', {});
    let gkChanged = false;
    for (const k of Object.keys(gk)) {
      const filtered = (gk[k] || []).filter(x => x.senderSlice16 !== slice16);
      if (filtered.length !== (gk[k] || []).length) gkChanged = true;
      if (filtered.length) gk[k] = filtered;
      else delete gk[k];
    }
    if (gkChanged) lsSet('frj_gallery_kots', gk);
  } catch(e) {}
  // frj_gallery_buddy_notes: same shape as above -- filter by senderSlice16
  try {
    const gbn = lsGet('frj_gallery_buddy_notes', {});
    let gbnChanged = false;
    for (const k of Object.keys(gbn)) {
      const filtered = (gbn[k] || []).filter(x => x.senderSlice16 !== slice16);
      if (filtered.length !== (gbn[k] || []).length) gbnChanged = true;
      if (filtered.length) gbn[k] = filtered;
      else delete gbn[k];
    }
    if (gbnChanged) lsSet('frj_gallery_buddy_notes', gbn);
  } catch(e) {}
  // frj_gallery_kots_sent / frj_gallery_notes_sent: { "ownerSlice16:galleryId:itemId": ... }
  for (const sentKey of ['frj_gallery_kots_sent', 'frj_gallery_notes_sent']) {
    try {
      const s = lsGet(sentKey, {});
      let changed = false;
      for (const k of Object.keys(s)) {
        if (k.startsWith(slice16 + ':')) { delete s[k]; changed = true; }
      }
      if (changed) lsSet(sentKey, s);
    } catch(e) {}
  }
  // In-memory: drop live timer state so the dashboard doesn't keep showing
  // their tugging/retaining badge until the next periodic refresh.
  try {
    if (typeof _buddyTimerStates !== 'undefined' && _buddyTimerStates instanceof Map) {
      _buddyTimerStates.delete(pubKeyB64);
    }
  } catch(e) {}
}

// ── Blocked buddy-network hashes ──────────────────────────────────────────────
// Stores pubKeyHashes (16-char base64url) of restorers blocked from the buddy network.
// ── Timer sessions ─────────────────────────────────────────────────────────
const _TIMER_SESSION_CAP = 2000;

function getTimerSessions() { return lsGet('frj_timer_sessions', []); }
function saveTimerSessions(arr) {
  // Safety cap: keep newest 500 sessions
  if (arr.length > _TIMER_SESSION_CAP) {
    arr.sort((a, b) => (b.endedAt || b.startedAt || 0) - (a.endedAt || a.startedAt || 0));
    arr = arr.slice(0, _TIMER_SESSION_CAP);
  }
  lsSet('frj_timer_sessions', arr);
}
// Returns the active (running) timer, or null. Stop markers (stopped:true)
// are stored for LWW sync but treated as "no active timer" by callers.
function getActiveTimer() {
  const t = lsGet('frj_active_timer', null);
  return (t && !t.stopped) ? t : null;
}
// Raw read including stop markers — used by sync for LWW comparison.
function getActiveTimerRaw() { return lsGet('frj_active_timer', null); }
function setActiveTimer(obj) { lsSet('frj_active_timer', obj); }

// ── Timer session dissipation (disabled) ────────────────────────────────────
// Raw timer sessions are now preserved permanently. Weekly stats are derived
// views via _autoAggregateWeeks() — the canonical data is never destroyed.
function _dissipateOldTimerSessions() { /* no-op — raw sessions are immutable */ }

// ── Buddy chat (ephemeral — IDB store 'frj_chat', 30-day client TTL) ─────────
// Record shape: { id, peer: pubKeyB64.slice(0,16), dir: 'in'|'out', body: base64ciphertext,
//                sentAt: ms, deliveredAt: ms|null, purgeAt: sentAt+30d, failed: bool }
const CHAT_PURGE_MS = 30 * 24 * 3600 * 1000;

async function getChatMessages(peerSlice) {
  const all = await _idbChatGetAll();
  const now = Date.now();
  return all.filter(m => m.peer === peerSlice && m.purgeAt > now)
            .sort((a, b) => a.sentAt - b.sentAt);
}

async function saveChatMessage(msg) {
  await _idbChatPut(msg.id, msg);
}

async function markChatDelivered(id, deliveredAt) {
  const msg = await _idbChatGet(id);
  if (msg) await _idbChatPut(id, { ...msg, deliveredAt });
}

async function markChatFailed(id) {
  const msg = await _idbChatGet(id);
  if (msg) await _idbChatPut(id, { ...msg, failed: true });
}

async function deleteChatConversation(peerSlice) {
  const all = await _idbChatGetAll();
  for (const m of all) {
    if (m.peer === peerSlice) await _idbChatDel(m.id);
  }
}

async function purgeChatMessages() {
  const all = await _idbChatGetAll();
  const now = Date.now();
  for (const m of all) {
    if (m.purgeAt <= now) await _idbChatDel(m.id);
  }
}

function getBlockedHashes() { return lsGet('frj_blocked_hashes', []); }
function addBlockedHash(hash) {
  const list = getBlockedHashes();
  if (!list.includes(hash)) { list.push(hash); lsSet('frj_blocked_hashes', list); }
}
function removeBlockedHash(hash) {
  const list = getBlockedHashes().filter(h => h !== hash);
  lsSet('frj_blocked_hashes', list);
}
// Blocked contacts — stores full contact metadata for unblock UI
function getBlockedContacts() { return lsGet('frj_blocked_contacts', []); }
function saveBlockedContacts(list) { lsSet('frj_blocked_contacts', list); }

// ── Session KOT (live-tugging fist reaction) ──────────────────────────────────
// frj_session_kots_given: { [sessionId]: true } — sessions this user sent a KOT for
function getSessionKotsGiven()       { return lsGet('frj_session_kots_given', {}); }
function hasGivenSessionKot(sid)     { return !!getSessionKotsGiven()[sid]; }
function recordSessionKotGiven(sid)  { const m = getSessionKotsGiven(); m[sid] = true; lsSet('frj_session_kots_given', m); }
// frj_session_kots_rcvd: { [sessionId]: count } — KOTs received per own session
function getSessionKotsRcvd()        { return lsGet('frj_session_kots_rcvd', {}); }
function addSessionKotRcvd(sid)      { const m = getSessionKotsRcvd(); m[sid] = (m[sid] || 0) + 1; lsSet('frj_session_kots_rcvd', m); }
function getSessionKotCount(sid)     { return sid ? (getSessionKotsRcvd()[sid] || 0) : 0; }
