/**
 * app.js — Spaceship Frontend
 * Features: E2EE messaging, media, replies, reactions, typing,
 *           delivery receipts, notifications, offline queuing, search
 */

// ── Config ──────────────────────────────────────────────────
const BASE = (function(){
  var p=window.location.pathname;
  return p.substring(0,p.lastIndexOf('/')+1)+'api';
})();
const EMOJIS = [
  // Smileys & faces
  '😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','🫠','😉','😊','😇',
  '🥰','😍','🤩','😘','😗','😚','😙','🥲','😋','😛','😜','🤪','😝','🤑',
  '🤗','🤭','🫢','🫣','🤫','🤔','🫡','🤐','🤨','😐','😑','😶','🫥','😶‍🌫️',
  '😏','😒','🙄','😬','😮‍💨','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕',
  '🤢','🤮','🤧','🥵','🥶','🥴','😵','😵‍💫','🤯','🤠','🥳','🥸','😎','🤓','🧐',
  '😕','🫤','😟','🙁','☹️','😮','😯','😲','😳','🥺','🥹','😦','😧','😨',
  '😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬',
  // Hands & gestures
  '👍','👎','👏','🙌','👐','🤲','🤝','🙏','✌️','🤞','🫶','🤟','🤘','👌','🤌',
  '👋','🖐️','✋','🖖','💪','🫵','👆','👇','👈','👉',
  // Hearts & symbols
  '❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❤️‍🔥','💕','💞','💓','💗','💖','💘','💝',
  // More smileys, cats & expressions
  '😺','😸','😹','😻','😼','😽','🙀','😿','😾','🤡','👽','👾','💩','😈','👿','🤩','🥳','🫨','🫩','😎',
  // People & celebration
  '🤷','🤷‍♂️','🤷‍♀️','🤦','🙆','🙅','💁','🙋','🧏','🙇','🤳','💃','🕺','👯','🎅','🤶','🦸','🦹','🧙','🧚',
  // Animals
  '🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🐔','🐧','🦄','🐝','🦋',
  // Food & drink
  '🍏','🍎','🍌','🍓','🍒','🍑','🍍','🥝','🍅','🥑','🌮','🍕','🍔','🍟','🌭','🍿','🍩','🍪','🎂','☕',
  // Fun & misc
  '🔥','🎉','🎊','✨','⭐','🌟','💯','💥','💫','🎶','🎵','👀','💀','👻','🤖','🎮','🚀','🏆','🥇',
  '💎','🎁','🎈','🌈','☀️','🌙','⚡','❄️','🍀','🌹','💐','🎯','🎲','🕹️','📱','💡','✅','❌','❓','❗'
];
const ATTACH_TYPES = [
  {label:'Photo',icon:'🖼️',color:'#7C3AED',accept:'image/*'},
  {label:'Video',icon:'🎬',color:'#DC2626',accept:'video/*'},
  {label:'Audio',icon:'🎵',color:'#D97706',accept:'audio/*'},
  {label:'Document',icon:'📄',color:'#2563EB',accept:'.pdf,.doc,.docx,.txt,.zip'},
];

// ── Account-specific automatic logout ────────────────────────
// Auto-logout (signing the user out when they switch windows / leave the active
// page) is enabled for ONE account only. Every other account stays signed in
// when they switch tabs or background the app. To change which account this
// applies to, edit the username below. Comparison is case-insensitive.
const AUTO_LOGOUT_ACCOUNT = 'Joshz';
function autoLogoutEnabledForCurrentUser(){
  return !!(me && me.toLowerCase() === AUTO_LOGOUT_ACCOUNT.toLowerCase());
}

// ── GIF picker ───────────────────────────────────────────────
// GIF search goes through our own backend (api/gif.php), which holds the API key
// server-side and works with KLIPY (what WhatsApp uses), Tenor, or GIPHY. Pick a
// provider and paste a free key in api/config.php — see notes there.
var gifTrayBuilt=false, gifSearchTimer=null, gifLoadedOnce=false;

// ── Call / WebRTC connectivity ───────────────────────────────
// STUN alone only works when both peers can reach each other directly. On most
// mobile/carrier networks and many home routers the peers are behind symmetric
// NATs, so a TURN relay is REQUIRED for audio/video to actually flow — this is
// the usual reason "users cannot see each other / hear each other".
//
// There is no reliable free anonymous public TURN any more, so you must supply
// credentials. Two easy options (pick ONE):
//
// OPTION A — paste static TURN entries below (coturn / Twilio / Cloudflare /
// Metered static creds). Example:
//   { urls:'turn:turn.example.com:3478', username:'USER', credential:'PASS' },
//   { urls:'turns:turn.example.com:5349', username:'USER', credential:'PASS' },
//
// OPTION B — let the app fetch fresh credentials at call time. Metered's free
// tier gives 20GB/month: sign up at https://dashboard.metered.ca/signup , then
// put your credentials URL here and the app pulls TURN servers automatically:
//   var METERED_TURN_API_URL = 'https://YOURAPP.metered.live/api/v1/turn/credentials?apiKey=YOUR_KEY';
var METERED_TURN_API_URL = 'api/cloudflare_turn.php';
const ICE_SERVERS = [
  { urls:'stun:stun.l.google.com:19302' },
  { urls:'stun:stun1.l.google.com:19302' }
  // , OPTION A TURN entries go here (see note above)
];
const RTC_CONFIG = { iceServers: ICE_SERVERS, iceCandidatePoolSize: 4 };
// Resolved ICE config for the current call (may include fetched TURN servers).
var activeIceConfig = RTC_CONFIG;
// Priority incoming-call ringing rule requested for Johsz -> Gel06.
// Add more pairs here if you want the same special ringtone behavior for other accounts.
const PRIORITY_CALL_RING_RULES = [
  { caller: 'johsz', receiver: 'gel06' },
  { caller: 'joshz', receiver: 'gel06' } // typo-tolerant alias for the same caller name
];
async function getIceConfig(){
  var servers = ICE_SERVERS.slice();

  if(METERED_TURN_API_URL){
    try {
      var r = await fetch(METERED_TURN_API_URL, {
        headers: token ? { 'Authorization': 'Bearer ' + token } : {},
        credentials: 'same-origin'
      });

      if(!r.ok) throw new Error('TURN endpoint returned HTTP '+r.status);
      var payload = await r.json();
      var arr = [];
      if(Array.isArray(payload)) arr = payload;
      else if(payload && Array.isArray(payload.iceServers)) arr = payload.iceServers;
      else if(payload && payload.urls) arr = [payload];

      if(arr.length) {
        servers = servers.concat(arr);
      } else {
        console.warn('TURN endpoint did not return an iceServers array', payload);
      }
    } catch(e){
      console.warn('Could not fetch TURN credentials', e);
    }
  }

  return {
    iceServers: servers,
    iceCandidatePoolSize: 10,
    bundlePolicy: 'max-bundle',
    rtcpMuxPolicy: 'require'
  };
}
// ── State ───────────────────────────────────────────────────
var token='', me='', myKP=null, myPubB64='';
var sharedKeys={}, msgHistory={}, lastId={}, lastGlobalId=0;
var lastReceiptAt='1970-01-01T00:00:00Z';
var activeChat=null, onlineUsers=[], conversations=[];
var replyTo=null, ctxMsg=null;
var pollTimer=null, onlineTimer=null, convTimer=null, callTimer=null, callStatusTimer=null;
var callRingTimer=null, callRingCtx=null, callRingActiveId=null, callRingerPrimed=false;
var activeCall=null, callPc=null, localStream=null, remoteStream=null, screenStream=null, activeVideoSender=null, originalVideoTrack=null, speakerOn=false, pendingIceCandidates=[], remoteIceSeen={}, pendingRemoteIce=[];
var preferredCameraMode='user', preferredCameraDeviceId='', knownVideoInputs=[], gifTargetPeer=null, callEmojiTrayBuilt=false, callMainVideo='remote', currentCameraFacing='user';
var typingTimer=null, isTyping=false, typingToPeer='';
var typingHeartbeat=null;
var typingUsers=[];
var offlineQueue=[];
var lockedPeers={}, unlockedProtectedChats={}, userMeta={}, myDisplayName='';
var clientTimeZone='', clientCountry='', isLoggingOut=false;
// True while a native file picker (photo/camera/document) or an upload is in
// progress. On mobile, opening the picker backgrounds the page and fires
// `visibilitychange`; without this guard the security auto-logout would fire and
// sign the user out mid-upload. Applies to every account.
var fileDialogActive=false;
try {
  preferredCameraMode=localStorage.getItem('spaceship_call_camera_mode')||'user';
  preferredCameraDeviceId=localStorage.getItem('spaceship_call_camera_device')||'';
  // Camera priority UI was removed; ignore any old exact-device setting saved by
  // previous builds so the new WhatsApp-style flip starts from front/back.
  if(preferredCameraMode.indexOf('device:')===0){ preferredCameraMode='user'; preferredCameraDeviceId=''; }
} catch(e) {}

// ── API helper ──────────────────────────────────────────────
async function api(ep, opts) {
  opts=opts||{};
  var hdrs={'Content-Type':'application/json'};
  if(token) hdrs['Authorization']='Bearer '+token;
  Object.assign(hdrs,opts.headers||{});
  var sep=ep.indexOf('?')>=0?'&':'?';
  var url=BASE+'/'+ep+(token?sep+'token='+encodeURIComponent(token):'');
  try {
    var r=await fetch(url,Object.assign({},opts,{headers:hdrs}));
    var txt=await r.text();
    return JSON.parse(txt);
  } catch(e) { console.error('API',ep,e); return {error:''+e}; }
}

async function apiForm(ep, fd) {
  var sep=ep.indexOf('?')>=0?'&':'?';
  var url=BASE+'/'+ep+(token?sep+'token='+encodeURIComponent(token):'');
  var r=await fetch(url,{method:'POST',headers:{'Authorization':'Bearer '+token},body:fd});
  return r.json();
}

// ── Helpers ─────────────────────────────────────────────────
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escAttr(s){return esc(s).replace(/'/g,'&#39;');}
function fmtTime(iso){return new Date(iso).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
function fmtDate(iso){
  var d=new Date(iso),n=new Date();
  if(d.toDateString()===n.toDateString()) return 'Today';
  var y=new Date(n); y.setDate(n.getDate()-1);
  if(d.toDateString()===y.toDateString()) return 'Yesterday';
  return d.toLocaleDateString();
}
function fmtSize(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(1)+'MB';}
function detectClientLocale(){
  try {
    clientTimeZone=(Intl.DateTimeFormat().resolvedOptions().timeZone||'').substring(0,64);
  } catch(e) { clientTimeZone=''; }
  try {
    var loc=(navigator.languages&&navigator.languages[0])||navigator.language||'';
    var parts=loc.split('-');
    clientCountry=(parts.length>1?parts.pop():'').toUpperCase().substring(0,8);
  } catch(e) { clientCountry=''; }
}
function rememberUser(u){
  if(!u||!u.username) return null;
  if(!userMeta[u.username]) userMeta[u.username]={username:u.username};
  ['display_name','avatar','about','last_seen','timezone','country','online'].forEach(function(k){
    if(typeof u[k] !== 'undefined' && u[k] !== null) userMeta[u.username][k]=u[k];
  });
  if(!userMeta[u.username].display_name) userMeta[u.username].display_name=u.username;
  return userMeta[u.username];
}
function displayNameOf(userOrName){
  var u=typeof userOrName==='string' ? (userMeta[userOrName]||{username:userOrName}) : (userOrName||{});
  return u.display_name || u.username || String(userOrName||'');
}
function avatarInitialOf(userOrName){
  var n=displayNameOf(userOrName)||'?';
  return n[0].toUpperCase();
}
function ymdInZone(date, tz){
  try {
    return new Intl.DateTimeFormat('en-CA',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit'}).format(date);
  } catch(e) {
    return date.toISOString().slice(0,10);
  }
}
function fmtLastSeen(iso,user){
  if(!iso) return '';
  var d=new Date(iso);
  if(isNaN(d.getTime())) return '';
  var tz=(user&&user.timezone)?user.timezone:(clientTimeZone||undefined);
  var country=(user&&user.country)?user.country:'';
  var now=new Date();
  var y=new Date(now); y.setDate(now.getDate()-1);
  var day=ymdInZone(d,tz), today=ymdInZone(now,tz), yesterday=ymdInZone(y,tz);
  var label=day===today?'today':(day===yesterday?'yesterday':new Intl.DateTimeFormat([], {timeZone:tz, year:'numeric', month:'short', day:'2-digit'}).format(d));
  var t=d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',timeZone:tz});
  return 'last seen '+label+' at '+t+(tz?' · '+tz:(country?' · '+country:''));
}
function receiptHtml(status, compact){
  var compactCls = compact ? ' compact' : '';
  if(status==='read'){
    return '<span class="receipt read'+compactCls+'" title="Read"><span class="ticks">✓✓</span></span>';
  }
  if(status==='delivered'){
    return '<span class="receipt delivered'+compactCls+'" title="Delivered"><span class="ticks">✓✓</span></span>';
  }
  return '<span class="receipt sent'+compactCls+'" title="Sent"><span class="ticks">✓</span></span>';
}
function applyReceipts(receipts){
  var rank={sent:0,delivered:1,read:2};
  var changed=false;
  (receipts||[]).forEach(function(r){
    if(r.status_at && new Date(r.status_at) > new Date(lastReceiptAt)){
      lastReceiptAt = r.status_at;
    }
    Object.keys(msgHistory).forEach(function(peer){
      var hist=msgHistory[peer]||[];
      var m=hist.find(function(h){return String(h.dbid)===String(r.id);});
      if(m && (rank[r.status]||0)>(rank[m.status]||0)){
        m.status=r.status;
        changed=true;
      }
    });
  });
  return changed;
}


function updateMyAvatar(){
  var av=document.getElementById('my-av');
  if(!av) return;
  av.textContent=avatarInitialOf(me);
  av.title='Signed in as '+displayNameOf(me)+' (@'+me+') — click to edit profile';
}
function avatarEl(name,imgUrl,size){
  var el=document.createElement('div');
  el.className='ci-av'; el.style.width=size+'px'; el.style.height=size+'px'; el.style.fontSize=(size*0.4)+'px';
  if(imgUrl){var img=new Image();img.src=imgUrl;el.appendChild(img);}
  else el.textContent=(name||'?')[0].toUpperCase();
  return el;
}

// ── Auth ─────────────────────────────────────────────────────
function switchTab(t) {
    document.getElementById('tab-login').style.display = t === 'login' ? '' : 'none';
    document.getElementById('tab-reg').style.display   = t === 'reg'   ? '' : 'none';
    document.querySelectorAll('.tab').forEach(function(el, i) {
        el.classList.toggle('active', i === (t === 'login' ? 0 : 1));
    });
    setErr('');
}
function setErr(m) { document.getElementById('auth-err').textContent = m || ''; }

async function saveLocalKeyPair(username){
    if(!username || !myKP) return;
    try { localStorage.setItem('sc_keys_' + username, JSON.stringify(await SC.exportKeyPair(myKP))); }
    catch (e) { console.warn('Could not save encryption keys locally.', e); }
}

async function loadLocalKeyPair(username){
    if(!username) return false;
    try {
        var saved = localStorage.getItem('sc_keys_' + username);
        if (!saved) return false;
        myKP = await SC.importKeyPair(JSON.parse(saved));
        myPubB64 = await SC.exportPub(myKP);
        return true;
    } catch (e) {
        console.warn('Could not load saved encryption keys.', e);
        return false;
    }
}

async function initKeys(username) {
    // Local fallback for the current browser. Normal login now restores the same
    // account keypair from a password-encrypted server backup so Android, iOS,
    // desktop and tablet devices use one consistent E2EE identity.
    if (await loadLocalKeyPair(username)) return;
    myKP = await SC.genKeys();
    myPubB64 = await SC.exportPub(myKP);
    await saveLocalKeyPair(username);
}

async function register() {
    var u = document.getElementById('ru').value.trim();
    var p = document.getElementById('rp').value;
    if (!u || !p) return setErr('Please fill in both fields.');
    var btn = document.querySelector('#tab-reg .btn-wa');
    btn.disabled = true; btn.textContent = 'Creating account…';
    primeCallRinger();
    try {
        detectClientLocale();
        await initKeys(u);
        var backup = await SC.encryptKeyPairBackup(u, p, myKP);
        var d = await api('register.php', {
            method: 'POST',
            body: JSON.stringify({
                username: u,
                displayName: u,
                password: p,
                publicKey: myPubB64,
                keyBackup: backup.keyBackup,
                keyBackupIv: backup.keyBackupIv,
                timezone: clientTimeZone,
                country: clientCountry
            })
        });
        if (d.error) { setErr(d.error); btn.disabled = false; btn.textContent = 'Create Account'; return; }
        await doLogin(u, p, false);
    } catch(e) {
        console.error('register error', e);
        setErr('Connection error — is XAMPP running? Check: http://localhost/phpmyadmin');
        btn.disabled = false; btn.textContent = 'Create Account';
    }
}

async function login() {
    var u = document.getElementById('lu').value.trim();
    var p = document.getElementById('lp').value;
    if (!u || !p) return setErr('Please fill in both fields.');
    var btn = document.querySelector('#tab-login .btn-wa');
    btn.disabled = true; btn.textContent = 'Signing in…';
    primeCallRinger();
    try {
        detectClientLocale();
        await doLogin(u, p, true);
    } catch(e) {
        console.error('login error', e);
        setErr('Connection error — is XAMPP running? Check: http://localhost/phpmyadmin');
        btn.disabled = false; btn.textContent = 'Sign In';
    }
}

async function ensureAccountKeyAfterLogin(u, p, loginData) {
    var restored = false;
    if (loginData.keyBackup && loginData.keyBackupIv) {
        try {
            myKP = await SC.decryptKeyPairBackup(u, p, loginData.keyBackup, loginData.keyBackupIv);
            myPubB64 = await SC.exportPub(myKP);
            await saveLocalKeyPair(u);
            restored = true;
        } catch (e) {
            console.warn('Could not decrypt account key backup; trying local device key.', e);
        }
    }
    if (!restored) {
        restored = await loadLocalKeyPair(u);
    }
    if (!restored) {
        myKP = await SC.genKeys();
        myPubB64 = await SC.exportPub(myKP);
        await saveLocalKeyPair(u);
    }

    var needsBackup = !(loginData.keyBackup && loginData.keyBackupIv);
    var needsPublicKeySync = !loginData.publicKey || loginData.publicKey !== myPubB64;
    if (needsBackup || needsPublicKeySync) {
        var backup = await SC.encryptKeyPairBackup(u, p, myKP);
        await api('profile.php', {
            method: 'POST',
            body: JSON.stringify({
                publicKey: myPubB64,
                keyBackup: backup.keyBackup,
                keyBackupIv: backup.keyBackupIv
            })
        });
    }
}

// Internal: perform the actual login API call + start app
async function doLogin(u, p, reinitBtn) {
    var btn = document.querySelector('#tab-login .btn-wa');
    var d = await api('login.php', {
        method: 'POST',
        body: JSON.stringify({ username: u, password: p, timezone: clientTimeZone, country: clientCountry })
    });
    if (d.error) {
        setErr(d.error);
        if (reinitBtn) { btn.disabled = false; btn.textContent = 'Sign In'; }
        else {
            var rb = document.querySelector('#tab-reg .btn-wa');
            if (rb) { rb.disabled = false; rb.textContent = 'Create Account'; }
        }
        return;
    }
    token = d.token;
    me    = d.username;
    await ensureAccountKeyAfterLogin(u, p, d);
    myDisplayName = d.display_name || me;
    rememberUser({username:me, display_name:myDisplayName, avatar:d.avatar, about:d.about, timezone:d.timezone, country:d.country});
    sessionStorage.setItem('wt', token);
    sessionStorage.setItem('wu', me);
    // reset volatile message/call state after login; full history is reloaded from the server
    msgHistory={}; lastId={}; lastGlobalId=0; lastReceiptAt='1970-01-01T00:00:00Z';
    setErr('');
    startApp();
}

function secureLogout(reason, noReload) {
  if(isLoggingOut) return;
  if(!token && !sessionStorage.getItem('wt')) return;
  isLoggingOut=true;
  try { stopTyping(); } catch(e) {}
  try { if(activeCall && activeCall.id) api('call.php',{method:'POST',body:JSON.stringify({action:'end',callId:activeCall.id})}); } catch(e) {}
  try { stopLocalCallMedia(); } catch(e) {}
  try { clearInterval(onlineTimer); clearInterval(convTimer); clearInterval(callTimer); clearInterval(callStatusTimer); clearTimeout(pollTimer); stopIncomingCallRinger(); } catch(e) {}
  try {
    var logoutUrl=BASE+'/logout.php?token='+encodeURIComponent(token||sessionStorage.getItem('wt')||'')+'&reason='+encodeURIComponent(reason||'logout');
    if(navigator.sendBeacon) navigator.sendBeacon(logoutUrl, new Blob([], {type:'text/plain'}));
    else fetch(logoutUrl,{method:'POST',keepalive:true}).catch(function(){});
  } catch(e) {}
  token=''; me=''; myKP=null; myPubB64='';
  sharedKeys={}; msgHistory={}; lastId={}; lastGlobalId=0;
  activeChat=null; onlineUsers=[]; conversations=[]; replyTo=null; ctxMsg=null;
  offlineQueue=[]; lockedPeers={}; unlockedProtectedChats={}; userMeta={}; myDisplayName='';
  try { sessionStorage.removeItem('wt'); sessionStorage.removeItem('wu'); } catch(e) {}
  if(!noReload) window.location.replace(window.location.pathname);
}

function signOut() {
    secureLogout('manual', false);
}
// ── App start ─────────────────────────────────────────────────
function startApp(){
  isLoggingOut=false;
  detectClientLocale();
  document.getElementById('auth').style.display='none';
  document.getElementById('app').style.display='flex';
  updateMyAvatar();
  syncProfileLocale();
  loadChatWallpaper();
  loadChatLocks().then(loadConversations);
  refreshOnline();
  onlineTimer=setInterval(refreshOnline,8000);
  convTimer=setInterval(function(){loadChatLocks().then(loadConversations);},10000);
  clearInterval(callTimer);
  callTimer=setInterval(checkIncomingCall,3500);
  schedulePoll();
  requestNotifPerm();
}

async function syncProfileLocale(){
  try { await api('profile.php',{method:'POST',body:JSON.stringify({timezone:clientTimeZone,country:clientCountry})}); } catch(e) {}
  try {
    var d=await api('profile.php');
    if(d&&d.username){ rememberUser(d); myDisplayName=displayNameOf(me); updateMyAvatar(); }
  } catch(e) {}
}

async function openProfileModal(){
  var d=await api('profile.php');
  if(d&&d.username){ rememberUser(d); myDisplayName=displayNameOf(me); }
  document.getElementById('profile-name').value=myDisplayName||me;
  document.getElementById('profile-about').value=(userMeta[me]&&userMeta[me].about)||'';
  document.getElementById('profile-username').textContent='@'+me;
  document.getElementById('profile-avatar').textContent=avatarInitialOf(me);
  document.getElementById('profile-locale').textContent='Detected: '+(clientTimeZone||'unknown timezone')+(clientCountry?' · '+clientCountry:'');
  document.getElementById('profile-modal').classList.add('open');
}
function closeProfileModal(e){
  if(e&&e.target&&e.target.id!=='profile-modal') return;
  var m=document.getElementById('profile-modal'); if(m) m.classList.remove('open');
}
async function saveProfile(){
  var name=document.getElementById('profile-name').value.trim();
  var about=document.getElementById('profile-about').value.trim();
  if(!name){alert('Display name cannot be empty.');return;}
  var d=await api('profile.php',{method:'POST',body:JSON.stringify({displayName:name,about:about,timezone:clientTimeZone,country:clientCountry})});
  if(d.error){alert(d.error);return;}
  rememberUser({username:me,display_name:name,about:about,timezone:clientTimeZone,country:clientCountry});
  myDisplayName=name; updateMyAvatar(); closeProfileModal(); loadConversations(); refreshOnline();
}

// ── Keys ──────────────────────────────────────────────────────
async function ensureKey(peer){
  if(sharedKeys[peer]) return true;
  var d=await api('publickey.php?username='+encodeURIComponent(peer));
  if(d&&d.username) rememberUser(d);
  if(!d.publicKey) return false;
  var pk=await SC.importPub(d.publicKey);
  sharedKeys[peer]=await SC.deriveKey(myKP.privateKey,pk);
  return true;
}


// ── Password protection for encrypted chats ───────────────────
// Chat password-protection is available to ONE account only (Joshz). For every
// other account the feature is disabled: no chat is ever treated as protected,
// the lock button is hidden, and existing server-side locks are ignored so the
// messages open normally. Configurable below (case-insensitive).
const PROTECTED_CHAT_ACCOUNT = 'Joshz';
function chatProtectionEnabled(){
  return !!(me && me.toLowerCase() === PROTECTED_CHAT_ACCOUNT.toLowerCase());
}
function isProtectedChat(peer){ return chatProtectionEnabled() && !!lockedPeers[peer]; }
function isProtectedChatUnlocked(peer){ return !!unlockedProtectedChats[peer]; }

async function loadChatLocks(){
  lockedPeers={};
  // Only the protection-enabled account tracks locked chats.
  if(!chatProtectionEnabled()) return;
  var d=await api('chat_lock.php');
  if(d.locked){
    d.locked.forEach(function(row){ lockedPeers[row.peer]=true; });
  }
}

async function verifyCurrentPassword(password){
  var d=await api('verify_password.php',{method:'POST',body:JSON.stringify({password:password})});
  return !!(d && d.ok);
}

// In-page password modal. Installed PWAs (and several mobile webviews) disable
// window.prompt(), which previously left password-protected chats impossible to
// unlock and the composer stuck disabled. This modal works everywhere.
var _unlockResolve=null;
function askPassword(message){
  return new Promise(function(resolve){
    var m=document.getElementById('unlock-modal');
    if(!m){ resolve(null); return; }
    _unlockResolve=resolve;
    document.getElementById('unlock-msg').textContent=message||'Enter your account password.';
    document.getElementById('unlock-err').textContent='';
    var pw=document.getElementById('unlock-pw'); pw.value='';
    var btn=document.getElementById('unlock-submit-btn'); if(btn){ btn.disabled=false; btn.textContent='Unlock messages'; }
    m.classList.add('open');
    setTimeout(function(){ try{ pw.focus(); }catch(e){} },50);
  });
}
function _resolveUnlock(val){
  var m=document.getElementById('unlock-modal'); if(m) m.classList.remove('open');
  var r=_unlockResolve; _unlockResolve=null;
  if(r) r(val);
}
function cancelUnlock(){ _resolveUnlock(null); }
function unlockModalBackdrop(e){ if(e&&e.target&&e.target.id==='unlock-modal') _resolveUnlock(null); }
async function submitUnlock(){
  var pw=document.getElementById('unlock-pw').value;
  var err=document.getElementById('unlock-err');
  var btn=document.getElementById('unlock-submit-btn');
  if(!pw){ err.textContent='Password is required.'; return; }
  if(btn){ btn.disabled=true; btn.textContent='Checking…'; }
  var ok=false;
  try { ok=await verifyCurrentPassword(pw); } catch(e){ ok=false; }
  if(!ok){
    err.textContent='Incorrect password. Try again.';
    if(btn){ btn.disabled=false; btn.textContent='Unlock messages'; }
    var f=document.getElementById('unlock-pw'); if(f){ f.value=''; f.focus(); }
    return;
  }
  _resolveUnlock(pw);
}

async function unlockProtectedChat(peer){
  if(!isProtectedChat(peer) || isProtectedChatUnlocked(peer)) return true;
  // submitUnlock() verifies the account password before resolving, so a non-null
  // result here means the password was already confirmed correct.
  var pw=await askPassword('This encrypted chat with '+displayNameOf(peer)+' is password protected. Enter your account password to decrypt the messages.');
  if(pw===null) return false;
  unlockedProtectedChats[peer]=true;
  return true;
}

function setComposerLocked(locked){
  var area=document.getElementById('input-area');
  var inp=document.getElementById('msg-input');
  ['emoji-btn','attach-btn','send-btn'].forEach(function(id){var b=document.getElementById(id); if(b) b.disabled=!!locked;});
  if(area) area.classList.toggle('locked', !!locked);
  if(inp){ inp.disabled=!!locked; inp.placeholder=locked?'Encrypted chat locked — unlock to view or send messages':'Type a message'; }
}

function showProtectedChatLocked(peer){
  document.getElementById('empty-state').style.display='none';
  var cv=document.getElementById('chat-view');
  cv.style.display='flex'; cv.classList.add('open');
  document.getElementById('ch-name').textContent=displayNameOf(peer);
  document.getElementById('ch-av-txt').textContent=avatarInitialOf(peer);
  document.getElementById('ch-status').textContent='encrypted · password required';
  var btn=document.getElementById('lock-btn');
  if(btn) btn.title='Unlock this protected chat';
  clearReply();
  setComposerLocked(true);
  document.getElementById('msgs').innerHTML='<div class="archive-lock"><div class="lock-ico">🔐</div><h3>Encrypted messages are locked</h3><p>This conversation is password protected. Enter your account password to decrypt and view the message thread.</p><button onclick="unlockAndLoadActive()">Unlock messages</button></div>';
  updateLockButton();
}

async function unlockAndLoadActive(){
  if(!activeChat) return;
  var peer=activeChat;
  var ok=await unlockProtectedChat(peer);
  if(!ok) return;
  delete msgHistory[peer];
  lastId[peer]=0;
  setComposerLocked(false);
  await ensureKey(peer);
  await loadMessages(peer);
  updateLockButton();
}

function updateLockButton(){
  var btn=document.getElementById('lock-btn');
  if(!btn) return;
  // The lock button only exists for the protection-enabled account.
  if(!chatProtectionEnabled()){ btn.style.display='none'; return; }
  btn.style.display='';
  if(!activeChat){btn.textContent='🔒'; btn.title='Open a chat to enable password protection'; return;}
  if(isProtectedChat(activeChat) && !isProtectedChatUnlocked(activeChat)){
    btn.textContent='🔓';
    btn.title='Unlock this password-protected encrypted chat';
  } else if(isProtectedChat(activeChat) && isProtectedChatUnlocked(activeChat)){
    btn.textContent='🔒';
    btn.title='Lock this chat now. Shift-click to remove password protection.';
  } else {
    btn.textContent='🔒';
    btn.title='Lock and password-protect this encrypted chat';
  }
}

async function setChatProtection(peer, locked){
  var d=await api('chat_lock.php',{method:'POST',body:JSON.stringify({peer:peer, locked:locked})});
  if(d.error){ alert(d.error); return false; }
  if(locked) lockedPeers[peer]=true;
  else delete lockedPeers[peer];
  return true;
}

async function toggleChatLockActive(e){
  if(!chatProtectionEnabled()) return; // feature disabled for this account
  if(!activeChat){ alert('Open a chat first.'); return; }
  var peer=activeChat;

  if(!isProtectedChat(peer)){
    if(!confirm('Lock and password-protect this encrypted chat? You will need your account password to view these messages.')) return;
    if(!(await setChatProtection(peer, true))) return;
    delete unlockedProtectedChats[peer];
    delete msgHistory[peer];
    lastId[peer]=0;
    showProtectedChatLocked(peer);
    updateLockButton();
    loadConversations();
    return;
  }

  if(!isProtectedChatUnlocked(peer)){
    var unlocked=await unlockProtectedChat(peer);
    if(!unlocked) return;
    delete msgHistory[peer];
    lastId[peer]=0;
    setComposerLocked(false);
    await ensureKey(peer);
    await loadMessages(peer);
    updateLockButton();
    loadConversations();
    return;
  }

  if(e && e.shiftKey){
    if(!confirm('Remove password protection from this encrypted chat?')) return;
    if(!(await setChatProtection(peer, false))) return;
    delete unlockedProtectedChats[peer];
    setComposerLocked(false);
    updateLockButton();
    loadConversations();
    return;
  }

  if(!confirm('Lock this chat now? Password protection remains enabled.')) return;
  delete unlockedProtectedChats[peer];
  delete msgHistory[peer];
  lastId[peer]=0;
  showProtectedChatLocked(peer);
  updateLockButton();
  loadConversations();
}

async function clearActiveChatHistory(){
  if(!activeChat){ alert('Open a chat first.'); return; }
  var peer=activeChat;
  if(isProtectedChat(peer) && !isProtectedChatUnlocked(peer)){
    var unlocked=await unlockProtectedChat(peer);
    if(!unlocked) return;
  }
  if(!confirm('Clear chat history with '+displayNameOf(peer)+'? This removes the conversation from your account view only.')) return;
  var d=await api('clear_chat.php',{method:'POST',body:JSON.stringify({peer:peer})});
  if(d.error){ alert(d.error); return; }
  msgHistory[peer]=[];
  lastId[peer]=0;
  setComposerLocked(false);
  renderMessages(peer);
  updateLockButton();
  loadConversations();
  alert('Chat history cleared.');
}

// ── Conversations sidebar ─────────────────────────────────────
async function loadConversations(){
  var d=await api('conversations.php');
  if(!d.conversations) return;
  conversations=d.conversations;
  renderConvList();
}

function renderConvList(){
  var list=document.getElementById('chat-list');
  if(!conversations.length){list.innerHTML='<div style="padding:20px;text-align:center;color:var(--wa-sub);font-size:14px;">No conversations yet.<br/>Search for a user to start chatting.</div>';return;}
  list.innerHTML='';
  conversations.forEach(function(c){
    var el=document.createElement('div');
    el.className='ci'+(activeChat===c.peer?' active':'')+(isProtectedChat(c.peer)?' locked':'');
    el.setAttribute('data-peer',c.peer);
    var online=parseInt(c.online);
    var unread=parseInt(c.unread)||0;
    rememberUser(c);
    var preview=isProtectedChat(c.peer)?'🔐 Password protected — unlock to view':(c.msg_type!=='text'?'📎 '+c.msg_type:'…');
    // We can't decrypt preview here (no key yet) - show type indicator
    var ticks='';
    if(c.from_user===me){
      ticks=receiptHtml(c.status,true);
    }
    var avBg=c.avatar?'':'var(--wa-dark)';
    var avContent=c.avatar?'<img src="'+esc(c.avatar)+'" alt="">':esc(avatarInitialOf(c.peer));
    el.innerHTML='<div class="ci-av" style="background:'+avBg+'">'+avContent+(online?'<div class="on-dot"></div>':'')+'</div>'+
      '<div class="ci-body">'+
        '<div class="ci-top"><div class="ci-name">'+esc(displayNameOf(c.peer))+'</div><div class="ci-time">'+fmtTime(c.sent_at)+'</div></div>'+
        '<div class="ci-bot"><div class="ci-prev">'+ticks+(isProtectedChat(c.peer)?preview:(c.msg_type!=='text'?preview:'…'))+'</div>'+(unread?'<div class="ubadge">'+unread+'</div>':'')+'</div>'+ 
      '</div>';
    el.addEventListener('click',function(){openChat(c.peer);});
    list.appendChild(el);
  });
}

// ── Online users ──────────────────────────────────────────────
async function refreshOnline(){
  var d=await api('online.php');
  if(d.users) {
    onlineUsers=d.users;
    d.users.forEach(function(u){rememberUser(u); if(!sharedKeys[u.username])ensureKey(u.username);});
    if(activeChat){
      var pu=d.users.find(function(u){return u.username===activeChat;});
      updateChatStatus(pu);
    }
  }
}

function setHeaderTyping(isTypingNow){
  var el=document.getElementById('ch-status');
  if(!el) return;
  if(isTypingNow){
    el.textContent='typing…';
    el.classList.add('typing-status');
  } else {
    el.classList.remove('typing-status');
  }
}

function updateChatStatus(user){
  var el=document.getElementById('ch-status');
  if(!el) return;
  if(activeChat && typingUsers.indexOf(activeChat)>=0){
    setHeaderTyping(true);
    return;
  }
  setHeaderTyping(false);
  if(user&&parseInt(user.online)) el.textContent='online';
  else if(user&&user.last_seen) el.textContent=fmtLastSeen(user.last_seen,user);
  else el.textContent='';
}

// ── Open chat ─────────────────────────────────────────────────
async function openChat(peer){
  if(isTyping && typingToPeer && typingToPeer!==peer) stopTyping(typingToPeer);
  activeChat=peer;
  document.getElementById('empty-state').style.display='none';
  var cv=document.getElementById('chat-view');
  cv.style.display='flex'; cv.classList.add('open');
  document.getElementById('ch-name').textContent=displayNameOf(peer);
  document.getElementById('ch-av-txt').textContent=avatarInitialOf(peer);
  document.getElementById('ch-status').textContent='';
  setHeaderTyping(false);
  hideTypingIndicator();
  clearReply();
  // Mobile
  document.getElementById('sidebar').classList.add('mob-hide');
  document.getElementById('main').classList.add('mob');
  // Update list
  document.querySelectorAll('.ci').forEach(function(el){el.classList.toggle('active',el.getAttribute('data-peer')===peer);});
  updateLockButton();
  if(isProtectedChat(peer) && !isProtectedChatUnlocked(peer)){
    showProtectedChatLocked(peer);
    return;
  }
  setComposerLocked(false);
  // Load messages
  var ok=await ensureKey(peer);
  if(ok) await loadMessages(peer);
  var pu=onlineUsers.find(function(u){return u.username===peer;});
  updateChatStatus(pu);
  updateLockButton();
  document.getElementById('msg-input').focus();
}

function backToList(){
  document.getElementById('sidebar').classList.remove('mob-hide');
  document.getElementById('main').classList.remove('mob');
}

// ── Load message history ──────────────────────────────────────
async function loadMessages(peer){
  var hasHistory = Array.isArray(msgHistory[peer]);
  var since = hasHistory ? (lastId[peer]||0) : 0;
  var fullFlag = since ? '' : '&full=1';
  var d=await api('messages.php?with='+encodeURIComponent(peer)+'&since='+since+fullFlag);
  if(!d.messages) return;
  for(var i=0;i<d.messages.length;i++){
    var m=d.messages[i];
    var mid=parseInt(m.id);
    if(mid>(lastId[peer]||0)) lastId[peer]=mid;
    if(mid>lastGlobalId) lastGlobalId=mid;
    var hist=msgHistory[peer]||[];
    if(!hist.some(function(h){return h.dbid==m.id;})){
      var pt=await decryptMsg(m,peer);
      storeMsg(peer,m.from_user,pt,m.sent_at,m.iv,m.cipher_text,m.id,m.msg_type,m.file_name,m.file_size,m.file_mime,m.reply_to,m.forwarded,m.reactions||[],m.status);
    }
  }
  applyReceipts(d.receipts||[]);
  renderMessages(peer);
  loadConversations();
}

async function decryptMsg(m,peer){
  var isMe=m.from_user===me;
  var keyPeer=isMe?peer:m.from_user;
  if(!sharedKeys[keyPeer]){
    var ok=await ensureKey(keyPeer);
    if(!ok) return '[encrypted]';
  }
  // Check if deleted for all
  try {
    var df=m.deleted_for?JSON.parse(m.deleted_for):[];
    if(df.includes('__all__')) return '__deleted__';
  } catch(e){}
  try {
    return await SC.decrypt(sharedKeys[keyPeer],m.cipher_text,m.iv);
  } catch(e) { return '[encrypted]'; }
}

// ── Store + render messages ───────────────────────────────────
function storeMsg(peer,from,text,time,iv,cipher,dbid,type,fname,fsize,fmime,replyToId,fwd,rxns,status){
  if(!msgHistory[peer]) msgHistory[peer]=[];
  var msg = {from:from,text:text,time:time,iv:iv,cipher:cipher,dbid:dbid||null,
    type:type||'text',fname:fname||'',fsize:fsize||0,fmime:fmime||'',
    replyToId:replyToId||null,fwd:fwd||0,rxns:rxns||[],status:status||'sent'};
  msgHistory[peer].push(msg);
  return msg;
}

function renderMessages(peer){
  var container=document.getElementById('msgs');
  var msgs=(msgHistory[peer]||[]).slice().sort(function(a,b){
    var aid=a.dbid?parseInt(a.dbid):0, bid=b.dbid?parseInt(b.dbid):0;
    if(aid && bid && aid!==bid) return aid-bid;
    return new Date(a.time)-new Date(b.time);
  });
  container.innerHTML='';
  if(!msgs.length){
    container.innerHTML='<div class="sys-msg">🔒 Messages are end-to-end encrypted. Only you and '+esc(displayNameOf(peer))+' can read them.</div>';
    return;
  }
  var lastDate='';
  msgs.forEach(function(m,idx){
    var ds=fmtDate(m.time);
    if(ds!==lastDate){
      var dd=document.createElement('div');
      dd.className='date-div'; dd.innerHTML='<span>'+esc(ds)+'</span>';
      container.appendChild(dd); lastDate=ds;
    }
    var isMe=m.from===me;
    var mw=document.createElement('div');
    mw.className='mw '+(isMe?'me':'them');
    mw.setAttribute('data-id',m.dbid||'');

    // Build bubble content
    var inner='';

    // Forwarded label
    if(m.fwd) inner+='<div class="fwd">↗ Forwarded</div>';

    // Reply reference
    if(m.replyToId){
      var orig=msgs.find(function(x){return x.dbid==m.replyToId;});
      if(orig){
        inner+='<div class="rply" onclick="scrollToMsg('+orig.dbid+')"><div class="rr-nm">'+esc(displayNameOf(orig.from))+'</div><div class="rr-tx">'+esc(orig.text.substring(0,60))+'</div></div>';
      }
    }

    // Message content
    if(m.text==='__deleted__'){
      inner+='<div class="del-msg">🚫 This message was deleted</div>';
    } else if(m.type==='image'){
      inner+='<div class="media-bbl" onclick="viewMedia(\''+esc(m.text)+'\',\'image\')"><img src="'+esc(m.text)+'" loading="lazy" alt="image"></div>';
    } else if(m.type==='video'){
      inner+='<div class="media-bbl"><video src="'+esc(m.text)+'" controls preload="none"></video></div>';
    } else if(m.type==='audio'||m.type==='voice'){
      inner+='<div class="audio-bbl"><audio src="'+esc(m.text)+'" controls preload="none"></audio></div>';
    } else if(m.type==='document'){
      inner+='<div class="doc-bbl" onclick="window.open(\''+esc(m.text)+'\')"><div class="doc-ico">📄</div><div><div class="doc-nm">'+esc(m.fname||'Document')+'</div><div class="doc-sz">'+fmtSize(m.fsize)+'</div></div></div>';
    } else {
      inner+='<div class="bbl-txt">'+esc(m.text)+'</div>';
    }

    // Footer: time + ticks
    var ticks='';
    if(isMe){
      ticks=receiptHtml(m.status,false);
    }
    inner+='<div class="bbl-foot"><span class="msg-time">'+fmtTime(m.time)+'</span>'+ticks+'</div>';

    // Reactions
    if(m.rxns&&m.rxns.length){
      var rxnMap={};
      m.rxns.forEach(function(r){rxnMap[r.emoji]=(rxnMap[r.emoji]||0)+1;});
      inner+='<div class="rxns">';
      Object.keys(rxnMap).forEach(function(e){
        inner+='<span class="rxn" title="'+rxnMap[e]+'" onclick="addReaction('+m.dbid+',\''+e+'\')">'+e+(rxnMap[e]>1?' '+rxnMap[e]:'')+'</span>';
      });
      inner+='</div>';
    }

    var bbl=document.createElement('div');
    bbl.className='bbl'; bbl.innerHTML=inner;
    // Enc bar update on hover
    if(m.iv) bbl.title='IV: '+SC.hexPrev(m.iv);
    bbl.addEventListener('contextmenu',function(e){e.preventDefault();showCtx(e,m,idx);});
    bbl.addEventListener('dblclick',function(){setReply(m);});

    mw.appendChild(bbl);
    container.appendChild(mw);
  });
  container.scrollTop=container.scrollHeight;
}

function scrollToMsg(id){
  var el=document.querySelector('[data-id="'+id+'"]');
  if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.style.background='rgba(18,140,126,.1)';setTimeout(function(){el.style.background='';},1500);}
}

// ── Context menu ──────────────────────────────────────────────
function showCtx(e,msg,idx){
  ctxMsg=msg;
  var menu=document.getElementById('ctx-menu');
  menu.style.left=Math.min(e.clientX,window.innerWidth-170)+'px';
  menu.style.top=Math.min(e.clientY,window.innerHeight-300)+'px';
  menu.classList.add('open');
  // Show reaction picker
  var rp=document.getElementById('rxn-picker');
  rp.style.left=Math.min(e.clientX,window.innerWidth-220)+'px';
  rp.style.top=(Math.min(e.clientY,window.innerHeight-300)-50)+'px';
  rp.classList.add('open');
}
document.addEventListener('click',function(){
  document.getElementById('ctx-menu').classList.remove('open');
  document.getElementById('rxn-picker').classList.remove('open');
  document.getElementById('emoji-tray').classList.remove('open');
  document.getElementById('attach-tray').classList.remove('open');
  var cet=document.getElementById('call-emoji-tray'); if(cet) cet.classList.remove('open');
  if(typeof closeCallMore==='function') closeCallMore();
});

function ctxReply(){if(ctxMsg)setReply(ctxMsg);document.getElementById('ctx-menu').classList.remove('open');}
function ctxForward(){if(ctxMsg)forwardMsg(ctxMsg);document.getElementById('ctx-menu').classList.remove('open');}
function ctxStar(){if(ctxMsg)api('star.php',{method:'POST',body:JSON.stringify({id:ctxMsg.dbid,star:true})});document.getElementById('ctx-menu').classList.remove('open');}
function ctxCopy(){if(ctxMsg)navigator.clipboard.writeText(ctxMsg.text||'').catch(function(){});document.getElementById('ctx-menu').classList.remove('open');}
function ctxDelete(forAll){
  if(!ctxMsg) return;
  api('delete_msg.php',{method:'POST',body:JSON.stringify({id:ctxMsg.dbid,forAll:forAll})});
  var hist=msgHistory[activeChat]||[];
  var m=hist.find(function(h){return h.dbid==ctxMsg.dbid;});
  if(m){m.text='__deleted__';m.type='text';}
  renderMessages(activeChat);
  document.getElementById('ctx-menu').classList.remove('open');
}

// ── Reply ─────────────────────────────────────────────────────
function setReply(msg){
  replyTo=msg;
  var bar=document.getElementById('rply-bar');
  bar.classList.add('open');
  document.getElementById('rb-nm').textContent=displayNameOf(msg.from);
  document.getElementById('rb-tx').textContent=msg.text||'Media';
  document.getElementById('msg-input').focus();
}
function clearReply(){
  replyTo=null;
  document.getElementById('rply-bar').classList.remove('open');
}

// ── Reactions ─────────────────────────────────────────────────
async function addReaction(msgId,emoji){
  await api('reaction.php',{method:'POST',body:JSON.stringify({messageId:msgId,emoji:emoji})});
  var hist=msgHistory[activeChat]||[];
  var m=hist.find(function(h){return h.dbid==msgId;});
  if(m){
    if(!m.rxns) m.rxns=[];
    var ex=m.rxns.find(function(r){return r.username===me;});
    if(ex) ex.emoji=emoji; else m.rxns.push({username:me,emoji:emoji});
    renderMessages(activeChat);
  }
  document.getElementById('rxn-picker').classList.remove('open');
}

// ── Send message ──────────────────────────────────────────────
async function sendTextToPeer(peer,text,opts){
  opts=opts||{};
  text=(text||'').trim();
  if(!text||!peer) return null;
  if(isProtectedChat(peer)&&!isProtectedChatUnlocked(peer)){
    if(activeChat===peer) showProtectedChatLocked(peer);
    else alert('This protected chat must be unlocked before sending messages.');
    return null;
  }
  var ok=await ensureKey(peer);
  if(!ok){alert('Encryption key not ready, please wait.');return null;}
  var enc=await SC.encrypt(sharedKeys[peer],text);
  var payload={to:peer,cipherText:enc.cipherText,iv:enc.iv,type:'text'};
  if(opts.replyTo){payload.replyTo=opts.replyTo.dbid;}
  var localMsg = storeMsg(peer,me,text,new Date().toISOString(),enc.iv,enc.cipherText,null,'text','',0,'',opts.replyTo?opts.replyTo.dbid:null,0,[], 'sent');
  if(activeChat===peer) renderMessages(peer);
  if(activeCall && activeCall.peer===peer) renderCallChat(peer);
  var d=await api('messages.php',{method:'POST',body:JSON.stringify(payload)});
  if(d.id){
    localMsg.dbid=d.id;
    if(d.id>lastGlobalId) lastGlobalId=d.id;
    if(d.id>(lastId[peer]||0)) lastId[peer]=d.id;
    if(activeChat===peer) renderMessages(peer);
    if(activeCall && activeCall.peer===peer) renderCallChat(peer);
  } else if(d.error){
    offlineQueue.push(payload);
  }
  flushOfflineQueue();
  return localMsg;
}

async function sendMessage(){
  var input=document.getElementById('msg-input');
  var text=input.value.trim();
  if(!text||!activeChat) return;
  input.value=''; input.style.height='auto';
  stopTyping();
  var reply=replyTo;
  clearReply();
  await sendTextToPeer(activeChat,text,{replyTo:reply});
}

// ── Media send ────────────────────────────────────────────────
async function sendFile(file){
  if(!activeChat){ releaseFileDialogGuard(); return; }
  if(isProtectedChat(activeChat)&&!isProtectedChatUnlocked(activeChat)){showProtectedChatLocked(activeChat);releaseFileDialogGuard();return;}
  var ok=await ensureKey(activeChat);
  if(!ok){alert('Key not ready.');releaseFileDialogGuard();return;}
  // Upload file
  var fd=new FormData(); fd.append('file',file);
  showUploadProgress();
  var up;
  try {
    up=await apiForm('upload.php',fd);
  } finally {
    hideUploadProgress();
    // Keep the guard alive a moment longer so the page can regain focus after
    // the picker without the security auto-logout firing.
    setTimeout(releaseFileDialogGuard, 800);
  }
  if(up.error){alert('Upload failed: '+up.error);return;}
  // Encrypt the URL
  var enc=await SC.encrypt(sharedKeys[activeChat],up.url);
  var payload={to:activeChat,cipherText:enc.cipherText,iv:enc.iv,type:up.type,
    fileName:up.name,fileSize:up.size,fileMime:up.mime};
  var localMsg = storeMsg(activeChat,me,up.url,new Date().toISOString(),enc.iv,enc.cipherText,null,up.type,up.name,up.size,up.mime,null,0,[],'sent');
  renderMessages(activeChat);
  var d=await api('messages.php',{method:'POST',body:JSON.stringify(payload)});
  if(d.id){
    localMsg.dbid=d.id;
    if(d.id>lastGlobalId) lastGlobalId=d.id;
    if(d.id>(lastId[activeChat]||0)) lastId[activeChat]=d.id;
    renderMessages(activeChat);
  } else if(d.error){
    offlineQueue.push(payload);
  }
}

function showUploadProgress(){document.getElementById('send-btn').disabled=true;document.getElementById('send-btn').textContent='⏳';}
function hideUploadProgress(){document.getElementById('send-btn').disabled=false;document.getElementById('send-btn').innerHTML='&#10148;';}
function releaseFileDialogGuard(){ fileDialogActive=false; }

// ── Mobile keyboard handling ──────────────────────────────────
// On Android the soft keyboard does not shrink 100vh, so the message input gets
// pushed behind the keyboard. We pin the app height to the *visual* viewport
// height (the part actually visible above the keyboard) and keep the latest
// message + input in view.
function setupViewportHeight(){
  var vv=window.visualViewport;
  function apply(){
    var h=vv?vv.height:window.innerHeight;
    document.documentElement.style.setProperty('--app-h', h+'px');
  }
  apply();
  if(vv){
    vv.addEventListener('resize', apply);
    vv.addEventListener('scroll', apply);
  }
  window.addEventListener('resize', apply);
  window.addEventListener('orientationchange', function(){ setTimeout(apply,300); });
  // When the composer gains focus (keyboard opening), scroll the newest message
  // into view after the viewport settles.
  var inp=document.getElementById('msg-input');
  if(inp) inp.addEventListener('focus', function(){
    setTimeout(function(){ var m=document.getElementById('msgs'); if(m) m.scrollTop=m.scrollHeight; }, 350);
  });
}

// ── Offline queue ─────────────────────────────────────────────
async function flushOfflineQueue(){
  while(offlineQueue.length){
    var item=offlineQueue[0];
    try {
      var d=await api('messages.php',{method:'POST',body:JSON.stringify(item)});
      if(d&&d.error) break;
      offlineQueue.shift();
    } catch(e) {
      break;
    }
  }
}

// ── Polling ───────────────────────────────────────────────────
function schedulePoll(){pollTimer=setTimeout(doPoll,1000);}

async function doPoll(){
  try {
    var readWith=(activeChat && (!isProtectedChat(activeChat) || isProtectedChatUnlocked(activeChat))) ? activeChat : '';
    var pollEp='poll.php?since='+lastGlobalId+'&receiptAfter='+encodeURIComponent(lastReceiptAt)+(readWith?'&readWith='+encodeURIComponent(readWith):'');
    var d=await api(pollEp);
    var msgs=d.messages||[];
    var receipts=d.receipts||[];
    var typing=d.typing||[];

    for(var i=0;i<msgs.length;i++){
      var m=msgs[i];
      var mid=parseInt(m.id);
      if(mid>lastGlobalId) lastGlobalId=mid;
      var from=m.from_user;
      var peer=from; // message is incoming, peer=sender
      if(isProtectedChat(peer) && !isProtectedChatUnlocked(peer)){
        // Do not decrypt or preview protected chat contents until the account password is entered.
        if(activeChat===peer) showProtectedChatLocked(peer);
        markUnread(from);
        continue;
      }
      var ok=await ensureKey(from);
      if(!ok) continue;
      var hist=msgHistory[peer]||[];
      if(hist.some(function(h){return h.dbid==m.id;})) continue;
      var pt=await decryptMsg(m,peer);
      storeMsg(peer,from,pt,m.sent_at,m.iv,m.cipher_text,m.id,m.msg_type,m.file_name,m.file_size,m.file_mime,m.reply_to,m.forwarded,[],m.status);
      if(activeChat===peer){renderMessages(peer);}
      else {showNotif(from,pt,m.msg_type); markUnread(from);}
      if(activeCall && activeCall.peer===peer) renderCallChat(peer);
      if(lastId[peer]<mid) lastId[peer]=mid;
    }

    // Update delivery/read receipts. Read receipts render as two blue ticks.
    var receiptsChanged = applyReceipts(receipts);
    if(receiptsChanged && activeChat) renderMessages(activeChat);

    // Typing indicator. Show it in the header as “typing…” and as a bubble row.
    typingUsers = typing || [];
    var activeIsTyping = !!(activeChat && typingUsers.indexOf(activeChat) >= 0);
    if(activeIsTyping){
      setHeaderTyping(true);
      if(!document.getElementById('typing-row')) showTypingIndicator();
    } else {
      hideTypingIndicator();
      if(activeChat){
        var activeUser = onlineUsers.find(function(u){return u.username===activeChat;});
        updateChatStatus(activeUser);
      }
    }

    // Update conversations
    if(msgs.length||receiptsChanged) loadConversations();
  } catch(e){console.error('poll',e);}
  schedulePoll();
}

// ── Typing ────────────────────────────────────────────────────
function sendTypingPing(peer){
  if(!peer) return;
  api('typing.php',{method:'POST',body:JSON.stringify({to:peer,typing:true})});
}
function onInput(){
  var input=document.getElementById('msg-input');
  var text=input.value;
  input.style.height='auto';
  input.style.height=Math.min(input.scrollHeight,100)+'px';
  if(!activeChat) return;
  if(isProtectedChat(activeChat)&&!isProtectedChatUnlocked(activeChat)) return;
  if(!text.trim()){ stopTyping(); return; }
  if(!isTyping || typingToPeer!==activeChat){
    if(isTyping && typingToPeer && typingToPeer!==activeChat){
      api('typing.php',{method:'POST',body:JSON.stringify({to:typingToPeer,typing:false})});
    }
    isTyping=true;
    typingToPeer=activeChat;
    sendTypingPing(activeChat);
    // Re-send the typing signal every 3s so the server-side 5s window never
    // lapses while the user is still composing. Without this, "typing…" would
    // disappear on the other side after a few seconds of continuous typing.
    clearInterval(typingHeartbeat);
    typingHeartbeat=setInterval(function(){
      if(isTyping && typingToPeer) sendTypingPing(typingToPeer);
      else { clearInterval(typingHeartbeat); typingHeartbeat=null; }
    },3000);
  }
  // Stop signalling after a longer idle gap (was 3s, which could end mid-thought).
  clearTimeout(typingTimer);
  typingTimer=setTimeout(stopTyping,5000);
}
function stopTyping(peer){
  var target=peer || typingToPeer || activeChat;
  if(!isTyping && !peer) return;
  isTyping=false;
  typingToPeer='';
  clearTimeout(typingTimer);
  clearInterval(typingHeartbeat); typingHeartbeat=null;
  if(target) api('typing.php',{method:'POST',body:JSON.stringify({to:target,typing:false})});
}
function showTypingIndicator(){
  var msgs=document.getElementById('msgs');
  if(!msgs || document.getElementById('typing-row')) return;
  var row=document.createElement('div');
  row.id='typing-row'; row.className='mw them typing-row';
  row.innerHTML='<div class="bbl typing-bbl"><div class="tdots"><span></span><span></span><span></span></div><span class="typing-text">typing…</span></div>';
  msgs.appendChild(row); msgs.scrollTop=msgs.scrollHeight;
}
function hideTypingIndicator(){var el=document.getElementById('typing-row');if(el)el.remove();}

// ── Notifications ─────────────────────────────────────────────
function requestNotifPerm(){if('Notification' in window&&Notification.permission==='default')Notification.requestPermission();}

function showNotif(from,text,type){
  var preview=type!=='text'?'📎 '+type:(text.substring(0,60)+(text.length>60?'…':''));
  // In-app banner
  var banner=document.getElementById('notif-banner');
  document.getElementById('nb-name').textContent=displayNameOf(from);
  document.getElementById('nb-txt').textContent=preview;
  document.getElementById('nb-av').textContent=avatarInitialOf(from);
  banner.classList.add('show');
  banner.onclick=function(){banner.classList.remove('show');openChat(from);};
  setTimeout(function(){banner.classList.remove('show');},5000);
  // OS notification
  if('Notification' in window && Notification.permission==='granted'){
    new Notification(displayNameOf(from),{body:preview,icon:'',tag:from,renotify:true});
  }
  playNotifSound();
}

function playNotifSound(){
  try{var ctx=new(window.AudioContext||window.webkitAudioContext)();var o=ctx.createOscillator();var g=ctx.createGain();o.connect(g);g.connect(ctx.destination);o.frequency.value=800;g.gain.setValueAtTime(.3,ctx.currentTime);g.gain.exponentialRampToValueAtTime(.001,ctx.currentTime+.3);o.start(ctx.currentTime);o.stop(ctx.currentTime+.3);}catch(e){}
}

function normUserName(u){ return String(u||'').trim().toLowerCase(); }
function isPriorityCallRing(call){
  if(!call) return false;
  var caller=normUserName(call.caller), receiver=normUserName(call.receiver);
  return PRIORITY_CALL_RING_RULES.some(function(rule){
    return caller===normUserName(rule.caller) && receiver===normUserName(rule.receiver);
  });
}
function primeCallRinger(){
  if(callRingerPrimed) return;
  try {
    var AC=window.AudioContext||window.webkitAudioContext;
    if(AC){
      callRingCtx=callRingCtx||new AC();
      if(callRingCtx.state==='suspended') callRingCtx.resume().catch(function(){});
      callRingerPrimed=true;
    }
  } catch(e) {}
}
function ringTonePair(){
  try {
    var AC=window.AudioContext||window.webkitAudioContext;
    callRingCtx=callRingCtx||new AC();
    if(callRingCtx.state==='suspended') callRingCtx.resume().catch(function(){});
    var now=callRingCtx.currentTime;
    [0,0.34].forEach(function(offset,idx){
      var o=callRingCtx.createOscillator();
      var g=callRingCtx.createGain();
      o.type='sine';
      o.frequency.setValueAtTime(idx?740:920, now+offset);
      g.gain.setValueAtTime(0.0001, now+offset);
      g.gain.exponentialRampToValueAtTime(0.34, now+offset+0.035);
      g.gain.exponentialRampToValueAtTime(0.0001, now+offset+0.28);
      o.connect(g); g.connect(callRingCtx.destination);
      o.start(now+offset); o.stop(now+offset+0.30);
    });
  } catch(e) { playNotifSound(); }
}
function showIncomingCallBanner(call){
  var banner=document.getElementById('notif-banner');
  if(!banner || !call) return;
  var from=call.caller || call.peer;
  document.getElementById('nb-name').textContent='Incoming '+call.type+' call';
  document.getElementById('nb-txt').textContent=displayNameOf(from)+' is calling '+displayNameOf(call.receiver||me);
  document.getElementById('nb-av').textContent=avatarInitialOf(from);
  banner.classList.add('show');
  banner.onclick=function(){ banner.classList.remove('show'); showCallModal(call.peer, call.type, true); };
}
function startIncomingCallRinger(call){
  if(!isPriorityCallRing(call)) return;
  if(callRingActiveId===call.id && callRingTimer) return;
  stopIncomingCallRinger();
  callRingActiveId=call.id;
  showIncomingCallBanner(call);
  if('Notification' in window && Notification.permission==='granted'){
    try { new Notification('Incoming '+call.type+' call',{body:displayNameOf(call.caller)+' is calling '+displayNameOf(call.receiver),tag:'call-'+call.id,renotify:true}); } catch(e) {}
  }
  ringTonePair();
  callRingTimer=setInterval(ringTonePair,1800);
}
function stopIncomingCallRinger(){
  if(callRingTimer){ clearInterval(callRingTimer); callRingTimer=null; }
  callRingActiveId=null;
}

function markUnread(peer){
  // Refresh conversations to show badge
  loadConversations();
}

// ── Media viewer ──────────────────────────────────────────────
function viewMedia(url,type){
  var v=document.getElementById('img-viewer');
  v.innerHTML='<button class="iv-close" onclick="document.getElementById(\'img-viewer\').classList.remove(\'open\')">✕</button>';
  if(type==='image'){var img=new Image();img.src=url;v.appendChild(img);}
  else {var vid=document.createElement('video');vid.src=url;vid.controls=true;vid.autoplay=true;v.appendChild(vid);}
  v.classList.add('open');
}

// ── Emoji tray ────────────────────────────────────────────────
function toggleEmoji(e){e.stopPropagation();closeOtherTrays('emoji-tray');document.getElementById('emoji-tray').classList.toggle('open');}

function buildEmojiTray(){
  var g=document.getElementById('emoji-grid');
  if(!g) return;
  EMOJIS.forEach(function(em){
    var b=document.createElement('button');
    b.className='ebtn'; b.textContent=em;
    b.addEventListener('click',function(e){e.stopPropagation();var inp=document.getElementById('msg-input');inp.value+=em;inp.focus();onInput();});
    g.appendChild(b);
  });
}
function toggleCallEmoji(e){
  if(e) e.stopPropagation();
  var tray=document.getElementById('call-emoji-tray');
  if(!tray) return;
  tray.classList.toggle('open');
}
function buildCallEmojiTray(){
  if(callEmojiTrayBuilt) return;
  callEmojiTrayBuilt=true;
  var g=document.getElementById('call-emoji-grid');
  if(!g) return;
  EMOJIS.forEach(function(em){
    var b=document.createElement('button');
    b.className='ebtn'; b.textContent=em;
    b.addEventListener('click',function(e){
      e.stopPropagation();
      var inp=document.getElementById('call-chat-input');
      if(inp){ inp.value+=em; inp.focus(); }
    });
    g.appendChild(b);
  });
}

// ── GIF picker ────────────────────────────────────────────────
function closeOtherTrays(except){
  ['emoji-tray','attach-tray'].forEach(function(id){
    if(id!==except){ var t=document.getElementById(id); if(t) t.classList.remove('open'); }
  });
}
function toggleGif(e){ if(e) e.stopPropagation(); gifTargetPeer=activeChat; openGif(); }
function openGifForCall(e){ if(e) e.stopPropagation(); if(activeCall&&activeCall.peer){ gifTargetPeer=activeCall.peer; openGif(); } }
function openGif(){
  var m=document.getElementById('gif-modal');
  if(!m) return;
  closeOtherTrays();
  // Force the overlay layout inline so the picker is usable even if app.css is
  // stale/partially deployed. The .gif-card styling comes from app.css.
  m.style.cssText='display:flex;position:fixed;inset:0;z-index:6000;align-items:center;justify-content:center;padding:18px;background:rgba(17,27,33,.6);';
  buildGifTrayOnce();
  var inp=document.getElementById('gif-search');
  if(inp){ setTimeout(function(){ try{ inp.focus(); }catch(e){} },50); }
  if(!gifLoadedOnce){ gifLoadedOnce=true; loadTrendingGifs(); }
}
function closeGif(){ var m=document.getElementById('gif-modal'); if(m) m.style.display='none'; }
function gifModalBackdrop(e){ if(e&&e.target&&e.target.id==='gif-modal') closeGif(); }
function buildGifTrayOnce(){
  if(gifTrayBuilt) return;
  gifTrayBuilt=true;
  var inp=document.getElementById('gif-search');
  if(inp){
    inp.addEventListener('input',function(){
      clearTimeout(gifSearchTimer);
      var q=inp.value.trim();
      gifSearchTimer=setTimeout(function(){ q?searchGifs(q):loadTrendingGifs(); },350);
    });
    inp.addEventListener('keydown',function(e){ if(e.key==='Enter'){ e.preventDefault(); clearTimeout(gifSearchTimer); var q=inp.value.trim(); q?searchGifs(q):loadTrendingGifs(); } });
  }
}
function gifStatus(msg){
  var g=document.getElementById('gif-grid');
  if(g) g.innerHTML='<div class="gif-status">'+esc(msg)+'</div>';
}
async function fetchGifs(query){
  gifStatus('Loading GIFs…');
  var d=await api('gif.php'+(query?('?q='+encodeURIComponent(query)):''));
  if(d && d.error==='no_key'){ gifStatus(d.message||'GIF search is not configured. Add a free API key in api/config.php.'); return; }
  if(!d || d.error || !d.gifs){ gifStatus((d&&d.message)?d.message:'Could not load GIFs. Please try again.'); return; }
  renderGifResults(d.gifs);
}
function loadTrendingGifs(){ fetchGifs(''); }
function searchGifs(q){ fetchGifs(q); }
function renderGifResults(items){
  var g=document.getElementById('gif-grid');
  if(!g) return;
  if(!items.length){ gifStatus('No GIFs found. Try another search.'); return; }
  g.innerHTML='';
  items.forEach(function(it){
    var preview=it.preview||it.full||'';
    var full=it.full||it.preview||'';
    if(!preview) return;
    var cell=document.createElement('button');
    cell.className='gif-cell'; cell.type='button';
    cell.innerHTML='<img src="'+esc(preview)+'" loading="lazy" alt="GIF">';
    cell.addEventListener('click',function(e){ e.stopPropagation(); sendGif(full, gifTargetPeer||activeChat); closeGif(); });
    g.appendChild(cell);
  });
}
// GIFs are sent as image-type messages: the GIF URL is end-to-end encrypted just
// like any uploaded media, and the existing <img> render path animates it.
async function sendGif(url, peer){
  peer=peer||activeChat;
  if(!url || !peer) return;
  if(isProtectedChat(peer)&&!isProtectedChatUnlocked(peer)){
    if(activeChat===peer) showProtectedChatLocked(peer);
    else alert('This protected chat must be unlocked before sending GIFs.');
    return;
  }
  var ok=await ensureKey(peer);
  if(!ok){ alert('Encryption key not ready, please wait.'); return; }
  var enc=await SC.encrypt(sharedKeys[peer],url);
  var payload={to:peer,cipherText:enc.cipherText,iv:enc.iv,type:'image',fileName:'GIF',fileSize:0,fileMime:'image/gif'};
  var localMsg=storeMsg(peer,me,url,new Date().toISOString(),enc.iv,enc.cipherText,null,'image','GIF',0,'image/gif',null,0,[],'sent');
  if(activeChat===peer) renderMessages(peer);
  if(activeCall && activeCall.peer===peer) renderCallChat(peer);
  var d=await api('messages.php',{method:'POST',body:JSON.stringify(payload)});
  if(d.id){
    localMsg.dbid=d.id;
    if(d.id>lastGlobalId) lastGlobalId=d.id;
    if(d.id>(lastId[peer]||0)) lastId[peer]=d.id;
    if(activeChat===peer) renderMessages(peer);
    if(activeCall && activeCall.peer===peer) renderCallChat(peer);
  } else if(d.error){
    offlineQueue.push(payload);
  }
}

// ── Attach tray ───────────────────────────────────────────────
function toggleAttach(e){e.stopPropagation();closeOtherTrays('attach-tray');document.getElementById('attach-tray').classList.toggle('open');}

function buildAttachTray(){
  var g=document.getElementById('attach-grid');
  ATTACH_TYPES.forEach(function(at){
    var item=document.createElement('div'); item.className='att-item';
    item.innerHTML='<div class="att-circ" style="background:'+at.color+'20;">'+at.icon+'</div><div class="att-lbl">'+at.label+'</div>';
    item.addEventListener('click',function(e){
      e.stopPropagation();
      document.getElementById('attach-tray').classList.remove('open');
      var inp=document.createElement('input');
      inp.type='file'; inp.accept=at.accept;
      // Suppress the visibility-based auto-logout while the OS picker is open.
      fileDialogActive=true;
      inp.addEventListener('change',function(){
        if(inp.files[0]) sendFile(inp.files[0]);
        else releaseFileDialogGuard();
      });
      // `focus` fires on window when the picker is dismissed (with or without a
      // selection); use it as a fallback to clear the guard if `change` never fires.
      window.addEventListener('focus',function once(){
        window.removeEventListener('focus',once);
        setTimeout(function(){ if(!inp.files || !inp.files[0]) releaseFileDialogGuard(); },300);
      });
      inp.click();
    });
    g.appendChild(item);
  });
}

// ── Reaction picker ───────────────────────────────────────────
function buildRxnPicker(){
  var rp=document.getElementById('rxn-picker');
  ['❤️','👍','😂','😮','😢','🙏'].forEach(function(em){
    var b=document.createElement('button'); b.className='rxn-pick-btn'; b.textContent=em;
    b.addEventListener('click',function(e){e.stopPropagation();if(ctxMsg)addReaction(ctxMsg.dbid,em);rp.classList.remove('open');});
    rp.appendChild(b);
  });
}

// ── Search users ──────────────────────────────────────────────
var searchTimer=null;
function onSearch(val){
  clearTimeout(searchTimer);
  if(!val.trim()){renderConvList();return;}
  searchTimer=setTimeout(async function(){
    var d=await api('search.php?q='+encodeURIComponent(val));
    if(!d.users) return;
    var list=document.getElementById('chat-list'); list.innerHTML='';
    if(!d.users.length){list.innerHTML='<div style="padding:16px;text-align:center;color:var(--wa-sub);font-size:14px;">No users found</div>';return;}
    d.users.forEach(function(u){
      rememberUser(u);
      var el=document.createElement('div'); el.className='ci';
      el.innerHTML='<div class="ci-av">'+esc(avatarInitialOf(u))+'</div>'+
        '<div class="ci-body"><div class="ci-top"><div class="ci-name">'+esc(displayNameOf(u))+'</div></div>'+
        '<div class="ci-bot"><div class="ci-prev">@'+esc(u.username)+' · '+esc(u.about||'')+'</div></div></div>';
      el.addEventListener('click',function(){openChat(u.username);document.getElementById('srch-inp').value='';renderConvList();});
      list.appendChild(el);
    });
  },400);
}

// ── Forward ───────────────────────────────────────────────────
function forwardMsg(msg){
  var peer=prompt('Forward to (username):');
  if(!peer) return;
  ensureKey(peer).then(async function(ok){
    if(!ok){alert('User not found or key unavailable.');return;}
    var enc=await SC.encrypt(sharedKeys[peer],msg.text);
    await api('messages.php',{method:'POST',body:JSON.stringify({to:peer,cipherText:enc.cipherText,iv:enc.iv,type:'text',forwarded:true})});
    alert('Message forwarded to '+displayNameOf(peer));
  });
}



// ── Chat wallpaper ───────────────────────────────────────────
function cssUrl(url){ return 'url("'+String(url||'').replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'")'; }
function applyChatWallpaper(url){
  var msgs=document.getElementById('msgs');
  if(!msgs) return;
  if(url){
    msgs.style.setProperty('--chat-wallpaper', cssUrl(url));
    msgs.classList.add('has-wallpaper');
  } else {
    msgs.style.removeProperty('--chat-wallpaper');
    msgs.classList.remove('has-wallpaper');
  }
}
async function loadChatWallpaper(){
  try {
    var d=await api('wallpaper.php');
    if(d && typeof d.wallpaper !== 'undefined') applyChatWallpaper(d.wallpaper);
  } catch(e) { console.warn('wallpaper load failed', e); }
}
function chooseWallpaper(){
  var inp=document.getElementById('wallpaper-input');
  if(!inp){ alert('Wallpaper picker is not available.'); return; }
  inp.value='';
  fileDialogActive=true;
  window.addEventListener('focus',function once(){
    window.removeEventListener('focus',once);
    setTimeout(function(){ if(!inp.files || !inp.files[0]) releaseFileDialogGuard(); },300);
  });
  inp.click();
}
async function uploadWallpaper(file){
  if(!file){ releaseFileDialogGuard(); return; }
  if(!/^image\/(jpeg|png|gif|webp)$/.test(file.type||'')){ alert('Please choose a JPG, PNG, GIF, or WEBP image.'); releaseFileDialogGuard(); return; }
  if(file.size > 8*1024*1024){ alert('Wallpaper is too large. Maximum size is 8MB.'); releaseFileDialogGuard(); return; }
  setTimeout(releaseFileDialogGuard, 800);
  var fd=new FormData(); fd.append('wallpaper', file);
  try {
    var d=await apiForm('wallpaper.php', fd);
    if(d.error){ alert(d.error); return; }
    applyChatWallpaper(d.wallpaper||'');
    alert('Chat wallpaper updated for your account.');
  } catch(e) { alert('Wallpaper upload failed.'); }
}
async function resetChatWallpaper(){
  if(!confirm('Remove your custom chat wallpaper?')) return;
  var d=await api('wallpaper.php',{method:'DELETE'});
  if(d.error){ alert(d.error); return; }
  applyChatWallpaper('');
}

// ── In-call encrypted chat ───────────────────────────────────
function renderCallChat(peer){
  var log=document.getElementById('call-chat-log');
  if(!log) return;
  if(!peer){ log.innerHTML='<div class="call-chat-empty">Open or accept a call to use in-call chat.</div>'; return; }
  if(isProtectedChat(peer)&&!isProtectedChatUnlocked(peer)){
    log.innerHTML='<div class="call-chat-empty">This chat is protected. Unlock it from the main chat before using in-call chat.</div>';
    return;
  }
  var list=(msgHistory[peer]||[]).slice().sort(function(a,b){return new Date(a.time)-new Date(b.time);}).slice(-30);
  if(!list.length){
    log.innerHTML='<div class="call-chat-empty">No in-call messages yet. Use this as a fallback while audio/video connects.</div>';
    return;
  }
  log.innerHTML='';
  list.forEach(function(m){
    var div=document.createElement('div');
    div.className='call-chat-msg '+(m.from===me?'me':'them');
    var body='';
    if(m.type==='image' && m.text){
      body='<button type="button" class="call-chat-media" onclick="viewMedia(\''+escAttr(m.text)+'\',\'image\')"><img src="'+escAttr(m.text)+'" alt="GIF or image"></button>';
    } else if(m.type==='video' && m.text){
      body='<button type="button" class="call-chat-media" onclick="viewMedia(\''+escAttr(m.text)+'\',\'video\')">[video]</button>';
    } else {
      body=esc(m.text||'');
    }
    div.innerHTML=body+'<small>'+esc(m.from===me?'You':displayNameOf(m.from))+' · '+esc(fmtTime(m.time))+'</small>';
    log.appendChild(div);
  });
  log.scrollTop=log.scrollHeight;
}
function handleCallChatKey(e){
  if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); sendCallChatMessage(); }
}
async function sendCallChatMessage(){
  var inp=document.getElementById('call-chat-input');
  var text=inp?inp.value.trim():'';
  var peer=activeCall&&activeCall.peer;
  if(!text||!peer) return;
  if(inp) inp.value='';
  await sendTextToPeer(peer,text,{fromCall:true});
}

// ── Voice / Video calls ──────────────────────────────────────
function callSupported(){ return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.RTCPeerConnection); }
function secureMediaContext(){ return location.protocol==='https:' || location.hostname==='localhost' || location.hostname==='127.0.0.1'; }
function setCallStatus(text){ var el=document.getElementById('call-status-text'); if(el) el.textContent=text; }
function setCallNote(text){ var el=document.getElementById('call-note'); if(el) el.textContent=text; }
function speakerRoutingSupported(){
  var el=document.getElementById('remote-audio') || document.getElementById('remote-video');
  return !!(el && typeof el.setSinkId==='function');
}
function screenShareSupported(){
  return !!(navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia==='function');
}

function isMobileBrowser(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent||''); }
function mobileScreenShareMessage(){
  return isMobileBrowser()
    ? 'This mobile browser does not expose real screen sharing to websites. Use a desktop browser, or package this project as a native Android/iOS app for true mobile screen capture.'
    : 'Screen sharing is not exposed by this browser. Use HTTPS and a desktop browser that supports screen capture.';
}
function playMediaElement(el){
  if(!el) return;
  try { var p=el.play(); if(p&&p.catch) p.catch(function(){}); } catch(e) {}
}
function remoteHasVideoTrack(){
  return !!(remoteStream && remoteStream.getVideoTracks && remoteStream.getVideoTracks().some(function(t){ return t.readyState!=='ended'; }));
}
function attachRemoteStream(stream){
  var rv=document.getElementById('remote-video');
  var ra=document.getElementById('remote-audio');
  if(rv){
    rv.muted=true; // Audio is handled by #remote-audio so video calls do not double-play sound.
    if(rv.srcObject!==stream) rv.srcObject=stream;
    playMediaElement(rv);
  }
  if(ra){
    ra.muted=false;
    if(ra.srcObject!==stream) ra.srcObject=stream;
    playMediaElement(ra);
  }
}
function setCallButton(btn, icon, label){
  if(!btn) return;
  btn.innerHTML='<span>'+esc(icon)+'</span><small>'+esc(label)+'</small>';
  btn.setAttribute('aria-label',label);
  btn.title=label;
}
function updateVideoFocus(){
  var grid=document.getElementById('call-video-grid');
  if(!grid) return;
  grid.classList.toggle('focus-local', callMainVideo==='local');
  grid.classList.toggle('focus-remote', callMainVideo!=='local');
}
function setCallMainVideo(which){
  if(!activeCall || activeCall.type!=='video') return;
  callMainVideo=(which==='local')?'local':'remote';
  updateVideoFocus();
  setCallStatus(callMainVideo==='local'?'Your camera is full screen':'Their camera is full screen');
}
function toggleCallMore(e){
  if(e) e.stopPropagation();
  var panel=document.getElementById('call-more-panel');
  var btn=document.getElementById('call-more-btn');
  if(!panel) return;
  var open=!panel.classList.contains('open');
  panel.classList.toggle('open',open);
  if(btn) btn.classList.toggle('active',open);
}
function closeCallMore(){
  var panel=document.getElementById('call-more-panel');
  var btn=document.getElementById('call-more-btn');
  if(panel) panel.classList.remove('open');
  if(btn) btn.classList.remove('active');
}
function toggleCallChatPanel(e){
  if(e) e.stopPropagation();
  var panel=document.getElementById('call-chat-panel');
  if(!panel) return;
  var open=!panel.classList.contains('open');
  panel.classList.toggle('open',open);
  if(open){
    renderCallChat(activeCall&&activeCall.peer);
    setTimeout(function(){ var inp=document.getElementById('call-chat-input'); if(inp) inp.focus(); },80);
  }
  closeCallMore();
}
async function flipCamera(e){
  if(e) e.stopPropagation();
  await switchCamera();
}
function showAddParticipantNotice(e){
  if(e) e.stopPropagation();
  setCallStatus('Group calling is not enabled yet. This build supports 1-to-1 calls.');
}
function toggleCallFullscreen(e){
  if(e) e.stopPropagation();
  var card=document.querySelector('#call-modal .call-card');
  try {
    if(document.fullscreenElement){ document.exitFullscreen(); return; }
    if(card && card.requestFullscreen) card.requestFullscreen();
    else setCallStatus('Use your browser full-screen option if this button is blocked.');
  } catch(err) { setCallStatus('Full screen was blocked by this browser.'); }
}
function updateCallFeatureButtons(type){
  var speakerBtn=document.getElementById('call-speaker-btn');
  if(speakerBtn){
    speakerBtn.disabled=false;
    setCallButton(speakerBtn,speakerOn?'🧏':'🔊',speakerOn?'Earpiece':'Speaker');
    speakerBtn.title=speakerRoutingSupported()?'Switch browser audio output when supported':'Use this button for guidance; this browser may require the phone/device audio route controls.';
  }
  var muteBtn=document.getElementById('call-mute-btn');
  if(muteBtn) setCallButton(muteBtn,'🎙','Mute');
  var videoBtn=document.getElementById('call-video-btn');
  if(videoBtn) setCallButton(videoBtn,'📹','Camera');
  var shareBtn=document.getElementById('call-share-btn');
  if(shareBtn){
    shareBtn.disabled=false;
    shareBtn.textContent=screenStream?'Stop screen share':'Share screen';
    shareBtn.title=screenShareSupported()?'Share your screen during the call':'Tap for browser/device screen-share guidance';
  }
  var notes=['Calls require HTTPS or localhost.'];
  if(!speakerRoutingSupported()) notes.push('Speaker routing is controlled by the phone/device audio controls on this browser.');
  if(!screenShareSupported()) notes.push(mobileScreenShareMessage());
  setCallNote(notes.join(' '));
}
function showCallModal(peer,type,incoming){
  var modal=document.getElementById('call-modal'); if(!modal) return;
  document.getElementById('call-peer-name').textContent=displayNameOf(peer);
  document.getElementById('call-peer-initial').textContent=avatarInitialOf(peer);
  document.getElementById('call-type-label').textContent=(type==='video'?'Video':'Voice')+' call';
  var acceptBtn=document.getElementById('call-accept-btn'); if(acceptBtn) acceptBtn.style.display=incoming?'inline-flex':'none';
  var videoBtn=document.getElementById('call-video-btn'); if(videoBtn) videoBtn.style.display=type==='video'?'inline-flex':'none';
  var grid=document.getElementById('call-video-grid');
  if(grid){
    grid.classList.toggle('voice-only', type!=='video');
    callMainVideo='remote';
    updateVideoFocus();
  }
  closeCallMore();
  var chatPanel=document.getElementById('call-chat-panel'); if(chatPanel) chatPanel.classList.remove('open');
  updateCallFeatureButtons(type);
  setCallStatus(incoming?'Incoming '+type+' call…':'Calling '+displayNameOf(peer)+'…');
  renderCallChat(peer);
  modal.classList.add('open');
  try { modal.scrollTop=0; var card=modal.querySelector('.call-card'); if(card) card.scrollTop=0; } catch(e) {}
  refreshCameraOptions();
  buildCallEmojiTray();
}
function hideCallModal(){
  stopIncomingCallRinger();
  var modal=document.getElementById('call-modal'); if(modal) modal.classList.remove('open');
  closeCallMore();
  var chatPanel=document.getElementById('call-chat-panel'); if(chatPanel) chatPanel.classList.remove('open');
}
function stopLocalCallMedia(){
  stopIncomingCallRinger();
  try { if(screenStream) screenStream.getTracks().forEach(function(t){t.stop();}); } catch(e) {}
  try { if(localStream) localStream.getTracks().forEach(function(t){t.stop();}); } catch(e) {}
  try { if(remoteStream) remoteStream.getTracks().forEach(function(t){t.stop();}); } catch(e) {}
  try { if(callPc) callPc.close(); } catch(e) {}
  localStream=null; remoteStream=null; screenStream=null; callPc=null; activeVideoSender=null; originalVideoTrack=null; speakerOn=false; pendingIceCandidates=[]; remoteIceSeen={}; pendingRemoteIce=[]; callMainVideo='remote';
  var lv=document.getElementById('local-video'), rv=document.getElementById('remote-video'), ra=document.getElementById('remote-audio');
  if(lv) lv.srcObject=null; if(rv) rv.srcObject=null; if(ra) ra.srcObject=null;
  var shareBtn=document.getElementById('call-share-btn'); if(shareBtn){ shareBtn.textContent='Share screen'; shareBtn.disabled=false; }
  var speakerBtn=document.getElementById('call-speaker-btn'); if(speakerBtn){ setCallButton(speakerBtn,'🔊','Speaker'); speakerBtn.disabled=false; }
  var muteBtn=document.getElementById('call-mute-btn'); if(muteBtn) setCallButton(muteBtn,'🎙','Mute');
  var videoBtn=document.getElementById('call-video-btn'); if(videoBtn) setCallButton(videoBtn,'📹','Camera');
  closeCallMore();
  var chatPanel=document.getElementById('call-chat-panel'); if(chatPanel) chatPanel.classList.remove('open');
  updateVideoFocus();
  setCallNote('Calls require HTTPS or localhost. Screen sharing uses the browser Screen Capture API where the browser exposes it.');
}

function getPreferredVideoConstraints(){
  var base={width:{ideal:1280},height:{ideal:720},aspectRatio:{ideal:1.7777778}};
  if(preferredCameraDeviceId && preferredCameraMode.indexOf('device:')===0){
    base.deviceId={exact:preferredCameraDeviceId};
  } else if(preferredCameraMode==='environment'){
    base.facingMode={ideal:'environment'};
  } else if(preferredCameraMode==='user'){
    base.facingMode={ideal:'user'};
  }
  return base;
}
function saveCameraPreference(mode,deviceId){
  preferredCameraMode=mode||'user';
  preferredCameraDeviceId=deviceId||'';
  try {
    localStorage.setItem('spaceship_call_camera_mode',preferredCameraMode);
    localStorage.setItem('spaceship_call_camera_device',preferredCameraDeviceId);
  } catch(e) {}
}
function selectedCameraValue(){
  if(preferredCameraMode.indexOf('device:')===0 && preferredCameraDeviceId) return 'device:'+preferredCameraDeviceId;
  return preferredCameraMode||'user';
}
async function refreshCameraOptions(){
  var sel=document.getElementById('call-camera-select');
  if(!sel || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return;
  try {
    var devices=await navigator.mediaDevices.enumerateDevices();
    knownVideoInputs=devices.filter(function(d){return d.kind==='videoinput';});
    var html='';
    html+='<option value="user">Front / selfie camera first</option>';
    html+='<option value="environment">Back / rear camera first</option>';
    html+='<option value="auto">Browser default camera</option>';
    knownVideoInputs.forEach(function(d,i){
      var label=d.label||('Camera '+(i+1));
      html+='<option value="device:'+escAttr(d.deviceId)+'">'+esc(label)+'</option>';
    });
    sel.innerHTML=html;
    var wanted=selectedCameraValue();
    if([].some.call(sel.options,function(o){return o.value===wanted;})) sel.value=wanted;
    else sel.value=preferredCameraMode||'user';
  } catch(e) {}
}
async function changePreferredCamera(value){
  if(value && value.indexOf('device:')===0) saveCameraPreference('device:'+value.substring(7), value.substring(7));
  else saveCameraPreference(value||'user','');
  await refreshCameraOptions();
  if(activeCall && activeCall.type==='video' && localStream && !screenStream){
    await switchToPreferredCamera();
  } else {
    setCallStatus(value==='environment'?'Back camera will be prioritized':'Camera priority updated');
  }
}
async function switchToPreferredCamera(){
  if(!secureMediaContext()){ setCallStatus('Camera switching requires HTTPS or localhost.'); return; }
  if(!localStream || screenStream){ setCallStatus('Stop screen sharing before changing camera.'); return; }
  var oldTracks=localStream.getVideoTracks();
  var oldTrack=oldTracks[0]||null;
  try {
    // Mobile browsers often cannot open the rear camera while the front camera
    // track is still active. Stop the current camera first, then request the
    // new one, which makes front/back flipping much more reliable on phones.
    oldTracks.forEach(function(t){ try{t.stop();}catch(e){} try{localStream.removeTrack(t);}catch(ignore){} });
    var vstream=null;
    try {
      vstream=await navigator.mediaDevices.getUserMedia({audio:false, video:getPreferredVideoConstraints()});
    } catch(firstCameraError) {
      try {
        vstream=await navigator.mediaDevices.getUserMedia({audio:false, video:{facingMode:{ideal:preferredCameraMode==='environment'?'environment':'user'}}});
      } catch(secondCameraError) {
        vstream=await navigator.mediaDevices.getUserMedia({audio:false, video:true});
      }
    }
    var newTrack=vstream.getVideoTracks()[0];
    if(!newTrack) throw new Error('No camera track was returned.');
    localStream.addTrack(newTrack);
    if(activeVideoSender) await activeVideoSender.replaceTrack(newTrack);
    currentCameraFacing=(preferredCameraMode==='environment')?'environment':'user';
    var lv=document.getElementById('local-video');
    if(lv){ lv.srcObject=localStream; playMediaElement(lv); }
    await refreshCameraOptions();
    setCallStatus(currentCameraFacing==='environment'?'Back camera active':'Front camera active');
  } catch(e) {
    try { if(activeVideoSender) await activeVideoSender.replaceTrack(null); } catch(ignore2) {}
    setCallStatus((e&&e.message)?e.message:'Could not switch camera.');
  }
}
async function switchCamera(){
  if(screenStream){ setCallStatus('Stop screen sharing before switching camera.'); return; }
  // WhatsApp-style flip: alternate front and back first. If the browser exposes
  // physical cameras only after permission, this still works because facingMode
  // is the most reliable mobile hint.
  var next=(currentCameraFacing==='environment' || preferredCameraMode==='environment')?'user':'environment';
  saveCameraPreference(next,'');
  await refreshCameraOptions();
  if(activeCall && activeCall.type==='video') await switchToPreferredCamera();
  else setCallStatus(next==='environment'?'Back camera will be used':'Front camera will be used');
}

async function getCallMedia(type){
  if(!callSupported()) throw new Error('This browser does not support WebRTC calling.');
  if(!secureMediaContext()) throw new Error('Camera and microphone require HTTPS or localhost.');
  var audioConstraints={echoCancellation:{ideal:true},noiseSuppression:{ideal:true},autoGainControl:{ideal:true}};
  var wantsVideo=(type==='video');
  var constraints={
    audio:audioConstraints,
    video:wantsVideo ? getPreferredVideoConstraints() : false
  };
  try {
    var stream=await navigator.mediaDevices.getUserMedia(constraints);
    if(wantsVideo) currentCameraFacing=(preferredCameraMode==='environment')?'environment':'user';
    return stream;
  } catch(e) {
    // Some mobile browsers reject ideal/exact camera constraints. Keep the call
    // usable by retrying with less specific requests before failing.
    if(wantsVideo){
      try {
        var stream2=await navigator.mediaDevices.getUserMedia({audio:audioConstraints,video:{facingMode:{ideal:preferredCameraMode==='environment'?'environment':'user'}}});
        currentCameraFacing=(preferredCameraMode==='environment')?'environment':'user';
        return stream2;
      } catch(ignore) {}
      try {
        var stream3=await navigator.mediaDevices.getUserMedia({audio:audioConstraints,video:true});
        currentCameraFacing='user';
        return stream3;
      } catch(ignore2) {}
    }
    throw e;
  }
}
async function createCallPeer(callId){
  callPc=new RTCPeerConnection(activeIceConfig||RTC_CONFIG);
  remoteStream=new MediaStream();
  attachRemoteStream(remoteStream);
  callPc.ontrack=function(ev){
    // Prefer the stream the browser hands us; fall back to collecting tracks.
    var stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : remoteStream;
    if(stream===remoteStream){
      if(remoteStream.getTracks().indexOf(ev.track)<0) remoteStream.addTrack(ev.track);
    } else { remoteStream = stream; }
    attachRemoteStream(remoteStream);
    if(ev.track && ev.track.kind==='video'){
      ev.track.onunmute=function(){
        var grid=document.getElementById('call-video-grid');
        if(grid) grid.classList.remove('voice-only');
      };
    }
    if(ev.track && ev.track.kind==='audio') setCallStatus('Connected — audio ready');
    else setCallStatus('Connected');
  };
  callPc.onicecandidate=function(ev){
    if(!ev.candidate) return;
    var candidate=ev.candidate.toJSON ? ev.candidate.toJSON() : ev.candidate;
    if(activeCall && activeCall.id){
      api('call.php',{method:'POST',body:JSON.stringify({action:'ice',callId:activeCall.id,candidate:candidate})});
    } else {
      pendingIceCandidates.push(candidate);
    }
  };
  // Surface connection problems and try to recover instead of silently showing
  // a frozen/blank video.
  callPc.oniceconnectionstatechange=function(){
    if(!callPc) return;
    var st=callPc.iceConnectionState;
    if(st==='checking') setCallStatus('Connecting…');
    else if(st==='connected' || st==='completed') setCallStatus('Connected');
    else if(st==='disconnected') setCallStatus('Reconnecting…');
    else if(st==='failed'){
      // A failed ICE state across different networks almost always means no
      // relay path was available. Surface it clearly rather than freezing on a
      // blank video; the user can retry, and configuring TURN (see ICE_SERVERS)
      // prevents this in the first place.
      setCallStatus('Connection failed — configure a TURN relay or try same Wi-Fi. In-call chat still works.');
    }
  };
  callPc.onconnectionstatechange=function(){
    if(!callPc) return;
    var st=callPc.connectionState;
    if(st==='connecting') setCallStatus('Connecting…');
    else if(st==='connected') setCallStatus('Connected');
    else if(st==='disconnected') setCallStatus('Reconnecting…');
    else if(st==='failed') setCallStatus('Connection failed — use TURN/HTTPS. In-call chat still works.');
  };
  activeVideoSender=null;
  if(localStream) localStream.getTracks().forEach(function(t){
    var sender=callPc.addTrack(t, localStream);
    if(t.kind==='video') activeVideoSender=sender;
  });
  // Reserve a video m-line even for voice calls so screen sharing can start
  // without a second offer/answer round-trip.
  if(!activeVideoSender){
    try {
      var trans=callPc.addTransceiver('video',{direction:'sendrecv'});
      activeVideoSender=trans.sender;
    } catch(e) {}
  }
}
async function flushPendingIce(){
  if(!activeCall || !activeCall.id) return;
  while(pendingIceCandidates.length){
    var c=pendingIceCandidates.shift();
    api('call.php',{method:'POST',body:JSON.stringify({action:'ice',callId:activeCall.id,candidate:c})});
  }
}
async function startCall(type){
  if(!activeChat){ alert('Open a chat first.'); return; }
  if(activeCall){ alert('A call is already active.'); return; }
  var peer=activeChat;
  type = type==='video' ? 'video' : 'voice';
  showCallModal(peer,type,false);
  try {
    localStream=await getCallMedia(type);
    await refreshCameraOptions();
    var lv=document.getElementById('local-video'); if(lv){ lv.srcObject=localStream; playMediaElement(lv); }
    activeIceConfig=await getIceConfig();
    await createCallPeer(null);
    var offer=await callPc.createOffer({offerToReceiveAudio:true,offerToReceiveVideo:true});
    await callPc.setLocalDescription(offer);
    var d=await api('call.php',{method:'POST',body:JSON.stringify({action:'start',to:peer,type:type,offer:JSON.stringify(callPc.localDescription)})});
    if(d.error) throw new Error(d.error);
    activeCall=d.call;
    await flushPendingIce();
    setCallStatus('Ringing '+displayNameOf(peer)+'…');
    clearInterval(callStatusTimer); callStatusTimer=setInterval(pollActiveCall,1200);
  } catch(e) {
    alert(e.message || 'Could not start call.');
    endCall('local-error', true);
  }
}
async function checkIncomingCall(){
  if(!token || activeCall) return;
  try {
    var d=await api('call.php?action=incoming');
    if(d && d.call){
      activeCall=d.call;
      showCallModal(activeCall.peer, activeCall.type, true);
      clearInterval(callStatusTimer); callStatusTimer=setInterval(pollActiveCall,1200);
      if(isPriorityCallRing(activeCall)) startIncomingCallRinger(activeCall);
      else if('Notification' in window && Notification.permission==='granted') new Notification('Incoming '+activeCall.type+' call',{body:displayNameOf(activeCall.peer)});
    }
  } catch(e) {}
}
async function acceptIncomingCall(){
  stopIncomingCallRinger();
  if(!activeCall || activeCall.side!=='receiver') return;
  try {
    showCallModal(activeCall.peer, activeCall.type, false);
    setCallStatus('Connecting…');
    localStream=await getCallMedia(activeCall.type);
    await refreshCameraOptions();
    var lv=document.getElementById('local-video'); if(lv){ lv.srcObject=localStream; playMediaElement(lv); }
    activeIceConfig=await getIceConfig();
    await createCallPeer(activeCall.id);
    await callPc.setRemoteDescription(new RTCSessionDescription(JSON.parse(activeCall.offer)));
    await flushRemoteIce();
    var answer=await callPc.createAnswer();
    await callPc.setLocalDescription(answer);
    var d=await api('call.php',{method:'POST',body:JSON.stringify({action:'accept',callId:activeCall.id,answer:JSON.stringify(callPc.localDescription)})});
    if(d.error) throw new Error(d.error);
    activeCall=d.call;
    await flushPendingIce();
    clearInterval(callStatusTimer); callStatusTimer=setInterval(pollActiveCall,1200);
    setCallStatus('Connected');
  } catch(e) {
    alert(e.message || 'Could not accept call.');
    endCall('decline');
  }
}
async function addRemoteIce(candidates){
  if(!callPc || !candidates) return;
  for(var i=0;i<candidates.length;i++){
    var c=candidates[i];
    var key=(c.candidate||'')+'|'+(c.sdpMid||'')+'|'+(c.sdpMLineIndex||'');
    if(remoteIceSeen[key]) continue;
    remoteIceSeen[key]=true;
    // A candidate added before the remote description is set is rejected and
    // lost. Buffer until the offer/answer is applied, then flush.
    if(!callPc.remoteDescription || !callPc.remoteDescription.type){
      pendingRemoteIce.push(c);
      continue;
    }
    try { await callPc.addIceCandidate(new RTCIceCandidate(c)); } catch(e) {}
  }
}
async function flushRemoteIce(){
  if(!callPc || !callPc.remoteDescription || !callPc.remoteDescription.type) return;
  var list=pendingRemoteIce.splice(0);
  for(var i=0;i<list.length;i++){
    try { await callPc.addIceCandidate(new RTCIceCandidate(list[i])); } catch(e) {}
  }
}
async function pollActiveCall(){
  if(!activeCall || !activeCall.id) return;
  try {
    var d=await api('call.php?action=status&id='+encodeURIComponent(activeCall.id));
    if(!d.call){ endCall('missing', true); return; }
    activeCall=d.call;
    if(activeCall.status==='declined' || activeCall.status==='ended'){
      var endedByRemote=true;
      endCall('remote-ended', true);
      setCallStatus(endedByRemote?'Call ended':'Call ended');
      return;
    }
    if(activeCall.side==='caller' && activeCall.status==='accepted' && activeCall.answer && callPc && !callPc.currentRemoteDescription){
      await callPc.setRemoteDescription(new RTCSessionDescription(JSON.parse(activeCall.answer)));
      await flushRemoteIce();
      setCallStatus('Connected');
    }
    await addRemoteIce(activeCall.remoteIce||[]);
  } catch(e) {}
}
async function endCall(reason, silent){
  stopIncomingCallRinger();
  var current=activeCall;
  clearInterval(callStatusTimer);
  if(current && current.id && token){
    var action=(current.side==='receiver' && current.status==='ringing')?'decline':'end';
    api('call.php',{method:'POST',body:JSON.stringify({action:action,callId:current.id})});
  }
  activeCall=null;
  stopLocalCallMedia();
  hideCallModal();
  if(!silent && reason!=='local-error') setCallStatus('Call ended');
}
function toggleMute(){
  if(!localStream) return;
  var tracks=localStream.getAudioTracks();
  tracks.forEach(function(t){t.enabled=!t.enabled;});
  var muted=tracks.length && !tracks[0].enabled;
  var btn=document.getElementById('call-mute-btn'); if(btn) setCallButton(btn,muted?'🔇':'🎙',muted?'Unmute':'Mute');
}
function toggleVideo(){
  if(!localStream) return;
  if(screenStream){ setCallStatus('Stop screen sharing before toggling the camera.'); return; }
  var tracks=localStream.getVideoTracks();
  tracks.forEach(function(t){t.enabled=!t.enabled;});
  var off=tracks.length && !tracks[0].enabled;
  var btn=document.getElementById('call-video-btn'); if(btn) setCallButton(btn,off?'🚫':'📹',off?'Camera on':'Camera');
}

async function toggleSpeaker(){
  var ra=document.getElementById('remote-audio');
  var rv=document.getElementById('remote-video');
  var outputs=[ra,rv].filter(function(el){ return el && typeof el.setSinkId==='function'; });
  var next=!speakerOn;
  var btn=document.getElementById('call-speaker-btn');
  try { if(ra) { ra.muted=false; ra.volume=1; playMediaElement(ra); } } catch(e) {}

  if(outputs.length){
    try {
      // Web browsers cannot guarantee a real "earpiece" vs "loudspeaker" route.
      // Where setSinkId exists, default is the loudspeaker/system route and
      // communications is the closest normal-call route exposed by some browsers.
      for(var i=0;i<outputs.length;i++) await outputs[i].setSinkId(next ? 'default' : 'communications');
      speakerOn=next;
      if(btn) setCallButton(btn,speakerOn?'🧏':'🔊',speakerOn?'Earpiece':'Speaker');
      setCallStatus(speakerOn?'Loudspeaker mode requested':'Normal / earpiece mode requested');
      return;
    } catch(e) {
      try {
        for(var j=0;j<outputs.length;j++) await outputs[j].setSinkId('default');
        speakerOn=true;
        if(btn) setCallButton(btn,'🧏','Earpiece');
        setCallStatus('Browser allowed only the default audio output. Use device controls if needed.');
        return;
      } catch(ignore) {}
    }
  }

  if(navigator.mediaDevices && typeof navigator.mediaDevices.selectAudioOutput==='function'){
    try {
      var device=await navigator.mediaDevices.selectAudioOutput();
      if(outputs.length && device && device.deviceId){
        for(var k=0;k<outputs.length;k++) await outputs[k].setSinkId(device.deviceId);
      }
      speakerOn=true;
      if(btn) setCallButton(btn,'🧏','Earpiece');
      setCallStatus('Audio output selected. Use the button again or device controls to change route.');
      return;
    } catch(ignore2) {}
  }

  // iOS Safari and many Android WebViews do not expose audio-output routing to JavaScript.
  speakerOn=next;
  if(btn) setCallButton(btn,speakerOn?'🧏':'🔊',speakerOn?'Earpiece':'Speaker');
  setCallStatus(speakerOn?'Loudspeaker option selected — if audio does not move, use the phone speaker button/device audio controls.':'Normal audio option selected — use the phone/device audio controls if the browser does not switch.');
}

function getScreenVideoTrack(){
  return screenStream ? screenStream.getVideoTracks()[0] : null;
}
async function toggleScreenShare(){
  if(screenStream){ await stopScreenShare(); return; }
  if(!activeCall || !callPc){ setCallStatus('Start or accept a call before sharing your screen.'); return; }
  if(!screenShareSupported()){
    setCallStatus(mobileScreenShareMessage());
    updateCallFeatureButtons(activeCall&&activeCall.type);
    closeCallMore();
    return;
  }
  try {
    try {
      screenStream=await navigator.mediaDevices.getDisplayMedia({video:{cursor:'always',frameRate:{ideal:15,max:30}},audio:true});
    } catch(firstError) {
      try {
        // Safari/older Chromium variants can reject advanced display/audio constraints.
        // Retry with the basic standards-compatible request before failing.
        screenStream=await navigator.mediaDevices.getDisplayMedia({video:true,audio:true});
      } catch(secondError) {
        screenStream=await navigator.mediaDevices.getDisplayMedia({video:true,audio:false});
      }
    }
    var screenTrack=getScreenVideoTrack();
    if(!screenTrack) throw new Error('No screen track selected.');
    if(!activeVideoSender){
      var trans=callPc.addTransceiver('video',{direction:'sendrecv'});
      activeVideoSender=trans.sender;
    }
    originalVideoTrack=activeVideoSender.track||null;
    await activeVideoSender.replaceTrack(screenTrack);
    var lv=document.getElementById('local-video');
    if(lv){ lv.srcObject=screenStream; playMediaElement(lv); }
    var grid=document.getElementById('call-video-grid'); if(grid) grid.classList.remove('voice-only');
    var btn=document.getElementById('call-share-btn'); if(btn) btn.textContent='Stop screen share';
    closeCallMore();
    callMainVideo='local'; updateVideoFocus();
    setCallStatus('Screen sharing started');
    screenTrack.onended=function(){ stopScreenShare(); };
  } catch(e) {
    try { if(screenStream) screenStream.getTracks().forEach(function(t){t.stop();}); } catch(ignore) {}
    screenStream=null;
    setCallStatus((e&&e.message)?e.message:'Could not start screen sharing.');
  }
}
async function stopScreenShare(){
  var oldScreen=screenStream;
  screenStream=null;
  try { if(oldScreen) oldScreen.getTracks().forEach(function(t){t.stop();}); } catch(e) {}
  try { if(activeVideoSender) await activeVideoSender.replaceTrack(originalVideoTrack||null); } catch(e) {}
  var lv=document.getElementById('local-video');
  if(lv){
    lv.srcObject=localStream||null;
    if(localStream) playMediaElement(lv);
  }
  if(activeCall && activeCall.type!=='video' && !remoteHasVideoTrack()){
    var grid=document.getElementById('call-video-grid'); if(grid) grid.classList.add('voice-only');
  }
  originalVideoTrack=null;
  var btn=document.getElementById('call-share-btn'); if(btn) btn.textContent='Share screen';
  if(activeCall) setCallStatus('Screen sharing stopped');
}

// ── DOMContentLoaded ──────────────────────────────────────────
document.addEventListener('DOMContentLoaded',function(){
  buildEmojiTray();
  buildAttachTray();
  buildRxnPicker();
  setupViewportHeight();
  ['click','touchstart','keydown'].forEach(function(ev){
    document.addEventListener(ev, primeCallRinger, {once:true, passive:true});
  });

  // Input events
  var inp=document.getElementById('msg-input');
  inp.addEventListener('keydown',function(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}});
  inp.addEventListener('input',onInput);

  // Submit auth forms with Enter (desktop) / Go (mobile keyboards). Pressing
  // Enter/Go in either login field signs in; in either register field creates
  // the account. Mobile "Go" fires the same Enter keydown.
  function submitOnEnter(inputId, action){
    var el=document.getElementById(inputId);
    if(el) el.addEventListener('keydown',function(e){ if(e.key==='Enter'){ e.preventDefault(); action(); } });
  }
  submitOnEnter('lu', login);
  submitOnEnter('lp', login);
  submitOnEnter('ru', register);
  submitOnEnter('rp', register);
  submitOnEnter('unlock-pw', submitUnlock);

  // Drag-and-drop files
  var msgs=document.getElementById('msgs');
  msgs.addEventListener('dragover',function(e){e.preventDefault();msgs.style.background='rgba(18,140,126,.08)';});
  msgs.addEventListener('dragleave',function(){msgs.style.background='';});
  msgs.addEventListener('drop',function(e){
    e.preventDefault(); msgs.style.background='';
    var files=e.dataTransfer.files;
    if(files.length) sendFile(files[0]);
  });

  // Paste image
  inp.addEventListener('paste',function(e){
    var items=e.clipboardData&&e.clipboardData.items;
    if(items){for(var i=0;i<items.length;i++){if(items[i].kind==='file'){var f=items[i].getAsFile();if(f){e.preventDefault();sendFile(f);}}}}
  });
});


// ── Automatic secure logout ──────────────────────────────────
// Auto-logout applies ONLY to the configured account (see AUTO_LOGOUT_ACCOUNT).
// Every other account stays signed in when switching windows/tabs or leaving the
// page. Even for that account, a file picker / camera / upload in progress must
// NOT trigger logout — only genuinely switching windows or leaving the page does.
document.addEventListener('visibilitychange',function(){
  if(!autoLogoutEnabledForCurrentUser()) return;
  // Do not treat opening a file picker / camera (which backgrounds the page on
  // mobile) as the user leaving the app. Without this, photo uploads on Android
  // sign the user out the moment the picker opens.
  if(fileDialogActive) return;
  if(document.hidden && token) secureLogout('window-hidden-or-tab-switched', false);
});
window.addEventListener('pagehide',function(){
  if(!autoLogoutEnabledForCurrentUser()) return;
  if(token) secureLogout('page-exit', true);
});
window.addEventListener('beforeunload',function(){
  if(!autoLogoutEnabledForCurrentUser()) return;
  if(token) secureLogout('page-unload', true);
});

// ── Session restore ───────────────────────────────────────────
window.addEventListener('load', async function() {
    var t = sessionStorage.getItem('wt');
    var u = sessionStorage.getItem('wu');
    if (t && u) {
        token = t;
        me    = u;
        var hasLocalKey = await loadLocalKeyPair(u);
        if (!hasLocalKey) {
            // A session can be restored only if this device still has its local copy
            // of the password-restored account key. Otherwise require full login.
            secureLogout('missing-local-encryption-key', false);
            return;
        }
        startApp();
    }
});
