// End-to-end test of the backend platform.
// Exercises the full flow: register -> login -> enroll machine -> agent online ->
// technician requests session -> consent -> signaling relay -> audit trail.
// No browser/Electron needed: the "agent" and "viewer" are raw WebSocket clients.
// (Login currently marks the session MFA-passed directly, so there is no separate
// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)

const fs = require('fs');
const os = require('os');
const path = require('path');
const DB = path.join(os.tmpdir(), 'ra-e2e.db');
process.env.DB_PATH = DB;
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }

const PORT = 8099;
process.env.PORT = PORT;
process.env.HTTPS_PORT = 8444; // avoid clashing with a running dev server on 8443
const { server } = require('../server');
const A = require('../auth');
const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');

const BASE = `http://localhost:${PORT}`;
let passed = 0, failed = 0;
function check(name, cond) {
  if (cond) { console.log('  ok  -', name); passed++; }
  else { console.log('  FAIL-', name); failed++; }
}

// minimal cookie-aware fetch
async function call(path, body, cookie) {
  const r = await fetch(BASE + path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
    body: body ? JSON.stringify(body) : undefined,
  });
  const setCookie = r.headers.get('set-cookie');
  const data = await r.json().catch(() => ({}));
  return { status: r.status, data, cookie: setCookie ? setCookie.split(';')[0] : cookie };
}
async function get(path, cookie) {
  const r = await fetch(BASE + path, { headers: cookie ? { Cookie: cookie } : {} });
  return { status: r.status, data: await r.json().catch(() => ({})) };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function wsClient() {
  const ws = new WebSocket(`ws://localhost:${PORT}/ws`);
  ws.q = [];
  ws.on('message', (d) => ws.q.push(JSON.parse(d)));
  return ws;
}
function nextMsg(ws, type, timeout = 3000) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    (function poll() {
      const i = ws.q.findIndex((m) => m.type === type);
      if (i >= 0) return resolve(ws.q.splice(i, 1)[0]);
      if (Date.now() - start > timeout) return reject(new Error('timeout waiting for ' + type));
      setTimeout(poll, 20);
    })();
  });
}

(async () => {
  await wait(300); // let server bind
  console.log('E2E backend tests:');

  // Local receiver to capture outbound webhook deliveries.
  const webhookHits = [];
  const hookSrv = http.createServer((rq, rs) => { let b = ''; rq.on('data', (c) => (b += c)); rq.on('end', () => { webhookHits.push({ sig: rq.headers['x-bizgaze-signature'], body: b }); rs.writeHead(200); rs.end('ok'); }); });
  await new Promise((r) => hookSrv.listen(8077, r));
  const HOOK_URL = 'http://localhost:8077/hook';

  // 1. Register (first user becomes admin)
  const email = 'tech@example.com';
  const reg = await call('/api/register', { email, password: 'supersecret', teamName: 'Acme IT' });
  check('register succeeds', reg.status === 200 && reg.data.ok === true);

  // 2. Login -> session cookie (login marks the session MFA-passed)
  const login = await call('/api/login', { email, password: 'supersecret' });
  check('login sets session cookie', !!login.cookie);
  const cookie = login.cookie;

  // 3. Protected route works right after login, role=admin
  const me = await get('/api/me', cookie);
  check('me works after login, role=admin', me.status === 200 && me.data.role === 'admin');

  // 3b. Native client path: access+refresh tokens, refresh exchange (rotated), Bearer auth
  check('login returns access + refresh tokens', !!login.data.token && !!login.data.refreshToken);
  const refreshed = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
  check('refresh issues a new access token', refreshed.status === 200 && !!refreshed.data.token && !!refreshed.data.refreshToken);
  const meBearer = await fetch(BASE + '/api/v1/me', { headers: { Authorization: 'Bearer ' + refreshed.data.token } });
  check('new access token authorizes /api/v1/me (Bearer)', meBearer.status === 200);
  const reuse = await call('/api/v1/auth/refresh', { refreshToken: login.data.refreshToken });
  check('rotated (old) refresh token is rejected', reuse.status === 401);

  // 3b'. Native device push tokens (FCM/APNs registration; delivery no-ops until creds set)
  const devReg = await call('/api/v1/devices', { platform: 'android', token: 'fcm-tok-123' }, cookie);
  check('device token registered', devReg.status === 200 && devReg.data.ok === true);
  const devBad = await call('/api/v1/devices', { platform: 'windows', token: 'x' }, cookie);
  check('device register rejects invalid platform', devBad.status === 400);
  const devNoAuth = await call('/api/v1/devices', { platform: 'ios', token: 'y' });
  check('device register requires auth', devNoAuth.status === 401);
  const devRm = await call('/api/v1/devices/remove', { token: 'fcm-tok-123' }, cookie);
  check('device token removed', devRm.status === 200);

  // 3b''. App install telemetry (track who installed) + admin listing
  const tel = await call('/api/v1/telemetry/install', { installId: 'inst-e2e-1', platform: 'desktop', appVersion: '0.1.1', os: 'win32' }, cookie);
  check('install telemetry recorded', tel.status === 200 && tel.data.ok === true);
  const insts = await get('/api/v1/admin/installs', cookie);
  check('admin sees the install tied to the user', insts.status === 200 && Array.isArray(insts.data) && insts.data.some((i) => i.install_id === 'inst-e2e-1' && i.app_version === '0.1.1' && i.user_email));

  // 3c. API keys (machine-to-machine integration), scoped + revocable
  const mkKey = await call('/api/v1/keys', { name: 'ci', scopes: ['report:read'] }, cookie);
  check('admin creates API key (bzc_ prefix)', mkKey.status === 200 && /^bzc_/.test(mkKey.data.key || ''));
  const apiKey = mkKey.data.key;
  const repKey = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
  check('API key with report:read reads /api/v1/report', repKey.status === 200);
  const repNone = await fetch(BASE + '/api/v1/report');
  check('no credential -> /api/v1/report 401', repNone.status === 401);
  const audKey = await fetch(BASE + '/api/v1/audit', { headers: { 'X-API-Key': apiKey } });
  check('report-only key cannot read audit (scope enforced)', audKey.status === 401);
  const revKey = await call('/api/v1/keys/revoke', { id: mkKey.data.id }, cookie);
  check('admin revokes API key', revKey.status === 200);
  const repRevoked = await fetch(BASE + '/api/v1/report', { headers: { 'X-API-Key': apiKey } });
  check('revoked API key -> 401', repRevoked.status === 401);

  // 3d. Register a webhook (delivery is asserted after the session flow below)
  const mkHook = await call('/api/v1/webhooks', { url: HOOK_URL, events: ['*'] }, cookie);
  check('admin registers a webhook', mkHook.status === 200 && !!mkHook.data.secret);
  const hookSecret = mkHook.data.secret;

  // 3e. Chat (persistent 1:1 messaging) — two users in the same team
  const adminId = me.data.id;
  await call('/api/v1/users', { email: 'bob@example.com', password: 'supersecret', name: 'Bob' }, cookie);
  const bobLogin = await call('/api/v1/login', { email: 'bob@example.com', password: 'supersecret' });
  const bobCookie = bobLogin.cookie;
  const contacts = await get('/api/v1/messages/contacts', cookie);
  check('contacts list includes the other user', contacts.status === 200 && contacts.data.some((c) => c.email === 'bob@example.com'));
  const bobId = (contacts.data.find((c) => c.email === 'bob@example.com') || {}).id;
  const bobWs = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: bobCookie } });
  bobWs.q = []; bobWs.on('message', (d) => bobWs.q.push(JSON.parse(d)));
  await new Promise((r) => bobWs.on('open', r));
  bobWs.send(JSON.stringify({ type: 'chat-hello' }));
  await nextMsg(bobWs, 'chat-ready');
  check('chat-hello -> chat-ready', true);
  const sent = await call('/api/v1/messages', { to: bobId, body: 'hello bob' }, cookie);
  check('message sent', sent.status === 200 && !!sent.data.id);
  const pushed = await nextMsg(bobWs, 'chat-message');
  check('recipient receives the message live over WS', pushed.message && pushed.message.body === 'hello bob');
  const bobConvos = await get('/api/v1/messages/conversations', bobCookie);
  check('recipient conversation shows unread', bobConvos.data.some((c) => c.contactId === adminId && c.unread >= 1));
  const bobThread = await get('/api/v1/messages/thread?with=' + adminId, bobCookie);
  check('thread returns the message', bobThread.status === 200 && bobThread.data.some((m) => m.body === 'hello bob'));
  const bobConvos2 = await get('/api/v1/messages/conversations', bobCookie);
  check('reading the thread clears unread', !bobConvos2.data.some((c) => c.contactId === adminId && c.unread >= 1));
  // read receipt: after bob read the thread, admin's sent message shows read_at
  const adminTh = await get('/api/v1/messages/thread?with=' + bobId, cookie);
  const seenMsg = adminTh.data.find((x) => x.body === 'hello bob');
  check('read receipt: sent message marked read after recipient reads', !!seenMsg && !!seenMsg.read_at);
  // reply / quote
  const reply = await call('/api/v1/messages', { to: bobId, body: 'replying to you', replyTo: sent.data.id }, cookie);
  check('reply accepts replyTo', reply.status === 200 && reply.data.reply_to === sent.data.id);
  const adminThread = await get('/api/v1/messages/thread?with=' + bobId, cookie);
  check('thread carries the quoted preview', adminThread.data.some((x) => x.reply && x.reply.body === 'hello bob'));
  // reactions
  const react1 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '👍' }, cookie);
  check('reaction toggles on', react1.status === 200 && react1.data.added === true);
  const rpush = await nextMsg(bobWs, 'chat-reaction');
  check('reaction pushed live (full set, with who)', rpush.messageId === sent.data.id && Array.isArray(rpush.reactions) && rpush.reactions.some((r) => r.emoji === '👍' && r.count === 1 && Array.isArray(r.who) && r.who.length === 1));
  const th2 = await get('/api/v1/messages/thread?with=' + bobId, cookie);
  const rmsg = th2.data.find((x) => x.id === sent.data.id);
  check('thread aggregates the reaction', !!rmsg && rmsg.reactions.some((r) => r.emoji === '👍' && r.count === 1 && r.mine === true && r.who.length === 1));
  // one reaction per user: a different emoji REPLACES the previous one (no stacking)
  const react1b = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
  check('switching emoji replaces (one reaction per user)', react1b.data.added === true && react1b.data.reactions.length === 1 && react1b.data.reactions[0].emoji === '❤️');
  const react2 = await call('/api/v1/messages/react', { messageId: sent.data.id, emoji: '❤️' }, cookie);
  check('reaction toggles off', react2.data.added === false && react2.data.reactions.length === 0);
  // file sharing
  const up = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'text/plain', 'X-Filename': encodeURIComponent('note.txt') }, body: 'hello file' });
  const upd = await up.json();
  check('file upload returns an id', up.status === 200 && !!upd.id);
  const fmsg = await call('/api/v1/messages', { to: bobId, attachmentId: upd.id }, cookie);
  check('message with attachment (no text) accepted', fmsg.status === 200 && fmsg.data.attachment && fmsg.data.attachment.id === upd.id);
  const dl = await fetch(BASE + '/files/' + upd.id, { headers: { Cookie: cookie } });
  const dlBody = await dl.text();
  check('attachment downloads for a participant', dl.status === 200 && dlBody === 'hello file');
  const dlNo = await fetch(BASE + '/files/' + upd.id);
  check('attachment download denied without auth', dlNo.status === 401);

  // 3g. Group chat
  const mkGroup = await call('/api/v1/groups', { name: 'Team Huddle', memberIds: [bobId] }, cookie);
  check('group created with members', mkGroup.status === 200 && !!mkGroup.data.id && mkGroup.data.members === 2);
  const gid = mkGroup.data.id;
  bobWs.q.length = 0; // drain prior pushes so we catch the group message
  const gsend = await call('/api/v1/messages', { group: gid, body: 'hi team' }, cookie);
  check('group message sent', gsend.status === 200 && gsend.data.conversation_id === gid);
  const gpush = await nextMsg(bobWs, 'chat-message');
  check('group message delivered to a member', gpush.message && gpush.message.conversation_id === gid && gpush.message.body === 'hi team');
  const gconv = await get('/api/v1/messages/conversations', bobCookie);
  const gItem = gconv.data.find((c) => c.kind === 'group' && c.id === gid);
  check('group appears in member conversations with unread', !!gItem && gItem.unread >= 1);
  const gthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
  check('group thread returns messages with sender name', gthread.status === 200 && gthread.data.some((m) => m.body === 'hi team' && m.fromName));
  const gconv2 = await get('/api/v1/messages/conversations', bobCookie);
  const gItem2 = gconv2.data.find((c) => c.kind === 'group' && c.id === gid);
  check('reading group thread clears unread', !!gItem2 && gItem2.unread === 0);
  // group "seen by": bob read the thread above, so admin's message lists him as a reader
  const gSeen = await get('/api/v1/messages/thread?group=' + gid, cookie);
  const hiTeam = gSeen.data.find((x) => x.body === 'hi team');
  check('group message shows seen-by readers', !!hiTeam && Array.isArray(hiTeam.seenBy) && hiTeam.seenBy.length >= 1);
  const gmembers = await get('/api/v1/groups/members?group=' + gid, cookie);
  check('group members listed', gmembers.status === 200 && gmembers.data.length === 2);
  // @mentions: members + @everyone are stored; non-members are filtered out
  const ment = await call('/api/v1/messages', { group: gid, body: 'ping @Bob and @everyone', mentions: [bobId, 'everyone', 'not-a-member'] }, cookie);
  check('group message accepts mentions', ment.status === 200);
  check('mentions filtered to members + everyone', Array.isArray(ment.data.mentions) && ment.data.mentions.includes(bobId) && ment.data.mentions.includes('everyone') && !ment.data.mentions.includes('not-a-member'));
  const mthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
  const mEntry = mthread.data.find((x) => x.id === ment.data.id);
  check('thread carries mentions', !!mEntry && mEntry.mentions.includes(bobId) && mEntry.mentions.includes('everyone'));

  // 3j. Polls (single + multi, vote/switch, close, inline in thread)
  const mkPoll = await call('/api/v1/polls', { group: gid, question: 'Lunch?', options: ['Pizza', 'Sushi'], multi: false }, cookie);
  check('poll created', mkPoll.status === 200 && mkPoll.data.options.length === 2 && mkPoll.data.totalVotes === 0);
  const pid = mkPoll.data.id;
  const needTwo = await call('/api/v1/polls', { group: gid, question: 'X?', options: ['only one'] }, cookie);
  check('poll requires >=2 options', needTwo.status === 400);
  const v1 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
  check('vote recorded', v1.status === 200 && v1.data.options[0].votes === 1 && v1.data.options[0].mine === true);
  const v2 = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 1 }, bobCookie); // single-choice -> switch
  check('single-choice switches the vote', v2.data.options[0].votes === 0 && v2.data.options[1].votes === 1);
  const av = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, cookie);
  check('second voter counted', av.data.totalVotes === 2 && av.data.voters === 2);
  const mkPoll2 = await call('/api/v1/polls', { group: gid, question: 'Toppings?', options: ['Cheese', 'Olives', 'Mushroom'], multi: true }, cookie);
  const pid2 = mkPoll2.data.id;
  await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 0 }, bobCookie);
  const mv = await call('/api/v1/polls/vote', { pollId: pid2, optionIdx: 1 }, bobCookie);
  check('multi-choice keeps multiple votes', mv.data.options[0].votes === 1 && mv.data.options[1].votes === 1 && mv.data.voters === 1);
  const badClose = await call('/api/v1/polls/close', { pollId: pid }, bobCookie);
  check('non-creator cannot close a poll', badClose.status === 403);
  const closed = await call('/api/v1/polls/close', { pollId: pid }, cookie);
  check('creator closes the poll', closed.status === 200 && closed.data.closed === true);
  const voteClosed = await call('/api/v1/polls/vote', { pollId: pid, optionIdx: 0 }, bobCookie);
  check('cannot vote on a closed poll', voteClosed.status === 400);
  const pthread = await get('/api/v1/messages/thread?group=' + gid, bobCookie);
  const pmsg = pthread.data.find((x) => x.poll && x.poll.id === pid);
  check('poll renders inline in the thread', !!pmsg && pmsg.poll.options.length === 2 && pmsg.poll.closed === true);

  // group management: rename, info, remove(leave)
  const ren = await call('/api/v1/groups/rename', { group: gid, name: 'Renamed Huddle' }, cookie);
  check('group renamed', ren.status === 200 && ren.data.name === 'Renamed Huddle');
  const gthrRen = await get('/api/v1/messages/thread?group=' + gid, cookie);
  check('rename posts an activity message', gthrRen.data.some((x) => x.system && /renamed/.test(x.body)));
  // admin-only toggle: only the creator can change it / manage members when on
  const ao1 = await call('/api/v1/groups/admin-only', { group: gid, value: true }, cookie);
  check('admin enables admin-only', ao1.status === 200 && ao1.data.adminOnly === true);
  const aoBobToggle = await call('/api/v1/groups/admin-only', { group: gid, value: false }, bobCookie);
  check('non-admin cannot change admin-only', aoBobToggle.status === 403);
  const aoBobRemove = await call('/api/v1/groups/remove', { group: gid, userId: adminId }, bobCookie);
  check('admin-only blocks non-admin removing others', aoBobRemove.status === 403);
  const ao0 = await call('/api/v1/groups/admin-only', { group: gid, value: false }, cookie);
  check('admin disables admin-only', ao0.status === 200 && ao0.data.adminOnly === false);
  // shared group call: start returns a room; a second start joins the same live call
  const call1 = await call('/api/v1/groups/call/start', { group: gid }, cookie);
  check('group call starts with a room', call1.status === 200 && /^\d{6}$/.test(call1.data.room || '') && call1.data.active === true);
  const call2 = await call('/api/v1/groups/call/start', { group: gid }, bobCookie);
  check('second start joins the same call (no code)', call2.status === 200 && call2.data.room === call1.data.room && call2.data.already === true);
  const convCall = await get('/api/v1/messages/conversations', bobCookie);
  const gcRow = convCall.data.find((c) => c.kind === 'group' && c.id === gid);
  check('conversations expose the active call', !!gcRow && gcRow.callActive === true && gcRow.callRoom === call1.data.room);
  // 1:1 call + add-participant invite
  const dmCall1 = await call('/api/v1/calls/dm/start', { to: bobId }, cookie);
  check('DM call starts with a room', dmCall1.status === 200 && /^\d{6}$/.test(dmCall1.data.room || '') && dmCall1.data.active === true);
  const dmCall2 = await call('/api/v1/calls/dm/start', { to: adminId }, bobCookie);
  check('DM call join returns the same room', dmCall2.status === 200 && dmCall2.data.room === dmCall1.data.room && dmCall2.data.already === true);
  const inv = await call('/api/v1/calls/invite', { room: dmCall1.data.room, userIds: [bobId] }, cookie);
  check('invite to a live call accepted', inv.status === 200 && inv.data.invited === 1);
  const invBad = await call('/api/v1/calls/invite', { room: '000000', userIds: [bobId] }, cookie);
  check('invite to a non-existent call rejected', invBad.status === 404);
  const ginfo = await get('/api/v1/groups/info?group=' + gid, cookie);
  check('group info returns name + members + isCreator', ginfo.status === 200 && ginfo.data.name === 'Renamed Huddle' && ginfo.data.isCreator === true && ginfo.data.members.length === 2);

  // 3h. Scheduled meetings (schedule -> announce -> list buckets -> lazy join -> cancel)
  const future = Date.now() + 24 * 60 * 60 * 1000;
  bobWs.q.length = 0;
  const sched = await call('/api/v1/meetings/schedule', { group: gid, title: 'Sprint Review', description: 'Demo + retro', scheduledAt: future, whenText: 'Tomorrow' }, cookie);
  check('meeting scheduled, returns 6-digit room code', sched.status === 200 && /^\d{6}$/.test(sched.data.roomCode || ''));
  const schP = await call('/api/v1/meetings/schedule', { title: 'Synced', scheduledAt: Date.now() + 3600000, participants: [bobId] }, cookie);
  check('meeting scheduled with participants', schP.status === 200 && Array.isArray(schP.data.participants) && schP.data.participants.includes(bobId));
  const bobMeetings = await get('/api/v1/meetings', bobCookie);
  const bm = bobMeetings.data.list.find((m) => m.id === schP.data.id);
  check('invited participant sees the meeting', !!bm && bm.isHost === false);
  const schPush = await nextMsg(bobWs, 'chat-message');
  check('schedule announced in the group chat', schPush.message && /Scheduled a call/.test(schPush.message.body));
  const pastM = await call('/api/v1/meetings/schedule', { group: gid, title: 'Old Standup', scheduledAt: Date.now() - 2 * 60 * 60 * 1000 }, cookie);
  check('past meeting scheduling is rejected', pastM.status === 400); // can't schedule in the past (#1)
  const mlist = await get('/api/v1/meetings', cookie);
  const schUp = mlist.data.list.find((m) => m.id === sched.data.id);
  check('meetings list buckets upcoming', mlist.status === 200 && schUp && schUp.status === 'upcoming');
  const sm = wsClient(); await new Promise((r) => sm.on('open', r));
  sm.send(JSON.stringify({ type: 'meeting-join', room: sched.data.roomCode, name: 'Admin' }));
  const smJoined = await nextMsg(sm, 'meeting-joined');
  check('scheduled meeting joinable by code (room created lazily)', !!smJoined.peerId && smJoined.room === sched.data.roomCode);
  const mlist2 = await get('/api/v1/meetings', cookie);
  const upRun = mlist2.data.list.find((m) => m.id === sched.data.id);
  check('scheduled meeting shows running while a peer is connected', !!upRun && upRun.status === 'running' && upRun.inCall >= 1);
  sm.close();
  const cancelBob = await call('/api/v1/meetings/cancel', { id: sched.data.id }, bobCookie);
  check('non-organizer cannot cancel a meeting', cancelBob.status === 403);
  const cancelOk = await call('/api/v1/meetings/cancel', { id: sched.data.id }, cookie);
  check('organizer cancels the meeting', cancelOk.status === 200);

  // 3i. Group image (upload -> set as group avatar -> visible to members)
  const imgUp = await fetch(BASE + '/api/v1/messages/upload', { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'image/png', 'X-Filename': encodeURIComponent('logo.png') }, body: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) });
  const imgUpd = await imgUp.json();
  check('group image uploaded', imgUp.status === 200 && !!imgUpd.id);
  const badAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: upd.id }, cookie); // upd.id is text/plain
  check('non-image rejected as group photo', badAv.status === 400);
  const setAv = await call('/api/v1/groups/avatar', { group: gid, attachmentId: imgUpd.id }, cookie);
  check('group photo set', setAv.status === 200 && setAv.data.avatar === '/files/' + imgUpd.id);
  const ginfoAv = await get('/api/v1/groups/info?group=' + gid, cookie);
  check('group info exposes the photo', ginfoAv.status === 200 && ginfoAv.data.avatar === '/files/' + imgUpd.id);
  const memberFetch = await fetch(BASE + '/files/' + imgUpd.id, { headers: { Cookie: bobCookie } });
  check('group member can fetch the group photo', memberFetch.status === 200);

  const bobLeave = await call('/api/v1/groups/remove', { group: gid }, bobCookie); // bob leaves
  check('member can leave the group', bobLeave.status === 200 && bobLeave.data.left === true);
  const gthrLeft = await get('/api/v1/messages/thread?group=' + gid, cookie);
  check('leaving posts an activity message', gthrLeft.data.some((x) => x.system && /left/.test(x.body)));
  const ginfo2 = await get('/api/v1/groups/info?group=' + gid, cookie);
  check('member count drops after leave', ginfo2.status === 200 && ginfo2.data.members.length === 1);
  bobWs.close();

  // 3f. Meetings (mesh) signaling: create room, two peers join, relay signal, leave
  const alice = wsClient(); await new Promise((r) => alice.on('open', r));
  alice.send(JSON.stringify({ type: 'meeting-create' }));
  const created = await nextMsg(alice, 'meeting-created');
  check('meeting-create returns a 6-digit room code', /^\d{6}$/.test(created.room || ''));
  alice.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Alice' }));
  const aJoined = await nextMsg(alice, 'meeting-joined');
  check('first peer joins (room empty)', !!aJoined.peerId && aJoined.peers.length === 0);
  const carol = wsClient(); await new Promise((r) => carol.on('open', r));
  carol.send(JSON.stringify({ type: 'meeting-join', room: created.room, name: 'Carol' }));
  const cJoined = await nextMsg(carol, 'meeting-joined');
  check('second peer sees the first in the room', cJoined.peers.some((p) => p.peerId === aJoined.peerId));
  const aPeerJoined = await nextMsg(alice, 'meeting-peer-joined');
  check('existing peer notified of newcomer', aPeerJoined.peerId === cJoined.peerId);
  alice.send(JSON.stringify({ type: 'meeting-signal', to: cJoined.peerId, data: { fake: 'offer' } }));
  const relayed = await nextMsg(carol, 'meeting-signal');
  check('meeting signal relayed peer->peer', relayed.from === aJoined.peerId && relayed.data.fake === 'offer');
  carol.close();
  const aPeerLeft = await nextMsg(alice, 'meeting-peer-left');
  check('peer-left delivered on disconnect', aPeerLeft.peerId === cJoined.peerId);
  alice.close();

  // 4. Wrong password rejected
  const badLogin = await call('/api/login', { email, password: 'wrong' });
  check('wrong password rejected', badLogin.status === 401);

  // 8. Enroll a machine (consent-required)
  const mach = await call('/api/machines', { name: 'Dana-Laptop', unattended: false }, cookie);
  check('machine enrolled, returns token', mach.status === 200 && mach.data.enrollToken);
  const enrollToken = mach.data.enrollToken;

  // 9. Agent comes online
  const agent = wsClient();
  await new Promise((r) => agent.on('open', r));
  agent.send(JSON.stringify({ type: 'agent-hello', enrollToken }));
  const reg2 = await nextMsg(agent, 'agent-registered');
  check('agent registers via enroll token', reg2.name === 'Dana-Laptop');

  // machine shows online in API
  const machines = await get('/api/machines', cookie);
  check('machine reports online', machines.data[0].online === true);

  // 10. Technician (viewer) requests a session — needs cookie on the WS upgrade
  const viewer = new WebSocket(`ws://localhost:${PORT}/ws`, { headers: { Cookie: cookie } });
  viewer.q = []; viewer.on('message', (d) => viewer.q.push(JSON.parse(d)));
  await new Promise((r) => viewer.on('open', r));
  viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id }));
  const pending = await nextMsg(viewer, 'session-pending');
  check('viewer gets session-pending', !!pending.sessionId);

  // 11. Agent receives the consent request
  const reqMsg = await nextMsg(agent, 'session-request');
  check('agent receives session-request with technician email', reqMsg.technician === email);

  // 12. Agent grants consent -> both sides proceed
  agent.send(JSON.stringify({ type: 'consent', sessionId: reqMsg.sessionId, granted: true }));
  const ready = await nextMsg(viewer, 'session-ready');
  const startStream = await nextMsg(agent, 'start-stream');
  check('consent grant -> viewer session-ready', !!ready);
  check('consent grant -> agent start-stream', !!startStream);

  // 13. Signaling relay: agent offer reaches viewer; viewer answer reaches agent
  agent.send(JSON.stringify({ type: 'offer', sessionId: reqMsg.sessionId, sdp: { fake: 'offer' } }));
  const relayedOffer = await nextMsg(viewer, 'offer');
  check('offer relayed agent->viewer', relayedOffer.sdp.fake === 'offer');
  viewer.send(JSON.stringify({ type: 'answer', sessionId: reqMsg.sessionId, sdp: { fake: 'answer' } }));
  const relayedAnswer = await nextMsg(agent, 'answer');
  check('answer relayed viewer->agent', relayedAnswer.sdp.fake === 'answer');

  // 14. End session
  viewer.send(JSON.stringify({ type: 'end-session', sessionId: reqMsg.sessionId }));
  await nextMsg(agent, 'session-ended');
  check('session-ended delivered to agent', true);

  // 14b. Outbound webhook delivery (session.started + session.ended) with valid signatures
  await wait(900);
  const parse = (h) => { try { return JSON.parse(h.body); } catch { return {}; } };
  const hookStarted = webhookHits.find((h) => parse(h).event === 'session.started');
  const hookEnded = webhookHits.find((h) => parse(h).event === 'session.ended');
  check('webhook received session.started', !!hookStarted);
  check('webhook received session.ended', !!hookEnded);
  check('webhook signature is valid (HMAC-SHA256)', !!hookEnded && hookEnded.sig === crypto.createHmac('sha256', hookSecret).update(hookEnded.body).digest('base64url'));

  // 15. Audit log captured the full flow
  const audit = await get('/api/audit', cookie);
  const actions = audit.data.map((a) => a.action);
  for (const a of ['user_registered', 'login', 'machine_enrolled', 'session_requested', 'consent_granted', 'session_ended']) {
    check(`audit contains "${a}"`, actions.includes(a));
  }

  // 16. Denial path
  viewer.send(JSON.stringify({ type: 'viewer-connect', machineId: machines.data[0].id }));
  const pending2 = await nextMsg(viewer, 'session-pending');
  const req2 = await nextMsg(agent, 'session-request');
  agent.send(JSON.stringify({ type: 'consent', sessionId: req2.sessionId, granted: false }));
  const denied = await nextMsg(viewer, 'session-denied');
  check('consent denial -> viewer session-denied', !!denied);

  agent.close(); viewer.close();
  hookSrv.close();
  console.log(`\n${passed} passed, ${failed} failed.`);
  server.close();
  process.exit(failed ? 1 : 0);
})().catch((e) => { console.error('E2E ERROR:', e); process.exit(1); });
