// HTTP JSON API routes (auth, MFA, users, machines, report, audit, media uploads, SSO).
// Returns a { "METHOD /path": handler } map consumed by server.js.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const BZ = require('./bizgaze');
const W = require('./webhooks');
const CHAT = require('./chat');
const PUSH = require('./push');
const MSG_MAX = 4000;
const parseMentions = (s) => { if (!s) return []; try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { return []; } };
const SYSTEM_SENDER = '__system__';
const msgDTO = (m) => ({ id: m.id, from: m.sender_id, to: m.recipient_id, conversation_id: m.conversation_id || null, body: m.deleted ? '' : m.body, created_at: m.created_at, read_at: m.read_at, delivered_at: m.delivered_at || null, reply_to: m.deleted ? null : (m.reply_to || null), mentions: parseMentions(m.mentions), evt: m.msg_type || null, fwd_from: m.deleted ? null : (m.fwd_from || null), deleted: !!m.deleted, system: m.sender_id === SYSTEM_SENDER || !!m.msg_type });
async function namesFor(teamId){ const o = {}; for (const x of await R.users.listByTenant(teamId)) o[x.id] = x.name || x.email; return o; }
// Await-aware filter: keep items whose async predicate resolves truthy (these predicates hit the DB, and a
// plain .filter() can't await). Sequential so per-item DB order is deterministic.
async function asyncFilter(arr, pred){ const out = []; for (const x of arr) { if (await pred(x)) out.push(x); } return out; }
// id -> profile photo, with a fallback across DUPLICATE rows for the same person.
//
// A person can end up with more than one row (signed in by email once and by mobile another time, before
// the bizgaze_user_id merge landed). Only one of those rows carries the DP. Groups happened to reference
// the row WITH the photo while a DM referenced the one without — so the same contact showed their picture
// in a group but fell back to initials in the 1:1. Key each row by its stable person identity (BizGaze
// person id, else email, else name) and let a photo-less row borrow its twin's photo.
async function avatarsFor(teamId) {
  const users = await R.users.listByTenant(teamId);
  const em = (x) => (x.email ? String(x.email).toLowerCase() : '');
  const nm = (x) => String(x.name || '').trim().toLowerCase();
  // Index every KNOWN photo under all three identities, then let a photo-less row match on ANY of them —
  // a single composite key missed the common case where the twin rows have different emails.
  const byBz = {}, byEmail = {}, byName = {};
  for (const x of users) {
    if (!x.avatar_url) continue;
    if (x.bizgaze_user_id && !byBz[x.bizgaze_user_id]) byBz[x.bizgaze_user_id] = x.avatar_url;
    if (em(x) && !byEmail[em(x)]) byEmail[em(x)] = x.avatar_url;
    if (nm(x) && !byName[nm(x)]) byName[nm(x)] = x.avatar_url;
  }
  const out = {};
  for (const x of users) {
    out[x.id] = x.avatar_url
      || (x.bizgaze_user_id && byBz[x.bizgaze_user_id])
      || (em(x) && byEmail[em(x)])
      || (nm(x) && byName[nm(x)])
      || null;
  }
  return out;
}
// Next future occurrence (same time-of-day) of a weekly-recurring meeting; searches 14 days ahead.
function nextOccurrence(baseTs, days, nowTs){ const b = new Date(baseTs); const hh = b.getHours(), mm = b.getMinutes(); const s = new Date(nowTs); for (let i = 0; i <= 14; i++){ const d = new Date(s.getFullYear(), s.getMonth(), s.getDate() + i, hh, mm, 0, 0); if (days.indexOf(d.getDay()) >= 0 && d.getTime() > nowTs) return d.getTime(); } return baseTs; }
const RDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function recurrenceLabel(days){ if (!days || !days.length) return ''; if (days.length === 7) return 'Every day'; return 'Every ' + days.slice().sort().map((d) => RDAY[d]).join(', '); }
// Post a centered "activity" line into a group (member added/removed/renamed/left) and push it.
async function postSystemMessage(conversationId, teamId, text){
  const id = A.id();
  await R.messages.send({ id, teamId, senderId: SYSTEM_SENDER, recipientId: '', body: text, conversationId });
  const dto = await buildMsgDTO(await R.messages.byId(id), {}, '');
  for (const mid of await R.conversations.members(conversationId)) { try { CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {} }
  return dto;
}
// Tell clients a group's membership changed so they refresh the member count / sidebar immediately.
async function pushGroupUpdate(group, alsoUsers){
  const seen = new Set();
  for (const mid of await R.conversations.members(group)) { seen.add(mid); try { CHAT.pushToUser(mid, { type: 'group-update', group }); } catch (_) {} }
  for (const mid of (alsoUsers || [])) { if (!seen.has(mid)) { try { CHAT.pushToUser(mid, { type: 'group-update', group, removed: true }); } catch (_) {} } }
}
// Group a flat reaction list into { messageId: [{emoji,count,mine,who}] } for the current user.
function groupReactions(list, userId, names){
  const rxBy = {};
  for (const r of list) {
    const byEmoji = (rxBy[r.message_id] || (rxBy[r.message_id] = {}));
    const e = (byEmoji[r.emoji] || (byEmoji[r.emoji] = { count: 0, mine: false, who: [] }));
    e.count++; if (r.user_id === userId) e.mine = true;
    e.who.push((names && names[r.user_id]) || 'Someone');
  }
  return rxBy;
}
const dtoReactions = (rxBy, id) => (rxBy[id] ? Object.entries(rxBy[id]).map(([emoji, v]) => ({ emoji, count: v.count, mine: v.mine, who: v.who })) : []);
// Full reaction DTO for ONE message, from `userId`'s perspective (mine/who).
async function reactionsForMessage(messageId, userId, names){
  const rows = (await R.reactions.forMessage(messageId)).map((r) => ({ message_id: messageId, user_id: r.user_id, emoji: r.emoji }));
  return dtoReactions(groupReactions(rows, userId, names), messageId);
}
// Poll tally for a given viewer ("mine" = this user voted that option).
async function buildPollDTO(poll, userId){
  let opts = []; try { opts = JSON.parse(poll.options); } catch { opts = []; }
  const counts = opts.map(() => 0); const mine = opts.map(() => false); const voters = new Set();
  for (const v of await R.pollVotes.forPoll(poll.id)) {
    if (v.option_idx >= 0 && v.option_idx < counts.length) { counts[v.option_idx]++; if (v.user_id === userId) mine[v.option_idx] = true; }
    voters.add(v.user_id);
  }
  return {
    id: poll.id, question: poll.question, multi: !!poll.multi, closed: !!poll.closed,
    options: opts.map((t, i) => ({ text: t, votes: counts[i], mine: mine[i] })),
    totalVotes: counts.reduce((a, b) => a + b, 0), voters: voters.size, isOwner: poll.created_by === userId,
  };
}
// DTO enriched with a small preview of the quoted message (if this is a reply).
async function buildMsgDTO(m, names, userId){
  const d = msgDTO(m);
  if (m.reply_to) {
    const r = await R.messages.byId(m.reply_to);
    if (r) d.reply = { id: r.id, at: r.created_at, from: r.sender_id, fromName: (names && names[r.sender_id]) || '', body: r.body.length > 140 ? r.body.slice(0, 140) + '…' : r.body };
  }
  if (m.attachment_id) {
    const a = await R.attachments.byId(m.attachment_id);
    if (a) d.attachment = { id: a.id, name: a.name, mime: a.mime, size: a.size, isImage: /^image\//.test(a.mime || ''), isVideo: /^video\//.test(a.mime || ''), isAudio: /^audio\//.test(a.mime || '') };
  }
  if (m.poll_id) { const p = await R.polls.byId(m.poll_id); if (p) d.poll = await buildPollDTO(p, userId); }
  if (m.msg_type) d.byName = (names && names[m.sender_id]) || '';
  return d;
}
const { now, json, readBody, parseCookies } = require('./lib');
const { audit, currentUser, tokenFromReq, apiKeyFromReq, keyHasScope } = require('./session');
const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED } = require('./config');
const https = require('https');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON(url) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, (res) => {
      if (res.statusCode !== 200) { res.resume(); return reject(new Error('upstream ' + res.statusCode)); }
      let buf = ''; res.on('data', (c) => { buf += c; if (buf.length > 4 * 1024 * 1024) { req.destroy(); reject(new Error('too large')); } });
      res.on('end', () => { try { resolve(JSON.parse(buf)); } catch (e) { reject(e); } });
    });
    req.on('error', reject);
    req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
  });
}
const mailer = require('./mailer');
// Basic email validation for external meeting invitees (#4).
const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
const crypto = require('crypto');
const MAX_UPLOAD_MB = parseInt(process.env.MAX_UPLOAD_MB, 10) || 1024; // default 1 GB per chat attachment
const MAX_FILE_BYTES = MAX_UPLOAD_MB * 1024 * 1024; // NOTE: also raise Nginx Proxy Manager's client_max_body_size to match (default is 1 MB) or large uploads are rejected at the proxy before reaching here.

// LiveKit access-token minting lives in ./livekit (shared with calls.js, which mints a token for the native
// VoIP call payload). Same hand-rolled HS256 JWT — grants join+publish+subscribe on one room, one identity.
const { livekitToken } = require('./livekit');

// Issue a refresh token (native clients), store only its hash, return the plaintext once.
async function issueRefreshToken(userId) {
  const rtok = A.token(32);
  await R.refreshTokens.create({ userId, tokenHash: A.hashToken(rtok), ttl: REFRESH_TTL });
  return rtok;
}

const routes = {};
const route = (method, p, fn) => (routes[`${method} ${p}`] = fn);

// Register: creates a team + admin user. MFA must be set up before full access.
route('POST', '/api/register', async (req, res) => {
  const anyUser = await R.users.anyExists();
  if (anyUser && process.env.ALLOW_REGISTRATION !== '1')
    return json(res, 403, { error: 'Registration is closed. Contact your administrator.' });
  const { email, password, teamName } = await readBody(req);
  if (!email || !password) return json(res, 400, { error: 'email and password required' });
  if (await R.users.emailExists(email))
    return json(res, 409, { error: 'email already registered' });
  const { hash, salt } = A.hashPassword(password);
  const team = await R.teams.create(teamName || `${email}'s team`);
  const userId = await R.users.create({ tenantId: team.id, email, hash, salt, role: 'admin', name: null, mfaSecret: A.newMfaSecret() });
  audit({ team_id: team.id, user_id: userId, user_email: email, action: 'user_registered' });
  json(res, 200, { ok: true });
});

// Verify MFA enrollment (confirm the user scanned the QR / entered code)
route('POST', '/api/mfa/enable', async (req, res) => {
  const { email, code } = await readBody(req);
  const u = await R.users.byEmail(email);
  if (!u) return json(res, 404, { error: 'no such user' });
  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
  await R.users.enableMfa(u.id);
  json(res, 200, { ok: true });
});

// Provision (or refresh) a local user from a successful BizGaze identity check.
// The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
// the source of truth for credentials (the local password is random + unused).
// Emails that must always be admins regardless of what BizGaze returns (safety net so an
// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
async function provisionFromBizgaze(email, bz) {
  const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
  const bizId = bz.bizgazeUserId || null;

  // Identity is keyed on the BizGaze person-id, NOT the typed identifier: signing in with a
  // mobile number and with an email both return the same person-id, so both resolve to one
  // Biz Connect account (#2 — no more duplicate contacts for the same person).
  let existing = await R.users.byBizgazeId(bizId);
  // Legacy account created before the person-id was stored: fall back to the typed identifier,
  // but only if it isn't already claimed by a different person, then stamp the id on below.
  if (!existing) {
    const byMail = await R.users.byEmail(email);
    if (byMail && (!byMail.bizgaze_user_id || byMail.bizgaze_user_id === bizId)) existing = byMail;
  }

  if (!existing) {
    const team = await R.teams.first() || await R.teams.create('BizGaze');
    const { hash, salt } = A.hashPassword(A.token());
    const id = await R.users.create({ tenantId: team.id, email, hash, salt, role, name: bz.name || null, mfaSecret: A.newMfaSecret() });
    if (bizId) await R.users.setBizgazeId(id, bizId);
    if (bz.avatarUrl) await R.users.setAvatar(id, bz.avatarUrl);
    audit({ team_id: team.id, user_id: id, user_email: email, action: 'sso_user_created', detail: 'via BizGaze' });
    return await R.users.byId(id);
  }
  // Retroactive merge: if this same identifier already has its OWN legacy account (a separate
  // row created before person-id keying — e.g. the person used email before and is now signing
  // in with their mobile), fold that duplicate's history into the canonical account. BizGaze just
  // proved this identifier belongs to this person, so the merge is safe.
  if (bizId) {
    const dup = await R.users.byEmail(email);
    if (dup && dup.id !== existing.id && (!dup.bizgaze_user_id || dup.bizgaze_user_id === bizId)) {
      await R.users.mergeInto(dup.id, existing.id);
      audit({ team_id: existing.team_id, user_id: existing.id, user_email: email, action: 'account_merged', detail: 'folded duplicate ' + dup.id });
    }
  }
  // BizGaze is the source of truth: keep the person-id + name + avatar + role in sync each login.
  // Stamping the id links legacy rows so the person's other identifier converges here next time.
  if (bizId && existing.bizgaze_user_id !== bizId) await R.users.setBizgazeId(existing.id, bizId);
  if (bz.name && bz.name !== existing.name) await R.users.setName(existing.id, bz.name);
  if (bz.avatarUrl && bz.avatarUrl !== existing.avatar_url) await R.users.setAvatar(existing.id, bz.avatarUrl);
  if (existing.role !== role) await R.users.setRole(existing.id, role);
  return await R.users.byId(existing.id);
}

// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the
// credentials are verified against BizGaze and the user is provisioned/synced locally
// (local passwords are not accepted). Without it (dev/tests) the local password is
// checked. Sets a session cookie.
route('POST', '/api/login', async (req, res) => {
  const { email, password, remember } = await readBody(req);
  if (!email || !password) return json(res, 400, { error: 'email and password required' });
  const existing = await R.users.byEmail(email);
  if (existing && existing.active === 0) return json(res, 403, { error: 'This account has been deactivated' });

  // Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password
  // fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in
  // the same tenant (admins then see all sessions). Local accounts stay usable for
  // dev/testing via ALLOW_LOCAL_LOGIN=1.
  const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1';
  let u = null, bzMsg = null;
  if (bizgazeOnly) {
    const bz = await BZ.validateLogin(email, password);
    if (bz.error) return json(res, 503, { error: bz.error });
    if (!bz.ok) return json(res, 401, { error: bz.message || 'Username or password do not match.' });
    u = await provisionFromBizgaze(email, bz);
    if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
  } else {
    // Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back
    // to BizGaze if a local password isn't set/correct (so SSO users can still sign in).
    u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
    if (!u) {
      const bz = await BZ.validateLogin(email, password);
      if (bz.ok) u = await provisionFromBizgaze(email, bz);
      else if (bz.error) return json(res, 503, { error: bz.error });
      else bzMsg = bz.message || null; // BizGaze configured and rejected the credentials
    }
    if (!u) {
      if (existing) return json(res, 401, { error: 'Incorrect password. Please try again.' });
      if (bzMsg) return json(res, 401, { error: bzMsg });
      return json(res, 404, { error: 'This email is not registered.' });
    }
  }

  const tok = A.token();
  const ttl = remember ? 1000 * 60 * 60 * 24 * 30 : SESSION_TTL; // 30 days if remembered, else 24h
  await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
  res.setHeader('Set-Cookie', `sid=${tok}; HttpOnly; Path=/; Max-Age=${ttl / 1000}`);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
  // Cookie for the web app; access token + refresh token in the body for native
  // desktop/mobile clients (access via `Authorization: Bearer`, refresh via /api/v1/auth/refresh).
  const refreshToken = await issueRefreshToken(u.id);
  json(res, 200, { ok: true, mfaRequired: false, token: tok, expiresAt: now() + ttl, refreshToken, refreshExpiresAt: now() + REFRESH_TTL });
});

// Exchange a refresh token for a fresh access token (with rotation). Native clients call
// this when their access token expires, so the user stays signed in without re-entering a password.
route('POST', '/api/auth/refresh', async (req, res) => {
  const { refreshToken } = await readBody(req);
  if (!refreshToken) return json(res, 400, { error: 'refreshToken required' });
  const h = A.hashToken(refreshToken);
  const row = await R.refreshTokens.byHash(h);
  if (!row || row.revoked || row.expires_at < now()) return json(res, 401, { error: 'invalid or expired refresh token' });
  const u = await R.users.byId(row.user_id);
  if (!u || u.active === 0) return json(res, 401, { error: 'account unavailable' });
  await R.refreshTokens.revoke(h); // rotate: one-time use
  const tok = A.token();
  await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
  const newRefresh = await issueRefreshToken(u.id);
  json(res, 200, { ok: true, token: tok, expiresAt: now() + SESSION_TTL, refreshToken: newRefresh, refreshExpiresAt: now() + REFRESH_TTL });
});

// Mint a bearer token for the iOS Share Extension. The extension is a separate process that can't see the
// web app's HttpOnly `sid` cookie, so the logged-in web app calls this on boot and hands the token to the
// extension via the App Group. The extension then talks to the API directly (list chats, upload, send) —
// exactly like the native client, so no app-open is needed to share. Short-ish TTL, refreshed each boot.
route('GET', '/api/share/token', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const tok = A.token();
  const ttl = 1000 * 60 * 60 * 24 * 30; // 30 days; the app re-mints on every launch anyway
  await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl });
  json(res, 200, { token: tok, expiresAt: now() + ttl });
});

// Login step 2: TOTP code -> marks session mfa_passed
route('POST', '/api/login/mfa', async (req, res) => {
  const { code } = await readBody(req);
  const tok = parseCookies(req).sid;
  const s = tok && await R.authSessions.byToken(tok);
  if (!s) return json(res, 401, { error: 'no session' });
  const u = await R.users.byId(s.user_id);
  if (!A.verifyTotp(u.mfa_secret, code)) return json(res, 401, { error: 'invalid code' });
  await R.authSessions.markMfaPassed(tok);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login' });
  json(res, 200, { ok: true });
});

route('POST', '/api/logout', async (req, res) => {
  const tok = tokenFromReq(req); // cookie (web) or Bearer (native)
  if (tok) await R.authSessions.deleteByToken(tok);
  const { refreshToken } = await readBody(req);
  if (refreshToken) await R.refreshTokens.revoke(A.hashToken(refreshToken));
  res.setHeader('Set-Cookie', 'sid=; HttpOnly; Path=/; Max-Age=0');
  json(res, 200, { ok: true });
});

route('GET', '/api/setup-state', async (req, res) => {
  const anyUser = await R.users.anyExists();
  json(res, 200, { registrationOpen: !anyUser || process.env.ALLOW_REGISTRATION === '1' });
});

// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
// configured. Two credential modes:
//   - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint
//     time-limited credentials per request (no permanent password is ever handed out, so
//     outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h).
//   - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential.
route('GET', '/api/ice', async (req, res) => {
  const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
  if (process.env.TURN_URLS) {
    const urls = process.env.TURN_URLS.split(',').map((u) => u.trim()).filter(Boolean);
    let username = process.env.TURN_USERNAME || '';
    let credential = process.env.TURN_CREDENTIAL || '';
    if (process.env.TURN_SECRET) {
      const ttl = parseInt(process.env.TURN_TTL || '86400', 10);
      username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "<expiry>"
      credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64');
    }
    iceServers.push({ urls, username, credential });
  }
  json(res, 200, { iceServers });
});

route('GET', '/api/me', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  json(res, 200, { id: u.id, email: u.email, role: u.role, teamId: u.team_id, name: u.name || null, avatarUrl: u.avatar_url || null, status: u.status || 'active' });
});
// Set my presence status: 'active' | 'away' | 'onleave' ('incall' is derived, not settable).
route('POST', '/api/me/status', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { status } = await readBody(req);
  if (!['active', 'away', 'onleave'].includes(status)) return json(res, 400, { error: 'invalid status' });
  try { await R.users.setStatus(u.id, status); } catch (_) {}
  try { CHAT.broadcastPresence(u.id); } catch (_) {} // push the new status to contacts live (no refresh)
  json(res, 200, { ok: true, status });
});

// --- Web Push: background/closed-tab notifications (no-op unless VAPID is configured) ---
route('GET', '/api/push/vapid', async (req, res) => {
  json(res, 200, { enabled: PUSH.isEnabled(), key: PUSH.publicKey() });
});
route('POST', '/api/push/subscribe', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const sub = await readBody(req);
  if (!sub || !sub.endpoint || !sub.keys || !sub.keys.p256dh || !sub.keys.auth) return json(res, 400, { error: 'invalid subscription' });
  try { await R.pushSubs.add({ id: A.id(), userId: u.id, endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth }); } catch (_) {}
  json(res, 200, { ok: true });
});
route('POST', '/api/push/unsubscribe', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { endpoint } = await readBody(req);
  if (endpoint) { try { await R.pushSubs.removeByEndpoint(endpoint); } catch (_) {} }
  json(res, 200, { ok: true });
});

// --- Native device tokens (mobile app): FCM (Android) / APNs (iOS). Registration is always
//     accepted and stored; delivery is a no-op until FCM/APNs creds are configured. ---
route('POST', '/api/devices', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { platform, token } = await readBody(req);
  if (!token || typeof token !== 'string') return json(res, 400, { error: 'token required' });
  // 'ios-voip' = a PushKit VoIP token (CallKit wake), stored alongside the normal alert token.
  if (platform !== 'ios' && platform !== 'android' && platform !== 'ios-voip') return json(res, 400, { error: 'platform must be ios, android or ios-voip' });
  try { await R.deviceTokens.register({ id: A.id(), userId: u.id, tenantId: u.team_id, platform, token }); } catch (_) {}
  json(res, 200, { ok: true });
});
route('POST', '/api/devices/remove', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { token } = await readBody(req);
  if (token) { try { await R.deviceTokens.removeByToken(token); } catch (_) {} }
  json(res, 200, { ok: true });
});

// --- App install telemetry: records each install and, once the user signs in, who's using it. ---
route('POST', '/api/telemetry/install', async (req, res) => {
  const { installId, platform, appVersion, os } = await readBody(req);
  if (!installId || typeof installId !== 'string') return json(res, 400, { error: 'installId required' });
  const u = await currentUser(req); // may be null on a pre-login launch — still counted
  try {
    await R.appInstalls.record({ id: A.id(), installId: installId.slice(0, 64), userId: u && u.id, userEmail: u && u.email, tenantId: u && u.team_id, platform: (platform || '').slice(0, 20), appVersion: (appVersion || '').slice(0, 20), os: (os || '').slice(0, 60) });
  } catch (_) {}
  json(res, 200, { ok: true });
});
// Admin: who installed the app (this tenant).
route('GET', '/api/admin/installs', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'admin only' });
  json(res, 200, await R.appInstalls.listForTenant(u.team_id));
});

// ---------- BizGaze SSO: agent arrives already logged in ----------
route('GET', '/sso', async (req, res) => {
  if (!process.env.SSO_SECRET) { res.writeHead(503); return res.end('SSO not configured'); }
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  const token = q.get('token') || '';
  const [payloadB64, sig] = token.split('.');
  const fail = (msg) => { res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end(msg); };
  if (!payloadB64 || !sig) return fail('Invalid SSO token');
  const crypto = require('crypto');
  const expect = crypto.createHmac('sha256', process.env.SSO_SECRET).update(payloadB64).digest('base64url');
  const sigBuf = Buffer.from(sig), expBuf = Buffer.from(expect);
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return fail('Invalid SSO signature');
  let p; try { p = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); } catch { return fail('Invalid SSO payload'); }
  if (!p.email || !p.exp || p.exp < Math.floor(now() / 1000)) return fail('SSO token expired');
  let u = await R.users.byEmail(p.email);
  if (!u) {
    const team = await R.teams.first();
    if (!team) return fail('No team configured');
    const { hash, salt } = A.hashPassword(A.token());
    const role = (p.role === 'admin' || p.role === 'viewer') ? p.role : 'technician';
    const userId = await R.users.create({ tenantId: team.id, email: p.email, hash, salt, role, name: p.name || null, mfaSecret: A.newMfaSecret() });
    u = await R.users.byId(userId);
    audit({ team_id: team.id, user_id: userId, user_email: p.email, action: 'sso_user_created', detail: p.name || '' });
  } else if (p.name && p.name !== u.name) {
    await R.users.setName(u.id, p.name);
  }
  if (u.active === 0) return fail('Account deactivated');
  const tok = A.token();
  await R.authSessions.create({ token: tok, userId: u.id, mfaPassed: true, ttl: SESSION_TTL });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'login', detail: 'via BizGaze SSO' });
  const dest = '/connect' + (p.ticket ? ('?ticket=' + encodeURIComponent(p.ticket)) : '');
  res.writeHead(302, { 'Set-Cookie': `sid=${tok}; HttpOnly; Path=/; Max-Age=${SESSION_TTL / 1000}`, Location: dest });
  res.end();
});

// Admin adds an agent login to their team
route('POST', '/api/users', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
  // With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local
  // accounts is what previously shadowed BizGaze and split tenants). Allowed in dev via
  // ALLOW_LOCAL_LOGIN=1.
  if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' });
  const { email, password, name, role } = await readBody(req);
  if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
  if (await R.users.emailExists(email))
    return json(res, 409, { error: 'email already registered' });
  const { hash, salt } = A.hashPassword(password);
  const r = (role === 'admin' || role === 'viewer') ? role : 'technician';
  const userId = await R.users.create({ tenantId: u.team_id, email, hash, salt, role: r, name: name || null, mfaSecret: A.newMfaSecret() });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_added', detail: email + ' (' + r + ')' });
  json(res, 200, { ok: true, id: userId, email, role: r });
});

// List the team's agents
route('GET', '/api/users', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const rows = await R.users.listByTenant(u.team_id);
  json(res, 200, rows);
});

// First-login MFA self-setup: a logged-in (password ok) user who hasn't enabled MFA yet
route('GET', '/api/mfa/setup', async (req, res) => {
  const u = await currentUser(req, { requireMfa: false });
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.mfa_enabled) return json(res, 400, { error: 'MFA already enabled' });
  json(res, 200, { secret: u.mfa_secret, otpauthUrl: A.otpauthUrl(u.mfa_secret, u.email) });
});

// Admin manages an agent: reset password, rename, deactivate/activate, delete.
route('POST', '/api/users/manage', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage agents' });
  const { id, action, password, name } = await readBody(req);
  const target = await R.users.inTenant(id, u.team_id);
  if (!target) return json(res, 404, { error: 'no such agent' });
  switch (action) {
    case 'reset-password': {
      if (!password || String(password).length < 8) return json(res, 400, { error: 'new password must be at least 8 characters' });
      const { hash, salt } = A.hashPassword(password);
      await R.users.setPassword(target.id, hash, salt);
      await R.authSessions.deleteByUser(target.id); // force re-login
      await R.refreshTokens.revokeByUser(target.id);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_password_reset', detail: target.email });
      return json(res, 200, { ok: true });
    }
    case 'rename': {
      const clean = String(name || '').trim().slice(0, 60);
      if (!clean) return json(res, 400, { error: 'name required' });
      await R.users.setName(target.id, clean);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_renamed', detail: target.email + ' -> ' + clean });
      return json(res, 200, { ok: true, name: clean });
    }
    case 'deactivate': {
      if (target.id === u.id) return json(res, 400, { error: 'you cannot deactivate your own account' });
      await R.users.setActive(target.id, false);
      await R.authSessions.deleteByUser(target.id);
      await R.refreshTokens.revokeByUser(target.id);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deactivated', detail: target.email });
      return json(res, 200, { ok: true });
    }
    case 'activate': {
      await R.users.setActive(target.id, true);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_activated', detail: target.email });
      return json(res, 200, { ok: true });
    }
    case 'delete': {
      if (target.id === u.id) return json(res, 400, { error: 'you cannot delete your own account' });
      await R.authSessions.deleteByUser(target.id);
      await R.refreshTokens.revokeByUser(target.id);
      await R.users.remove(target.id);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'agent_deleted', detail: target.email });
      return json(res, 200, { ok: true });
    }
    default: return json(res, 400, { error: 'unknown action' });
  }
});

// ---------- API keys (admin-managed, for third-party / system integrations) ----------
route('POST', '/api/keys', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
  const { name, scopes } = await readBody(req);
  const sc = (Array.isArray(scopes) ? scopes : ['report:read']).filter((s) => API_KEY_SCOPES.includes(s));
  if (!sc.length) return json(res, 400, { error: 'at least one valid scope required (' + API_KEY_SCOPES.join(', ') + ')' });
  const key = 'bzc_' + A.token(24);   // shown once, never stored in plaintext
  const id = A.id();
  await R.apiKeys.create({ id, tenantId: u.team_id, name: name || null, keyHash: A.hashToken(key), scopes: sc.join(','), createdBy: u.id });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_created', detail: (name || id) + ' [' + sc.join(',') + ']' });
  json(res, 200, { id, name: name || null, scopes: sc, key });
});

route('GET', '/api/keys', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
  json(res, 200, await R.apiKeys.listByTenant(u.team_id));
});

route('POST', '/api/keys/revoke', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage API keys' });
  const { id } = await readBody(req);
  if (!id) return json(res, 400, { error: 'id required' });
  await R.apiKeys.revoke(id, u.team_id);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'api_key_revoked', detail: id });
  json(res, 200, { ok: true });
});

// ---------- Webhook subscriptions (admin-managed, outbound event delivery) ----------
route('POST', '/api/webhooks', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
  const { url, events, secret } = await readBody(req);
  if (!url || !/^https?:\/\//i.test(url)) return json(res, 400, { error: 'a valid http(s) url is required' });
  let ev = Array.isArray(events) ? events.filter((e) => e === '*' || W.EVENTS.includes(e)) : W.EVENTS.slice();
  if (!ev.length) ev = W.EVENTS.slice();
  const sec = (secret && String(secret).length >= 8) ? String(secret) : A.token(24);
  const id = A.id();
  await R.webhooks.create({ id, tenantId: u.team_id, url, secret: sec, events: ev.join(','), createdBy: u.id });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_created', detail: url + ' [' + ev.join(',') + ']' });
  // Secret returned so the receiver can verify the X-BizGaze-Signature header.
  json(res, 200, { id, url, events: ev, secret: sec });
});

route('GET', '/api/webhooks', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
  json(res, 200, await R.webhooks.listByTenant(u.team_id));
});

route('POST', '/api/webhooks/delete', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role !== 'admin') return json(res, 403, { error: 'only admins can manage webhooks' });
  const { id } = await readBody(req);
  if (!id) return json(res, 400, { error: 'id required' });
  await R.webhooks.remove(id, u.team_id);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'webhook_deleted', detail: id });
  json(res, 200, { ok: true });
});

// Available webhook event types (for integrators / an admin UI).
route('GET', '/api/webhooks/events', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  json(res, 200, { events: W.EVENTS });
});

// Session report — readable by a logged-in user OR an API key with `report:read`.
route('GET', '/api/report', async (req, res) => {
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  let tenantId, agentEmail;
  const u = await currentUser(req);
  if (u) {
    // Admins see the whole team (and may filter by agent); everyone else only their own.
    tenantId = u.team_id;
    agentEmail = u.role !== 'admin' ? u.email : (q.get('agent') || null);
  } else {
    const key = await apiKeyFromReq(req);
    if (!keyHasScope(key, 'report:read')) return json(res, 401, { error: 'unauthorized' });
    await R.apiKeys.touch(key.id);
    tenantId = key.teamId;           // a key sees its whole tenant
    agentEmail = q.get('agent') || null;
  }
  const from = q.get('from') ? new Date(q.get('from') + 'T00:00:00').getTime() : null;
  const to = q.get('to') ? new Date(q.get('to') + 'T23:59:59').getTime() : null;
  json(res, 200, await R.sessionsLog.report({ tenantId, agentEmail, from, to }));
});

// List machines for the team (with live online status from signaling layer)
route('GET', '/api/machines', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const rows = await R.machines.listByTenant(u.team_id);
  json(res, 200, rows.map((m) => ({ ...m, online: onlineAgents.has(m.id) })));
});

// Create a machine enrollment token (admin/technician). Agent uses it to come online.
route('POST', '/api/machines', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (u.role === 'viewer') return json(res, 403, { error: 'forbidden' });
  const { name, unattended } = await readBody(req);
  const enroll = A.token();
  const mId = await R.machines.create({ tenantId: u.team_id, name: name || 'Unnamed PC', enrollToken: enroll, unattended: !!unattended });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, machine_id: mId, machine_name: name, action: 'machine_enrolled' });
  json(res, 200, { id: mId, enrollToken: enroll });
});

route('GET', '/api/audit', async (req, res) => {
  const u = await currentUser(req);
  let tenantId;
  if (u) tenantId = u.team_id;
  else {
    const key = await apiKeyFromReq(req);
    if (!keyHasScope(key, 'audit:read')) return json(res, 401, { error: 'unauthorized' });
    await R.apiKeys.touch(key.id);
    tenantId = key.teamId;
  }
  json(res, 200, await R.audit.listByTenant(tenantId));
});

// ---------- session recording: upload (agent) ----------
const MAX_REC_BYTES = 500 * 1024 * 1024; // 500 MB safety cap
route('POST', '/api/recording', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const params = new URLSearchParams(req.url.split('?')[1] || '');
  const sid = params.get('sessionId');
  const ext = params.get('ext') === 'mp4' ? 'mp4' : 'webm'; // container chosen by the recorder
  if (!sid) return json(res, 400, { error: 'sessionId required' });
  const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
  if (!row) return json(res, 404, { error: 'no such session' });
  const chunks = []; let total = 0, aborted = false;
  req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
  req.on('end', async () => {
    if (aborted) return json(res, 413, { error: 'recording too large' });
    const fname = sid + '.' + ext;
    try {
      fs.writeFileSync(path.join(REC_DIR, fname), Buffer.concat(chunks));
      await R.sessionsLog.setRecording(sid, fname);
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'recording_saved', detail: 'session ' + sid });
      json(res, 200, { ok: true });
    } catch (e) { json(res, 500, { error: 'could not save recording' }); }
  });
  req.on('error', () => { try { res.end(); } catch (e) {} });
});

route('POST', '/api/transcript', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const sid = new URLSearchParams(req.url.split('?')[1] || '').get('sessionId');
  if (!sid) return json(res, 400, { error: 'sessionId required' });
  const row = await R.sessionsLog.byIdInTenant(sid, u.team_id);
  if (!row) return json(res, 404, { error: 'no such session' });
  const chunks = []; let total = 0, aborted = false;
  req.on('data', (c) => { total += c.length; if (total > 5 * 1024 * 1024) { aborted = true; req.destroy(); return; } chunks.push(c); });
  req.on('end', async () => {
    if (aborted) return json(res, 413, { error: 'transcript too large' });
    const fname = sid + '.txt';
    try {
      fs.writeFileSync(path.join(TRANS_DIR, fname), Buffer.concat(chunks));
      await R.sessionsLog.setTranscript(sid, fname);
      json(res, 200, { ok: true });
    } catch (e) { json(res, 500, { error: 'could not save transcript' }); }
  });
  req.on('error', () => { try { res.end(); } catch (e) {} });
});

// ---------- Chat (persistent 1:1 messaging between team members) ----------
// Contacts = other active users in the tenant (the people you can message).
route('GET', '/api/messages/contacts', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const rows = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
  const cAv = await avatarsFor(u.team_id); // duplicate-row DP fallback
  json(res, 200, rows.map((x) => ({ id: x.id, name: x.name || x.email, email: x.email, online: CHAT.isOnline(x.id), avatar: cAv[x.id] || null, lastSeen: x.last_seen || null, status: x.status || 'active' })));
});

// Cross-tenant people search via the BizGaze directory (token stays server-side). Results are
// tagged onConnect=true when the person already has a Connect account in this tenant (chat-ready).
route('GET', '/api/directory/search', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const q = (new URLSearchParams(req.url.split('?')[1] || '').get('q') || '').trim();
  if (q.length < 2) return json(res, 200, []);
  const results = await require('./directory').search(q);
  // Map directory people to existing Connect users in this tenant (by email) so they're chat-ready.
  const mine = (await R.users.listByTenant(u.team_id)).filter((x) => x.id !== u.id && x.active !== 0);
  const byEmail = new Map(mine.map((x) => [(x.email || '').toLowerCase(), x]));
  const out = results.map((p) => {
    const local = p.email ? byEmail.get(p.email.toLowerCase()) : null;
    return { name: p.name, email: p.email, phone: p.phone, org: p.org, avatar: p.avatar,
      onConnect: !!local, connectId: local ? local.id : null };
  });
  json(res, 200, out);
});

// Conversation list: DMs (per counterparty) + group conversations, merged + sorted.
route('GET', '/api/messages/conversations', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const names = {};
  const avatars = {};
  const statuses = {};
  const seen = {};
  for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; statuses[x.id] = x.status || 'active'; seen[x.id] = x.last_seen || null; }
  Object.assign(avatars, await avatarsFor(u.team_id)); // same person / two rows → borrow the DP (see avatarsFor)
  const favs = new Set(await R.favorites.forUser(u.id));
  const inCall = new Set();
  for (const [, peers] of meetingRooms) { for (const [, p] of peers) { if (p.ws && p.ws._meetingUserId) inCall.add(p.ws._meetingUserId); } }
  // DMs
  const byOther = new Map();
  for (const m of await R.messages.recentFor(u.team_id, u.id)) {
    const raw = m.sender_id === u.id ? m.recipient_id : m.sender_id;
    if (!raw) continue;
    // If this counterparty was merged away, key the row by the SURVIVING account, so the thread carries
    // that account's name/photo/presence (and two half-threads for one person collapse into one row).
    let other; try { other = await R.users.resolve(raw) || raw; } catch (_) { other = raw; }
    if (!byOther.has(other)) byOther.set(other, { other, last: m, unread: 0 });
    if (m.recipient_id === u.id && (m.sender_id === raw || m.sender_id === other) && !m.read_at) byOther.get(other).unread++;
  }
  const dmItems = [...byOther.values()].map((c) => {
    const dc = dmCalls.get(CALLS.pairKey(u.id, c.other));
    return {
    kind: 'dm', id: c.other, contactId: c.other, name: names[c.other] || 'Unknown', online: CHAT.isOnline(c.other), avatar: avatars[c.other] || null, lastSeen: seen[c.other] || null,
    callActive: !!dc, callRoom: dc ? dc.room : null, favorite: favs.has('dm:' + c.other), status: inCall.has(c.other) ? 'incall' : (statuses[c.other] || 'active'),
    last_body: c.last.body || (c.last.attachment_id ? '📎 Attachment' : ''), last_at: c.last.created_at, last_from_me: c.last.sender_id === u.id, unread: c.unread,
    last_status: c.last.sender_id === u.id ? (c.last.read_at ? 'read' : (c.last.delivered_at ? 'delivered' : 'sent')) : null, // tick for my last message
  }; });
  // Groups
  const groupItems = await Promise.all((await R.conversations.listForUser(u.team_id, u.id)).map(async (g) => {
    const last = await R.messages.lastInConversation(g.id);
    const since = await R.conversations.lastReadAt(g.id, u.id);
    const members = await R.conversations.members(g.id);
    // Group read tick for MY last message: read = every other member has read it, delivered = some
    // have, else sent. Same three states as DMs, so the sidebar renders them identically.
    let gStatus = null;
    if (last && last.sender_id === u.id) {
      const others = members.filter((id) => id !== u.id).length;
      const seenN = (await R.conversations.memberReads(g.id)).filter((r) => r.user_id !== u.id && r.last_read_at >= last.created_at).length;
      gStatus = (others > 0 && seenN >= others) ? 'read' : (seenN > 0 ? 'delivered' : 'sent');
    }
    return {
      kind: 'group', id: g.id, name: g.name || 'Group', members: members.length, avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null, favorite: favs.has('group:' + g.id),
      callActive: groupCalls.has(g.id), callRoom: (groupCalls.get(g.id) || {}).room || null,
      last_body: last ? (last.body || (last.attachment_id ? '📎 Attachment' : '')) : '', last_at: last ? last.created_at : g.created_at,
      last_from_me: last ? last.sender_id === u.id : false, unread: last ? await R.messages.unreadInConversation(g.id, u.id, since) : 0,
      last_status: gStatus,
    };
  }));
  json(res, 200, [...dmItems, ...groupItems].sort((a, b) => b.last_at - a.last_at));
});

// Full thread: a DM (?with=userId) or a group (?group=conversationId). Marks it read.
route('GET', '/api/messages/thread', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  const peek = !!q.get('peek'); // prefetch only — do NOT mark the conversation read
  const before = parseInt(q.get('before') || '', 10) || null; // pagination cursor: fetch messages OLDER than this created_at
  const names = await namesFor(u.team_id);
  const group = q.get('group');
  if (group) {
    if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
    const rows = await R.messages.threadByConversation(group, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
    if (!peek && !before) {
      await R.conversations.markRead(group, u.id);
      const evt = { type: 'group-read', group, by: u.id, byName: names[u.id] || u.email, at: now() };
      for (const mid of await R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
      try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13
    }
    const rxBy = groupReactions(await R.reactions.forConversation(group), u.id, names);
    const reads = await R.conversations.memberReads(group); // ALL members' read times (#2: seen-by visible to everyone)
    return json(res, 200, await Promise.all(rows.map(async (m) => {
      const d = await buildMsgDTO(m, names, u.id); d.fromName = names[m.sender_id] || ''; d.reactions = dtoReactions(rxBy, m.id);
      // Who has read this message (excluding its sender) — shown to every member, not just the sender.
      d.seenBy = reads.filter((r) => r.user_id !== m.sender_id && r.last_read_at >= m.created_at).map((r) => names[r.user_id] || 'Someone');
      return d;
    })));
  }
  const other = await R.users.resolve(q.get('with')); // follow a merge redirect so a stale peer id still loads the thread
  if (!other) return json(res, 400, { error: 'with or group required' });
  if (!await R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
  const rows = await R.messages.thread(u.team_id, u.id, other, 40, before); // page size (latest 40 / older via ?before) — matches client PAGE for smooth open + lazy load
  if (!peek && !before) { await R.messages.markRead(u.team_id, u.id, other); try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {} try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} } // #13
  const rxBy = groupReactions(await R.reactions.forPair(u.team_id, u.id, other), u.id, names);
  return json(res, 200, await Promise.all(rows.map(async (m) => { const d = await buildMsgDTO(m, names, u.id); d.reactions = dtoReactions(rxBy, m.id); return d; })));
});

// Search the ENTIRE thread (not just the loaded window). Returns matching message ids + timestamps,
// oldest-first, so the client can jump to any hit and lazy-load the window around it.
route('GET', '/api/messages/search', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  const term = String(q.get('q') || '').trim();
  if (term.length < 1) return json(res, 200, { hits: [] });
  const like = '%' + term.replace(/[\\%_]/g, '\\$&') + '%'; // escape LIKE wildcards
  const group = q.get('group');
  let rows;
  if (group) {
    if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
    rows = await R.messages.searchConversation(group, like);
  } else {
    const other = await R.users.resolve(q.get('with'));
    if (!other || !await R.users.inTenant(other, u.team_id)) return json(res, 404, { error: 'no such contact' });
    rows = await R.messages.searchThread(u.team_id, u.id, other, like);
  }
  return json(res, 200, { hits: rows.map((m) => ({ id: m.id, at: m.created_at })) });
});

// Create a group conversation with the given members (creator is always added).
route('POST', '/api/groups', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { name, memberIds } = await readBody(req);
  const nm = String(name || '').trim().slice(0, 80);
  if (!nm) return json(res, 400, { error: 'group name required' });
  const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
  const id = A.id();
  await R.conversations.create({ id, teamId: u.team_id, name: nm, createdBy: u.id });
  await R.conversations.addMember(id, u.id, true); // creator is the first admin
  for (const mid of ids) await R.conversations.addMember(id, mid);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_created', detail: nm + ' (' + (ids.length + 1) + ' members)' });
  json(res, 200, { id, name: nm, members: ids.length + 1 });
});

// Members of a group (id + name), for the group header / member list.
route('GET', '/api/groups/members', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
  if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
  const names = {}; const avatars = {};
  for (const x of await R.users.listByTenant(u.team_id)) { names[x.id] = x.name || x.email; }
  Object.assign(avatars, await avatarsFor(u.team_id));
  const adminSet = new Set(await R.conversations.admins(gid));
  json(res, 200, (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, admin: adminSet.has(mid) })));
});

// Full group info: name, creator flag, members (with isMe).
route('GET', '/api/groups/info', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const gid = new URLSearchParams(req.url.split('?')[1] || '').get('group');
  if (!gid || !await R.conversations.isMember(gid, u.id)) return json(res, 403, { error: 'not a member' });
  const g = await R.conversations.byId(gid);
  const tenantUsers = await R.users.listByTenant(u.team_id);
  const names = {}; const avatars = {};
  for (const x of tenantUsers) { names[x.id] = x.name || x.email; }
  Object.assign(avatars, await avatarsFor(u.team_id));
  const adminSet = new Set(await R.conversations.admins(gid));
  json(res, 200, {
    id: gid, name: g.name || 'Group', createdBy: g.created_by, isCreator: g.created_by === u.id,
    isAdmin: adminSet.has(u.id),
    adminOnly: !!g.admin_only, callActive: groupCalls.has(gid), callRoom: (groupCalls.get(gid) || {}).room || null,
    createdByName: names[g.created_by] || 'Someone', createdAt: g.created_at,
    avatar: g.avatar_id ? ('/files/' + g.avatar_id) : null,
    members: (await R.conversations.members(gid)).map((mid) => ({ id: mid, name: names[mid] || 'Unknown', avatar: avatars[mid] || null, isMe: mid === u.id, admin: adminSet.has(mid) })),
  });
});

// Rename a group (any member).
route('POST', '/api/groups/rename', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, name } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  const nm = String(name || '').trim().slice(0, 80);
  if (!nm) return json(res, 400, { error: 'group name required' });
  await R.conversations.rename(group, nm);
  await postSystemMessage(group, u.team_id, (u.name || u.email) + ' renamed the group to “' + nm + '”');
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_renamed', detail: nm });
  json(res, 200, { ok: true, name: nm });
});

// Start (or join) the group's shared call — returns the mesh room to connect to. No code:
// members see a Join button driven by the live call state.
route('POST', '/api/groups/call/start', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
  const r = await CALLS.startGroupCall(group, u.team_id, u);
  json(res, 200, r);
});

// Start (or join) a 1:1 call with another user.
route('POST', '/api/calls/dm/start', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { to } = await readBody(req);
  if (!to || !await R.users.inTenant(to, u.team_id)) return json(res, 404, { error: 'no such contact' });
  json(res, 200, await CALLS.startDmCall(u, to, u.team_id));
});

// Invite more people into the call I'm in (turns a 1:1 into multi-party). Pushes them an
// incoming-call notification carrying the room to join.
route('POST', '/api/calls/invite', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { room, userIds } = await readBody(req);
  if (!room || !meetingRooms.has(String(room))) return json(res, 404, { error: 'call not found' });
  const ids = await asyncFilter((Array.isArray(userIds) ? userIds : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id));
  for (const id of ids) { try { CHAT.pushToUser(id, { type: 'call-invite', room: String(room), byName: (u.name || u.email) }); } catch (_) {} }
  json(res, 200, { ok: true, invited: ids.length });
});

// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => {
  json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED });
});

// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
route('GET', '/api/gifs', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (!GIPHY_API_KEY) return json(res, 200, { enabled: false, gifs: [] });
  const p = new URLSearchParams(req.url.split('?')[1] || '');
  const q = (p.get('q') || '').trim();
  const offset = Math.max(0, Math.min(200, Number(p.get('offset')) || 0));
  const limit = 24;
  const base = 'https://api.giphy.com/v1/gifs/' + (q ? 'search' : 'trending');
  const url = base + '?api_key=' + encodeURIComponent(GIPHY_API_KEY)
    + (q ? ('&q=' + encodeURIComponent(q)) : '')
    + '&limit=' + limit + '&offset=' + offset + '&rating=pg-13&bundle=messaging_non_clips';
  try {
    const data = await fetchJSON(url);
    const gifs = (data && Array.isArray(data.data) ? data.data : []).map((g) => {
      const im = g.images || {};
      const full = (im.downsized_medium || im.fixed_height || im.original || {});
      const prev = (im.fixed_width_small || im.fixed_height_small || im.preview_gif || full || {});
      return { id: g.id, url: full.url || '', preview: prev.url || full.url || '', w: +full.width || 0, h: +full.height || 0, title: g.title || 'GIF' };
    }).filter((g) => g.url);
    json(res, 200, { enabled: true, gifs, offset: offset + limit });
  } catch (e) { json(res, 502, { error: 'gif search failed' }); }
});

// The web build currently on the server (home.html's __BUILD marker). Long-running clients poll this and
// offer a Refresh when it changes. This matters because the desktop app now CLOSES TO TRAY — it can run
// for weeks without ever reloading the page, so it would silently keep serving stale code after a deploy.
let APP_BUILD = '';
try {
  const h = fs.readFileSync(path.join(require('./config').PUBLIC_DIR, 'home.html'), 'utf8');
  const m = /__BUILD='([^']+)'/.exec(h);
  if (m) APP_BUILD = m[1];
} catch (_) {}
route('GET', '/api/build', (req, res) => json(res, 200, { build: APP_BUILD }));

// Mint a LiveKit join token for the signed-in user + a specific room (the 6-digit meeting code).
// The room-membership/host authorization already happens over the meeting WebSocket; this only
// hands the client a media-plane credential scoped to that room and its own identity.
route('POST', '/api/meetings/token', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
  const { room } = await readBody(req);
  const rm = String(room || '').trim();
  if (!/^[A-Za-z0-9._-]{4,64}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
  const metadata = JSON.stringify({ avatarUrl: u.avatar_url || '' });
  const token = livekitToken(u.id, u.name || u.email, rm, metadata);
  json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
});

// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
// Connect account. Only minted for a room that is currently LIVE or a valid (not-ended) scheduled
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
route('POST', '/api/meetings/guest-token', async (req, res) => {
  if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
  const { room, name, identity } = await readBody(req);
  const rm = String(room || '').trim();
  if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
  const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })();
  // #3 Link expiry: a scheduled meeting's guest link is only valid until ~2h after its scheduled end —
  // after that the link is dead (returns 404) even though the DB row lingers. Live rooms are valid while
  // anyone's in them (they vanish from meetingRooms when empty), which is its own natural expiry.
  const sched = await (async () => {
    try {
      const s = await R.scheduledMeetings.byCode(rm);
      if (!s || s.ended_at) return false;
      const endBy = s.scheduled_at + ((s.duration_mins || 60) * 60000) + (2 * 3600000);
      return Date.now() <= endBy;
    } catch (_) { return false; }
  })();
  if (!live && !sched) return json(res, 410, { error: 'This meeting link has expired or the meeting isn’t active.' });
  // Reuse the guest's client id as the LiveKit identity so it matches the id they announced over
  // signaling (meeting-join guestId) — that mapping is how their media attaches to their tile.
  const gid = (typeof identity === 'string' && /^guest-[a-z0-9]+$/i.test(identity)) ? identity.slice(0, 64) : ('guest-' + crypto.randomBytes(8).toString('hex'));
  const gname = String(name || 'Guest').slice(0, 60);
  const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
  json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
});

// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
route('POST', '/api/calls/decline', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { room } = await readBody(req);
  if (!room) return json(res, 400, { error: 'room required' });
  json(res, 200, await CALLS.declineDmCall(String(room), u));
});

// --- Native-call lifecycle. NATIVE (CallKit + LiveKit) calls carry media over LiveKit, NOT our mesh/WS, so
//     the server can't learn from a room emptying that a native call was answered/ended. The app signals it
//     explicitly. (WebView/mesh calls keep using the WS room lifecycle — these are additive no-ops there.) ---
route('POST', '/api/calls/answered', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { room } = await readBody(req);
  if (!room) return json(res, 400, { error: 'room required' });
  try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {}
  json(res, 200, { ok: true });
});
route('POST', '/api/calls/end', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { room } = await readBody(req);
  if (!room) return json(res, 400, { error: 'room required' });
  try { await CALLS.endCallByRoom(String(room)); } catch (_) {}
  json(res, 200, { ok: true });
});

// Toggle "only admins can add/remove members" (any admin).
route('POST', '/api/groups/admin-only', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, value } = await readBody(req);
  const g = group && await R.conversations.byId(group);
  if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change this' });
  await R.conversations.setAdminOnly(group, !!value);
  await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' restricted adding members to admins only' : ' allowed everyone to add members'));
  json(res, 200, { ok: true, adminOnly: !!value });
});

// Promote/demote a member as admin (#9, multiple admins allowed). Only an admin can change roles.
route('POST', '/api/groups/admin', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, userId, value } = await readBody(req);
  const g = group && await R.conversations.byId(group);
  if (!g || g.team_id !== u.team_id || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  if (!await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'only a group admin can change roles' });
  if (!userId || !await R.conversations.isMember(group, userId)) return json(res, 404, { error: 'not a member of this group' });
  if (!value && (await R.conversations.admins(group)).length <= 1 && await R.conversations.isAdmin(group, userId)) return json(res, 400, { error: 'a group must have at least one admin' });
  await R.conversations.setMemberAdmin(group, userId, !!value);
  const names = await namesFor(u.team_id);
  await postSystemMessage(group, u.team_id, (u.name || u.email) + (value ? ' made ' + (names[userId] || 'someone') + ' an admin' : ' removed ' + (names[userId] || 'someone') + ' as admin'));
  await pushGroupUpdate(group);
  try { CHAT.pushToUser(userId, { type: 'group-role', group, admin: !!value, by: u.name || u.email }); } catch (_) {} // notify the affected member
  json(res, 200, { ok: true });
});

// Set a group's image. Pass an attachmentId from /api/messages/upload (must be an image
// the caller uploaded). Pass null/empty to clear it.
route('POST', '/api/groups/avatar', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, attachmentId } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  if (attachmentId) {
    const a = await R.attachments.byId(attachmentId);
    if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' });
    if (!/^image\//.test(a.mime || '')) return json(res, 400, { error: 'group image must be an image file' });
  }
  await R.conversations.setAvatar(group, attachmentId || null);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_avatar_set', detail: group });
  json(res, 200, { ok: true, avatar: attachmentId ? ('/files/' + attachmentId) : null });
});

// Add members to a group (any member).
route('POST', '/api/groups/add', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, memberIds } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  const gA = await R.conversations.byId(group);
  if (gA && gA.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can add members' });
  const ids = await asyncFilter((Array.isArray(memberIds) ? memberIds : []), async (x) => typeof x === 'string' && await R.users.inTenant(x, u.team_id) && !await R.conversations.isMember(group, x));
  for (const mid of ids) await R.conversations.addMember(group, mid);
  if (ids.length) {
    const names = await namesFor(u.team_id);
    await postSystemMessage(group, u.team_id, (u.name || u.email) + ' added ' + ids.map((x) => names[x] || 'someone').join(', '));
  }
  if (ids.length) await pushGroupUpdate(group); // live member-count refresh for everyone (incl. the new members)
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'group_members_added', detail: ids.length + ' to ' + group });
  json(res, 200, { ok: true, added: ids.length });
});

// Remove a member (creator removes others; anyone can remove themselves = leave).
route('POST', '/api/groups/remove', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, userId, newAdmin } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' });
  const target = userId || u.id;
  const isSelf = target === u.id;
  // Leaving (self) is always allowed; removing others requires admin when admin_only is on.
  if (!isSelf) { const gR = await R.conversations.byId(group); if (gR && gR.admin_only && !await R.conversations.isAdmin(group, u.id)) return json(res, 403, { error: 'Only a group admin can remove members' }); }
  const wasAdmin = await R.conversations.isAdmin(group, target);
  // #10: the last admin must hand off to a chosen successor before leaving (no auto-assign).
  const others = (await R.conversations.members(group)).filter((m) => m !== target);
  if (wasAdmin && others.length && (await R.conversations.admins(group)).filter((a) => a !== target).length === 0) {
    if (!newAdmin || !await R.conversations.isMember(group, newAdmin) || newAdmin === target) return json(res, 400, { error: 'NEED_ADMIN', message: 'Choose a member to be the new admin before leaving.' });
    await R.conversations.setMemberAdmin(group, newAdmin, true);
    const names0 = await namesFor(u.team_id);
    await postSystemMessage(group, u.team_id, (names0[newAdmin] || 'A member') + ' is now an admin');
    try { CHAT.pushToUser(newAdmin, { type: 'group-role', group, admin: true }); } catch (_) {}
  }
  // Post the activity BEFORE removing, so the removed person's tab also receives it.
  if (target !== u.id && await R.conversations.isMember(group, target)) {
    const names = await namesFor(u.team_id);
    await postSystemMessage(group, u.team_id, (u.name || u.email) + ' removed ' + (names[target] || 'someone'));
  } else if (isSelf) {
    await postSystemMessage(group, u.team_id, (u.name || u.email) + ' left the group');
  }
  await R.conversations.removeMember(group, target);
  if ((await R.conversations.members(group)).length === 0) { await R.conversations.remove(group); } // drop empty groups
  else await pushGroupUpdate(group, [target]); // live member-count refresh; the removed person drops the group
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: isSelf ? 'group_left' : 'group_member_removed', detail: group });
  json(res, 200, { ok: true, left: isSelf });
});

// ---------- Meetings (scheduled calls) ----------
// Schedule a call (optionally tied to a group). Gets a stable room code so it can be
// joined later; the live mesh room is created on first join. Announces in the group chat.
route('POST', '/api/meetings/schedule', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence, lobby } = await readBody(req);
  const t = String(title || '').trim().slice(0, 120);
  if (!t) return json(res, 400, { error: 'title required' });
  const when = Number(scheduledAt);
  if (!Number.isFinite(when) || when <= 0) return json(res, 400, { error: 'valid scheduledAt (ms) required' });
  if (when < Date.now()) return json(res, 400, { error: 'cannot schedule a meeting in the past' }); // #1
  const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : 30;
  const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
  let groupId = null;
  if (group) {
    if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
    groupId = group;
  }
  const desc = String(description || '').trim().slice(0, 1000);
  // Invited participants: tenant users, excluding the host (creator).
  const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
  // External invitees by email (#4): people not on Connect — they get an emailed guest link.
  const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
  let code; do { code = A.numericCode(6); } while (await R.scheduledMeetings.byCode(code) || meetingRooms.has(code));
  const id = A.id();
  await R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails, lobby: lobby !== false });
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t });
  const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString();
  if (groupId) {
    const mid = A.id();
    await R.messages.send({ id: mid, teamId: u.team_id, senderId: u.id, recipientId: '', body: '📅 Scheduled a call: ' + t + ' — ' + label, conversationId: groupId });
    const dto = await buildMsgDTO(await R.messages.byId(mid), await namesFor(u.team_id), u.id); dto.fromName = u.name || u.email;
    for (const m of await R.conversations.members(groupId)) { try { CHAT.pushToUser(m, { type: 'chat-message', message: dto }); } catch (_) {} }
  }
  // Invitation notification to each invited participant.
  const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } };
  for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} }
  // Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users
  // who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
  try {
    if (mailer.isEnabled() && (guestEmails.length || invited.length)) {
      const link = PUBLIC_BASE_URL + '/home?meet=' + code;
      const nameByEmail = {}; const emails = new Set(guestEmails);
      for (const x of await R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; }
      for (const pid of invited) { const em = nameByEmail[pid]; if (em && isEmail(em)) emails.add(em.toLowerCase()); }
      if (emails.size) {
        const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: desc });
        mailer.send({ to: [...emails], subject: tpl.subject, html: tpl.html, text: tpl.text });
      }
    }
  } catch (e) { console.warn('[meetings] invite email failed:', e && e.message); }
  json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited, guestEmails, link: PUBLIC_BASE_URL + '/home?meet=' + code });
});

// List the meetings this user can see, bucketed into running / upcoming / past.
route('GET', '/api/meetings', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const names = await namesFor(u.team_id);
  const nowTs = Date.now();
  const rows = await Promise.all((await R.scheduledMeetings.listForUser(u.team_id, u.id)).map(async (s) => {
    let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
    let schedAt = s.scheduled_at;
    const live = meetingRooms.get(s.room_code);
    const running = !!(live && live.size > 0);
    // Recurring + its window has passed (and not live/cancelled) → roll forward to the next occurrence.
    if (recur.length && !running && !s.cancelled && !s.ended_at && nowTs > schedAt + ((s.duration_mins || 60) * 60000)) {
      const nxt = nextOccurrence(schedAt, recur, nowTs);
      if (nxt !== schedAt) { try { await R.scheduledMeetings.reschedule(s.id, u.team_id, nxt); } catch (_) {} schedAt = nxt; }
    }
    const endTime = schedAt + ((s.duration_mins || 60) * 60000); // can't be started past this (#3)
    let status = 'upcoming';
    if (s.cancelled) status = 'cancelled';
    else if (running) status = 'running';
    else if (s.ended_at) status = 'past';
    else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed
    let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
    let guestEmails = []; try { guestEmails = JSON.parse(s.guest_emails || '[]'); } catch (_) {}
    return {
      id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '',
      scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code,
      groupName: s.group_id ? ((await R.conversations.byId(s.group_id) || {}).name || 'Group') : null,
      createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id,
      invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails, lobby: s.lobby !== 0,
      durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur),
      status, inCall: running ? live.size : 0, recordings: [],
    };
  }));
  // Attach recordings/transcripts. A recording is visible to its creator, group members, or people
  // who can see the scheduled meeting it belongs to. Recordings not tied to a listed meeting become
  // their own "Past meeting" entry (group calls show the group name).
  const recDTO = (r) => ({ id: r.id, kind: r.kind, url: '/mrec/' + r.id, createdAt: r.created_at, durationMs: r.duration_ms, size: r.size, by: r.created_by_name });
  const canSeeRec = async (r) => {
    if (r.kind === 'transcript') return r.created_by === u.id; // transcripts are private to their owner
    if (r.created_by === u.id) return true;
    if (r.group_id) return await R.conversations.isMember(r.group_id, u.id);
    if (r.meeting_id) { const s = await R.scheduledMeetings.byId(r.meeting_id); if (s) return s.created_by === u.id || (s.participants && s.participants.includes('"' + u.id + '"')); }
    return false;
  };
  const schedById = new Map(rows.map((m) => [m.id, m]));
  const schedByRoom = new Map(rows.map((m) => [m.roomCode, m]));
  const unsched = new Map();
  for (const r of await R.recordings.forTeam(u.team_id)) {
    if (!(await canSeeRec(r))) continue;
    const m = (r.meeting_id && schedById.get(r.meeting_id)) || (r.room && schedByRoom.get(r.room));
    if (m) { m.recordings.push(recDTO(r)); }
    else { const k = r.room || r.id; if (!unsched.has(k)) unsched.set(k, []); unsched.get(k).push(r); }
  }
  const synth = await Promise.all([...unsched.values()].map(async (list) => {
    list.sort((a, b) => a.created_at - b.created_at); const f = list[0];
    return {
      id: 'rec-' + (f.room || f.id), roomCode: f.room || '', title: f.title || 'Meeting', description: '',
      scheduledAt: f.created_at, groupId: f.group_id || null,
      groupName: f.group_id ? ((await R.conversations.byId(f.group_id) || {}).name || 'Group') : null,
      createdBy: f.created_by, createdByName: f.created_by_name || '', canManage: false, isHost: false,
      invited: [], status: 'past', inCall: 0, recordings: list.map(recDTO),
    };
  }));

  // #7: past CALLS from the call log. Rules the user asked for:
  //   • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a
  //     recording/transcript, which the `synth` entries above already cover;
  //   • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined).
  // What belongs in Past meetings (the user's rules):
  //   • real SCHEDULED meetings  → always;
  //   • INSTANT meetings (Start-a-meeting → meeting-create → logged in call_history as 'adhoc') → always;
  //   • ad-hoc CALLS — a 1:1 "Direct Call", a group call, or a 1:1 that a 3rd person joined — are NOT
  //     meetings, so they show ONLY if they produced a recording/transcript.
  // calls.js writes a 'Direct Call'/'Group call' scheduled row per call (that's the 136 cards); those are
  // the ad-hoc calls to gate. Instant meetings have no scheduled row and live only in call_history.
  const CALL_TITLES = new Set(['Direct Call', 'Group call']);
  const keptRows = rows.filter((m) => {
    if (!CALL_TITLES.has(m.title)) return true;                  // a real, user-scheduled meeting
    return !!(m.recordings && m.recordings.length);             // ad-hoc call → only with a recording/transcript
  });
  // Instant meetings from the call log (deduped against anything already listed via a scheduled row/recording).
  const usedRooms = new Set([...schedByRoom.keys(), ...[...unsched.values()].map((l) => l[0].room).filter(Boolean)]);
  const instantRows = [];
  for (const c of await R.callHistory.forTeam(u.team_id)) {
    if (c.room && usedRooms.has(c.room)) continue;
    let uids = []; try { uids = JSON.parse(c.uids || '[]'); } catch (_) {}
    if (!uids.includes(u.id)) continue;                          // only meetings you were actually in
    let parts = []; try { parts = JSON.parse(c.participants || '[]'); } catch (_) {}
    instantRows.push({
      id: 'call-' + c.id, roomCode: c.room || '', title: c.title || 'Meeting', description: '',
      scheduledAt: c.started_at, endedAt: c.ended_at, groupId: null, groupName: null,
      createdBy: null, createdByName: '', canManage: false, isHost: false, invited: [],
      participantCount: c.peak, durationMins: Math.max(1, Math.round((c.ended_at - c.started_at) / 60000)),
      status: 'past', inCall: 0, recordings: [],
    });
  }

  // Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole).
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  const from = Number(q.get('from')) || 0;
  const to = Number(q.get('to')) || 0;
  const page = Math.max(1, Number(q.get('page')) || 1);
  const pageSize = Math.min(50, Math.max(5, Number(q.get('pageSize')) || 10));
  const all = keptRows.concat(synth, instantRows);
  const live2 = all.filter((m) => m.status !== 'past');
  let past = all.filter((m) => m.status === 'past');
  if (from) past = past.filter((m) => m.scheduledAt >= from);
  if (to) past = past.filter((m) => m.scheduledAt <= to);
  past.sort((a, b) => b.scheduledAt - a.scheduledAt);            // newest first
  const pastTotal = past.length;
  const start = (page - 1) * pageSize;
  json(res, 200, { list: live2.concat(past.slice(start, start + pageSize)), pastTotal, page, pageSize });
});

// Host uploads an in-browser meeting recording (webm). Stored + indexed so it shows under Past meetings.
route('POST', '/api/meetings/recording', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const params = new URLSearchParams(req.url.split('?')[1] || '');
  const room = params.get('room') || '';
  const groupHint = params.get('group') || '';
  const dur = parseInt(params.get('dur') || '0', 10) || null;
  const chunks = []; let total = 0, aborted = false;
  req.on('data', (c) => { total += c.length; if (total > MAX_REC_BYTES) { aborted = true; req.destroy(); return; } chunks.push(c); });
  req.on('end', async () => {
    if (aborted) return json(res, 413, { error: 'recording too large' });
    if (!total) return json(res, 400, { error: 'empty recording' });
    const ctx = CALLS.meetingContext(room);
    const groupId = ctx.groupId || (groupHint && await R.conversations.isMember(groupHint, u.id) ? groupHint : null);
    let title = ctx.title; if ((!title || title === 'Meeting') && groupId) { const g = await R.conversations.byId(groupId); if (g) title = g.name || 'Group'; }
    const id = A.id(); const file = 'm_' + id + '.webm';
    try {
      fs.writeFileSync(path.join(REC_DIR, file), Buffer.concat(chunks));
      await R.recordings.create({ id, teamId: u.team_id, room, groupId, meetingId: ctx.meetingId, title, kind: 'video', file, mime: 'video/webm', size: total, durationMs: dur, createdBy: u.id, createdByName: u.name || u.email });
      audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_recording_saved', detail: 'room ' + room });
      json(res, 200, { ok: true, id });
    } catch (e) { json(res, 500, { error: 'could not save recording' }); }
  });
  req.on('error', () => { try { res.end(); } catch (e) {} });
});

// Cancel a scheduled meeting (organizer only).
route('POST', '/api/meetings/cancel', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { id, scope } = await readBody(req);
  const s = id && await R.scheduledMeetings.byId(id);
  if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
  if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can cancel' });
  if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be cancelled' });
  if (s.scheduled_at <= Date.now()) return json(res, 400, { error: 'the meeting time has passed — it can no longer be cancelled' }); // #13
  let recur = []; try { recur = JSON.parse(s.recurrence || '[]'); } catch (_) {}
  const recips = new Set(); try { JSON.parse(s.participants || '[]').forEach((x) => recips.add(x)); } catch (_) {}
  if (s.group_id) for (const mid of await R.conversations.members(s.group_id)) recips.add(mid);
  if (recur.length && scope === 'one') {
    const occ = s.scheduled_at;
    const whenLabel = new Date(occ).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
    // Snapshot this cancelled occurrence (own non-recurring row) so it appears under Past meetings.
    try {
      let sc; do { sc = A.numericCode(6); } while (await R.scheduledMeetings.byCode(sc));
      let parts = []; try { parts = JSON.parse(s.participants || '[]'); } catch (_) {}
      const sid = A.id();
      await R.scheduledMeetings.create({ id: sid, teamId: u.team_id, groupId: s.group_id, roomCode: sc, title: s.title, description: s.description, scheduledAt: occ, createdBy: s.created_by, participants: parts, durationMins: s.duration_mins, recurrence: [] });
      await R.scheduledMeetings.cancel(sid, u.team_id);
    } catch (_) {}
    // Roll the recurring series forward to its next occurrence.
    const nxt = nextOccurrence(occ, recur, occ);
    if (nxt !== occ) await R.scheduledMeetings.reschedule(id, u.team_id, nxt);
    const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email, when: whenLabel } };
    recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } });
    return json(res, 200, { ok: true, skipped: true });
  }
  await R.scheduledMeetings.cancel(id, u.team_id); // keep it (marked cancelled), don't delete — #12
  const cevt = { type: 'meeting-cancelled', meeting: { id: s.id, title: s.title, by: u.name || u.email } };
  recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, cevt); } catch (_) {} } });
  json(res, 200, { ok: true });
});

// Edit a scheduled meeting (organizer only, while still upcoming).
route('POST', '/api/meetings/update', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence, lobby } = await readBody(req);
  const s = id && await R.scheduledMeetings.byId(id);
  if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
  if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' });
  if (s.cancelled || s.ended_at) return json(res, 400, { error: 'this meeting can no longer be edited' });
  const t = String(title || '').trim().slice(0, 120); if (!t) return json(res, 400, { error: 'title required' });
  const when = Number(scheduledAt); if (!Number.isFinite(when) || when < Date.now()) return json(res, 400, { error: 'pick a valid future time' });
  const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30);
  const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
  const invited = [...new Set(await asyncFilter((Array.isArray(participants) ? participants : []), async (x) => typeof x === 'string' && x !== u.id && await R.users.inTenant(x, u.team_id)))];
  const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
  await R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails, lobby: lobby !== false });
  const label = new Date(when).toLocaleString();
  // Email the updated details to external invitees (new + existing) so their link/time stays current.
  try {
    if (mailer.isEnabled() && guestEmails.length) {
      const link = PUBLIC_BASE_URL + '/home?meet=' + s.room_code;
      const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: String(description || '').trim().slice(0, 1000) });
      mailer.send({ to: guestEmails, subject: tpl.subject, html: tpl.html, text: tpl.text });
    }
  } catch (_) {}
  const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } };
  const recips = new Set(invited); if (s.group_id) for (const mid of await R.conversations.members(s.group_id)) recips.add(mid);
  recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } });
  json(res, 200, { ok: true });
});

// ---------- Polls (within a group conversation) ----------
// Create a poll: stores it + a message (body = question) and pushes the message to members.
route('POST', '/api/polls', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { group, question, options, multi } = await readBody(req);
  if (!group || !await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
  const q = String(question || '').trim().slice(0, 300);
  const opts = (Array.isArray(options) ? options : []).map((s) => String(s || '').trim()).filter(Boolean).slice(0, 10);
  if (!q) return json(res, 400, { error: 'question required' });
  if (opts.length < 2) return json(res, 400, { error: 'at least two options required' });
  const pollId = A.id(); const msgId = A.id();
  await R.messages.send({ id: msgId, teamId: u.team_id, senderId: u.id, recipientId: '', body: q, conversationId: group });
  await R.polls.create({ id: pollId, teamId: u.team_id, conversationId: group, messageId: msgId, question: q, options: opts, multi: !!multi, createdBy: u.id });
  await R.messages.setPoll(msgId, pollId);
  audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'poll_created', detail: q });
  const names = await namesFor(u.team_id);
  for (const mid of await R.conversations.members(group)) {
    try { const dto = await buildMsgDTO(await R.messages.byId(msgId), names, mid); dto.fromName = u.name || u.email; CHAT.pushToUser(mid, { type: 'chat-message', message: dto }); } catch (_) {}
  }
  json(res, 200, await buildPollDTO(await R.polls.byId(pollId), u.id));
});

// Vote on a poll option (toggle). Single-choice replaces the prior vote; multi toggles.
route('POST', '/api/polls/vote', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { pollId, optionIdx } = await readBody(req);
  const p = pollId && await R.polls.byId(pollId);
  if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' });
  if (!await R.conversations.isMember(p.conversation_id, u.id)) return json(res, 403, { error: 'not a member' });
  if (p.closed) return json(res, 400, { error: 'poll is closed' });
  let opts = []; try { opts = JSON.parse(p.options); } catch {}
  const idx = Number(optionIdx);
  if (!Number.isInteger(idx) || idx < 0 || idx >= opts.length) return json(res, 400, { error: 'invalid option' });
  if (p.multi) {
    if (await R.pollVotes.hasVoted(p.id, u.id, idx)) await R.pollVotes.remove(p.id, u.id, idx); else await R.pollVotes.add(p.id, u.id, idx);
  } else {
    const had = await R.pollVotes.hasVoted(p.id, u.id, idx);
    await R.pollVotes.clearUser(p.id, u.id);
    if (!had) await R.pollVotes.add(p.id, u.id, idx);
  }
  for (const mid of await R.conversations.members(p.conversation_id)) {
    try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(p, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
  }
  json(res, 200, await buildPollDTO(p, u.id));
});

// Close a poll (creator only) — no more votes accepted.
route('POST', '/api/polls/close', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { pollId } = await readBody(req);
  const p = pollId && await R.polls.byId(pollId);
  if (!p || p.team_id !== u.team_id) return json(res, 404, { error: 'poll not found' });
  if (p.created_by !== u.id) return json(res, 403, { error: 'only the poll creator can close it' });
  await R.polls.close(p.id);
  const fresh = await R.polls.byId(p.id);
  for (const mid of await R.conversations.members(p.conversation_id)) {
    try { CHAT.pushToUser(mid, { type: 'poll-update', poll: await buildPollDTO(fresh, mid), messageId: p.message_id, conversationId: p.conversation_id }); } catch (_) {}
  }
  json(res, 200, await buildPollDTO(fresh, u.id));
});

// Send a message (persists + live-pushes to the recipient and the sender's other tabs).
route('POST', '/api/messages', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { to, group, body, replyTo, attachmentId, mentions } = await readBody(req);
  const text = String(body || '').trim();
  if (!text && !attachmentId) return json(res, 400, { error: 'message or attachment required' });
  if (text.length > MSG_MAX) return json(res, 400, { error: 'message too long' });
  if (attachmentId) {
    const a = await R.attachments.byId(attachmentId);
    if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' });
  }
  try { await R.users.touchSeen(u.id); } catch (_) {} // #2: keep "last seen" fresh on activity, not just on disconnect
  const id = A.id();
  if (group) {
    if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });
    // Validate mentions: keep only the literal "everyone" and ids that are actual members.
    let mlist = [];
    if (Array.isArray(mentions)) {
      const memberSet = new Set(await R.conversations.members(group));
      mlist = mentions.filter((x) => x === 'everyone' || memberSet.has(x));
      mlist = [...new Set(mlist)];
    }
    await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: '', body: text, replyTo: replyTo || null, attachmentId: attachmentId || null, conversationId: group, mentions: mlist });
    const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
    dto.fromName = u.name || u.email;
    const push = { type: 'chat-message', message: dto };
    const conv = await R.conversations.byId(group); const gname = (conv && conv.name) || 'Group';
    const pushBody = (u.name || u.email) + ': ' + (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment');
    for (const mid of await R.conversations.members(group)) {
      try { CHAT.pushToUser(mid, push); } catch (_) {} // includes sender's other tabs
      if (mid !== u.id) PUSH.sendToUser(mid, { title: gname, body: pushBody, kind: 'group', id: group, tag: 'group:' + group });
    }
    return json(res, 200, dto);
  }
  // Resolve a merged-away recipient id to the surviving account, so DMs to a merged contact don't
  // save against a deleted user (which made them silently vanish).
  const toId = await R.users.resolve(to);
  if (!toId) return json(res, 400, { error: 'to or group required' });
  if (!await R.users.inTenant(toId, u.team_id)) return json(res, 404, { error: 'no such contact' });
  await R.messages.send({ id, teamId: u.team_id, senderId: u.id, recipientId: toId, body: text, replyTo: replyTo || null, attachmentId: attachmentId || null });
  const dto = await buildMsgDTO(await R.messages.byId(id), await namesFor(u.team_id), u.id);
  const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
  try { CHAT.pushToUser(toId, push); } catch (_) {}
  if (toId !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} // sync the sender's other devices (skip for self-notes)
  // Background/closed-tab push to the recipient (opens the DM). Not for a note-to-self.
  if (toId !== u.id) PUSH.sendToUser(toId, { title: (u.name || u.email), body: (text ? (text.length > 80 ? text.slice(0, 80) + '…' : text) : '📎 Attachment'), kind: 'dm', id: u.id, tag: 'dm:' + u.id, icon: u.avatar_url || undefined });
  json(res, 200, dto);
});

// Forward one or more of my visible messages to existing conversations (DMs I'm in / groups I'm a
// member of). Copies body + attachment (attachment stays viewable via the any-carrier /files auth).
route('POST', '/api/messages/forward', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { messageIds, targets } = await readBody(req);
  if (!Array.isArray(messageIds) || !messageIds.length || !Array.isArray(targets) || !targets.length) return json(res, 400, { error: 'messageIds and targets required' });
  // Gather source messages the user is allowed to see, oldest-first (preserve order).
  const srcs = [];
  for (const mid of messageIds.slice(0, 30)) {
    const m = await R.messages.byId(mid);
    if (!m || m.team_id !== u.team_id || m.deleted || m.poll_id) continue;
    const ok = m.conversation_id ? await R.conversations.isMember(m.conversation_id, u.id) : (m.sender_id === u.id || m.recipient_id === u.id);
    if (ok && (m.body || m.attachment_id)) srcs.push(m);
  }
  if (!srcs.length) return json(res, 400, { error: 'nothing to forward' });
  srcs.sort((a, b) => a.created_at - b.created_at);
  const names = await namesFor(u.team_id);
  let sent = 0;
  for (const t of (targets || []).slice(0, 20)) {
    let isGroup = t.kind === 'group', tid = t.id;
    if (isGroup) { if (!await R.conversations.isMember(tid, u.id)) continue; }
    else { tid = await R.users.resolve(tid); if (!tid || !await R.users.inTenant(tid, u.team_id)) continue; }
    for (const m of srcs) {
      const nid = A.token(16);
      const origin = m.fwd_from || names[m.sender_id] || 'Unknown'; // preserve the true origin across re-forwards
      if (isGroup) await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: '', body: m.body, attachmentId: m.attachment_id, conversationId: tid, fwdFrom: origin });
      else await R.messages.send({ id: nid, teamId: u.team_id, senderId: u.id, recipientId: tid, body: m.body, attachmentId: m.attachment_id, fwdFrom: origin });
      const dto = await buildMsgDTO(await R.messages.byId(nid), names, u.id);
      const push = { type: 'chat-message', message: { ...dto, fromName: u.name || u.email } };
      if (isGroup) { for (const mid of await R.conversations.members(tid)) { try { CHAT.pushToUser(mid, push); } catch (_) {} } }
      else { try { CHAT.pushToUser(tid, push); } catch (_) {} if (tid !== u.id) try { CHAT.pushToUser(u.id, push); } catch (_) {} }
      sent++;
    }
  }
  json(res, 200, { ok: true, sent });
});

// Delete one of YOUR OWN messages for everyone (clears content, keeps the row as a placeholder).
route('POST', '/api/messages/delete', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { id } = await readBody(req);
  if (!id) return json(res, 400, { error: 'id required' });
  const m = await R.messages.byId(id);
  if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
  if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only delete your own messages' });
  await R.messages.markDeleted(id);
  const evt = { type: 'chat-deleted', id, conversation_id: m.conversation_id || null };
  if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
  else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
  json(res, 200, { ok: true });
});
// Edit a message (sender only, text only) — updates the body + marks it edited, and pushes the
// change live to the other side / other tabs (mirrors the delete broadcast).
route('POST', '/api/messages/edit', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { id, body } = await readBody(req);
  if (!id || typeof body !== 'string') return json(res, 400, { error: 'id and body required' });
  const text = body.trim();
  if (!text) return json(res, 400, { error: 'message cannot be empty' });
  const m = await R.messages.byId(id);
  if (!m || m.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
  if (m.sender_id !== u.id) return json(res, 403, { error: 'you can only edit your own messages' });
  if (m.deleted) return json(res, 400, { error: 'cannot edit a deleted message' });
  await R.messages.editBody(id, text);
  const edited = await R.messages.byId(id);
  const evt = { type: 'chat-edited', id, body: text, edited_at: edited.edited_at, conversation_id: m.conversation_id || null };
  if (m.conversation_id) { for (const mid of await R.conversations.members(m.conversation_id)) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
  else { try { CHAT.pushToUser(m.recipient_id, evt); } catch (_) {} try { CHAT.pushToUser(u.id, evt); } catch (_) {} }
  json(res, 200, { ok: true, edited_at: edited.edited_at });
});
// Favourite/unfavourite a conversation (per user). target = 'dm:<userId>' or 'group:<groupId>'.
route('POST', '/api/favorites', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { kind, id, on } = await readBody(req);
  if (!kind || !id) return json(res, 400, { error: 'kind and id required' });
  try { await R.favorites.set(u.id, kind + ':' + id, !!on); } catch (_) {}
  json(res, 200, { ok: true, favorite: !!on });
});
// Shared media & files in a conversation (group) or DM — for the "Shared" Media/Files view.
route('GET', '/api/messages/media', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const q = new URLSearchParams(req.url.split('?')[1] || '');
  const group = q.get('group'); const other = q.get('with');
  let rows = [], linkRows = [];
  if (group) { if (!await R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member' }); rows = await R.messages.attachmentsForConversation(u.team_id, group); linkRows = await R.messages.linksForConversation(u.team_id, group); }
  else if (other) { rows = await R.messages.attachmentsForDm(u.team_id, u.id, other); linkRows = await R.messages.linksForDm(u.team_id, u.id, other); }
  else return json(res, 400, { error: 'group or with required' });
  const urlRe = /(https?:\/\/[^\s<>"']+)/gi;
  const links = []; for (const m of linkRows) { const mm = (m.body || '').match(urlRe); if (mm) for (const url of mm) links.push({ url, at: m.created_at }); }
  const att = rows.map((r) => ({ id: r.id, name: r.name, mime: r.mime, size: r.size, isImage: /^image\//.test(r.mime || ''), isAudio: /^audio\//.test(r.mime || ''), isVideo: /^video\//.test(r.mime || ''), at: r.created_at }));
  const isMedia = (a) => a.isImage || a.isAudio || a.isVideo; // images, audio & video → "Media"; everything else → "Docs"
  json(res, 200, { media: att.filter(isMedia), docs: att.filter((a) => !isMedia(a)), links });
});
route('POST', '/api/messages/read', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { with: rawOther, group } = await readBody(req);
  const other = await R.users.resolve(rawOther); // follow a merge redirect
  if (group) {
    if (await R.conversations.isMember(group, u.id)) {
      await R.conversations.markRead(group, u.id);
      const evt = { type: 'group-read', group, by: u.id, byName: (u.name || u.email), at: now() };
      for (const mid of await R.conversations.members(group)) { if (mid !== u.id) { try { CHAT.pushToUser(mid, evt); } catch (_) {} } }
      try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'group', id: group }); } catch (_) {} // #13: clear this chat's notifications on my other devices
    }
    return json(res, 200, { ok: true });
  }
  if (!other) return json(res, 400, { error: 'with or group required' });
  await R.messages.markRead(u.team_id, u.id, other);
  try { CHAT.pushToUser(other, { type: 'chat-read', by: u.id }); } catch (_) {}
  try { CHAT.pushToUser(u.id, { type: 'notif-clear', kind: 'dm', id: other }); } catch (_) {} // #13: multi-device dismissal
  json(res, 200, { ok: true });
});

// Toggle an emoji reaction on a message (live-pushed to the other party).
route('POST', '/api/messages/react', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const { messageId, emoji } = await readBody(req);
  if (!messageId || !emoji) return json(res, 400, { error: 'messageId and emoji required' });
  const msg = await R.messages.byId(messageId);
  const participant = msg && msg.team_id === u.team_id && (
    msg.conversation_id ? await R.conversations.isMember(msg.conversation_id, u.id)
                        : (msg.sender_id === u.id || msg.recipient_id === u.id));
  if (!participant) return json(res, 404, { error: 'no such message' });
  const e = String(emoji).slice(0, 16);
  const added = await R.reactions.toggle(messageId, u.id, e);
  const names = await namesFor(u.team_id);
  // Push the full, recomputed reaction set for this message (per-recipient perspective). Extra
  // fields (by/emoji/added/owner/convId) let the message owner show a "reacted to you" notification.
  const meta = { by: u.name || u.email, byId: u.id, emoji: e, added, owner: msg.sender_id, convId: msg.conversation_id || null };
  if (msg.conversation_id) {
    for (const mid of await R.conversations.members(msg.conversation_id)) {
      try { CHAT.pushToUser(mid, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, mid, names), ...meta }); } catch (_) {}
    }
  } else {
    const other = msg.sender_id === u.id ? msg.recipient_id : msg.sender_id;
    try { CHAT.pushToUser(other, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, other, names), ...meta }); } catch (_) {}
    try { CHAT.pushToUser(u.id, { type: 'chat-reaction', messageId, reactions: await reactionsForMessage(messageId, u.id, names), ...meta }); } catch (_) {}
  }
  json(res, 200, { ok: true, messageId, added, reactions: await reactionsForMessage(messageId, u.id, names) });
});

// Upload a chat attachment (raw body; filename in X-Filename, mime in Content-Type).
// Returns the attachment id to attach to a subsequent /api/messages send.
route('POST', '/api/messages/upload', async (req, res) => {
  const u = await currentUser(req);
  if (!u) return json(res, 401, { error: 'unauthorized' });
  const name = decodeURIComponent(req.headers['x-filename'] || 'file').slice(0, 200);
  const mime = (req.headers['content-type'] || 'application/octet-stream').split(';')[0].trim();
  // STREAM the body straight to disk (never buffer the whole file in memory — a 1 GB attachment would OOM the
  // container). Write to a .part temp file, then atomically rename on success. Backpressure-aware so a fast
  // uploader on a slow disk can't blow up memory either.
  const id = A.id();
  const tmp = path.join(UPLOADS_DIR, id + '.part');
  let ws, total = 0, aborted = false, done = false;
  try { ws = fs.createWriteStream(tmp); } catch (e) { return json(res, 500, { error: 'could not store file' }); }
  const cleanup = () => { try { ws.destroy(); } catch (_) {} try { fs.unlinkSync(tmp); } catch (_) {} };
  const finish = (code, body) => { if (done) return; done = true; json(res, code, body); };
  ws.on('error', () => { aborted = true; cleanup(); finish(500, { error: 'could not store file' }); });
  req.on('data', (c) => {
    if (aborted) return;
    total += c.length;
    if (total > MAX_FILE_BYTES) { aborted = true; cleanup(); finish(413, { error: 'file too large (max ' + MAX_UPLOAD_MB + ' MB)' }); try { req.destroy(); } catch (_) {} return; }
    if (!ws.write(c)) { req.pause(); ws.once('drain', () => { if (!aborted) req.resume(); }); } // respect backpressure
  });
  req.on('end', async () => {
    if (aborted) return;
    ws.end(async () => {
      if (!total) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(400, { error: 'empty file' }); }
      try { fs.renameSync(tmp, path.join(UPLOADS_DIR, id)); }
      catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} return finish(500, { error: 'could not store file' }); }
      await R.attachments.create({ id, teamId: u.team_id, uploaderId: u.id, name, mime, size: total });
      // Videos: build the capped/faststart streaming rendition in the background so it is ready before
      // anyone taps play. Never blocks the upload response, and playback falls back to the original.
      try { require('./media').ensureWebRendition(id, mime); } catch (e) {}
      finish(200, { id, name, mime, size: total });
    });
  });
  req.on('error', () => { aborted = true; cleanup(); try { res.end(); } catch (e) {} });
  req.on('aborted', () => { aborted = true; cleanup(); }); // client hung up mid-upload → drop the partial file
});

// API versioning: alias every /api/* route under /api/v1/* — a frozen contract for
// native desktop/mobile clients. The web app keeps using the unversioned paths, and
// both share the same handlers. (/sso is a browser redirect, intentionally unversioned.)
for (const key of Object.keys(routes)) {
  const m = key.match(/^(\S+) \/api\/(.+)$/);
  if (m) routes[`${m[1]} /api/v1/${m[2]}`] = routes[key];
}

module.exports = routes;
