// Shared group calls: one live call per group. Members join without a code; the call
// ends (with a duration line in the chat) when the last participant's mesh room empties.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const PUSH = require('./push'); // native push (APNs/FCM/WebPush) so a CLOSED app is notified of calls
const LK = require('./livekit'); // mint the callee's LiveKit join token for the native VoIP call payload
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|');

// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
async function meetingContext(room) {
  const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
  try {
    const sched = await R.scheduledMeetings.byCode(room);
    if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
  } catch (_) {}
  if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
  if (ctx.groupId && ctx.title === 'Meeting') { try { const g = await R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
  if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
  return ctx;
}

// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
async function finalizeTranscript(room, onlyUserId) {
  const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
  const buf = transcriptBuffers.get(room) || [];
  const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
  if (ids.length && buf.length) {
    const ctx = await meetingContext(room);
    const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
    const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
    for (const uid of ids) {
      let user = null; try { user = await R.users.byId(uid); } catch (_) {}
      if (!user) { subs.delete(uid); continue; }
      const id = A.id(); const file = 'm_' + id + '.txt';
      try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
      // groupId null → private to its creator (see canSeeRec / /mrec auth).
      await R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
      subs.delete(uid);
    }
  } else { ids.forEach((uid) => subs.delete(uid)); }
  if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}

function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }

async function broadcast(group, evt) { try { for (const mid of await R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }

// Post a centered activity line into the group (system sender → no ping on clients).
async function postSystem(group, teamId, text) {
  const id = A.id();
  await R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
  const m = await R.messages.byId(id);
  broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
}

async function startGroupCall(group, teamId, user) {
  const existing = groupCalls.get(group);
  if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
  let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
  meetingRooms.set(room, new Map());
  const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
  // Log the call as a meeting so it appears under Past meetings (history) with the group name.
  try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
  groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
  postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call').catch(() => {});
  let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
  broadcast(group, { type: 'group-call', group, active: true, room, uuid: call.uuid, by: user.id, startedByName: call.startedByName, groupName: gName });
  // Notify the OTHER members so a closed app is alerted to the group call — VoIP/CallKit if available,
  // else a banner. broadcast() above only reaches connected sockets. Best-effort; never throws.
  try { for (const mid of await R.conversations.members(group)) { if (mid !== user.id) PUSH.sendCallNotification(mid, { callUUID: call.uuid, room, kind: 'group', groupId: group, groupName: gName, callerId: user.id, callerName: call.startedByName, title: gName, body: '📞 ' + call.startedByName + ' started a group call', hasVideo: true, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(mid, null, room) }); } } catch (_) {}
  return { room, uuid: call.uuid, active: true };
}

// Called from signaling when a mesh room empties — ends the group call if this room was one.
async function endGroupCallByRoom(room) {
  const group = roomToGroupCall.get(room);
  if (!group) return;
  const call = groupCalls.get(group);
  roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
  if (call) {
    let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
    if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
    broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
    // Stop any CallKit ring on members' killed/backgrounded devices.
    try { for (const mid of await R.conversations.members(group)) { if (mid !== call.startedBy) PUSH.sendCallCancel(mid, call.uuid); } } catch (_) {} // not the starter — a cancel to them re-rings their own phone
  }
}

// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
async function startDmCall(me, otherId, teamId) {
  const key = pairKey(me.id, otherId);
  const existing = dmCalls.get(key);
  if (existing) return { room: existing.room, uuid: existing.uuid, active: true, already: true };
  let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
  meetingRooms.set(room, new Map());
  const byName = me.name || me.email;
  const call = { room, uuid: crypto.randomUUID(), startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId, answered: false };
  // Log to history (both participants) so the call shows under Past meetings with its transcript.
  try { const hid = A.id(); await R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
  dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
  // #9 (unanswered): if the callee never joins within the ring window, auto-end and mark it missed —
  // so the caller isn't stuck "ringing" forever.
  call.ringTimer = setTimeout(() => {
    if (call.answered) return;
    const peers = meetingRooms.get(room);
    if (peers) { for (const [, p] of peers) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended', reason: 'unanswered' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
    endDmCallByRoom(room);
  }, 40000);
  // A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
  const mid = A.id();
  await R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
  const m = await R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
  try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
  try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
  try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, uuid: call.uuid, with: me.id, by: me.id, byName }); } catch (_) {}
  try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, uuid: call.uuid, with: otherId, by: me.id, byName }); } catch (_) {}
  // Notify the callee so a CLOSED app still rings — VoIP/CallKit if the device registered a VoIP token,
  // else a banner (the CHAT.pushToUser events above only reach a connected socket). Best-effort. On
  // reconnect the callee's app also re-shows the invite (replayActiveCalls), so answering works either way.
  try { PUSH.sendCallNotification(otherId, { callUUID: call.uuid, room, kind: 'dm', callerId: me.id, callerName: byName, title: byName, body: '📞 Incoming call', hasVideo: false, livekitUrl: LK.LIVEKIT_URL, livekitToken: LK.livekitToken(otherId, null, room) }); } catch (_) {}
  return { room, uuid: call.uuid, active: true };
}

async function endDmCallByRoom(room, silent) {
  const key = roomToDmCall.get(room); if (!key) return;
  const call = dmCalls.get(key);
  roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
  if (!call) return;
  if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} }
  if (call.historyId && call.teamId) { try { await R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
  // A native side that ends the call (POST /api/calls/end) may have a dropped WebSocket, so the OTHER party
  // can be left sitting in the mesh meeting "still in the call". Close their window. Idempotent: when this
  // runs from the mesh emptying normally, the room is already gone → no-op (so it never double-ends).
  const stuck = meetingRooms.get(room);
  if (stuck) { for (const [, p] of stuck) { if (p.ws && p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p.ws._meetingRoom = null; } } meetingRooms.delete(room); }
  // Activity line: a duration ONLY if the call was answered; otherwise "Missed call" (#7/#9).
  if (!silent) try {
    const mid = A.id(); const body = call.answered ? ('📞 Call ended · ' + fmtDur(now() - (call.answeredAt || call.startedAt))) : '📞 Missed call';
    await R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
    const m = await R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
    call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
  } catch (_) {}
  call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} });
  // Stop the CallKit ring on a device that's still RINGING (unanswered) — a killed/asleep callee has no WS
  // to receive the dm-call above. For an ANSWERED call both sides are awake (the WS event ends it), and a
  // cancel push would re-ring the device that just hung up — so only cancel when it was NOT answered.
  // Cancel the ring ONLY on the CALLEE's device(s) — never the caller (startedBy). The caller is placing the
  // call, not ringing, so a cancel push to them made their OWN phone re-ring after they hung up an unanswered
  // outgoing call (the plugin must reportNewIncomingCall for every VoIP push → a phantom ring on the caller).
  if (!call.answered) { const callee = call.users.find((u) => u !== call.startedBy); if (callee) { try { PUSH.sendCallCancel(callee, call.uuid); } catch (_) {} } }
  // Missed-call banner to the callee (a plain notification, like a phone's missed call) when the call ended
  // UNANSWERED — timeout or the caller hung up before pickup. Skipped on decline (silent): they chose to.
  if (!silent && !call.answered) {
    const callee = call.users.find((u) => u !== call.startedBy);
    if (callee) { try { PUSH.sendToUser(callee, { title: call.startedByName || 'Missed call', body: '📞 Missed call', kind: 'dm', id: call.startedBy, tag: 'missed:' + room, data: { kind: 'dm', id: call.startedBy } }); } catch (_) {} }
  }
}

// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
// message shows a real duration (from answer) and the unanswered timeout stands down.
function markDmAnswered(room, userId) {
  const key = roomToDmCall.get(room); if (!key) return;
  const call = dmCalls.get(key); if (!call) return;
  if (userId && userId !== call.startedBy && !call.answered) {
    call.answered = true; call.answeredAt = now();
    if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; }
    // Native calls carry media over LiveKit, not our mesh — so the callee's OTHER devices never learn the
    // call was picked up here and keep ringing forever (and dismissing that stale ring as a "decline" would
    // tear down THIS live call). Tell them it was taken (dismiss the ring, no teardown), and flip the
    // caller's UI from "ringing" to "connected".
    try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {}
    try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {}
  }
}
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
// original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses
// them. This makes the call PUSH actionable: tapping the banner opens the app, the socket connects, and
// the invite re-appears so they can answer (while the caller is still within the ring window). Sends only
// to the freshly-connected socket. Best-effort; never throws.
async function replayActiveCalls(userId, ws) {
  if (!userId || !ws || ws.readyState !== 1) return;
  try {
    for (const [, call] of dmCalls) {
      if (call.answered) continue;
      if (call.users.includes(userId) && call.startedBy !== userId) {
        try { ws.send(JSON.stringify({ type: 'dm-call', active: true, room: call.room, uuid: call.uuid, with: call.startedBy, by: call.startedBy, byName: call.startedByName })); } catch (_) {}
      }
    }
    for (const [group, call] of groupCalls) {
      if (call.startedBy === userId) continue;
      let member = false; try { member = await R.conversations.isMember(group, userId); } catch (_) {}
      if (!member) continue;
      let gName = 'Group'; try { const g = await R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
      try { ws.send(JSON.stringify({ type: 'group-call', group, active: true, room: call.room, uuid: call.uuid, by: call.startedBy, startedByName: call.startedByName, groupName: gName })); } catch (_) {}
    }
  } catch (_) {}
}

// Called from signaling when any mesh room empties.
async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDmCallByRoom(room); }

// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
async function declineDmCall(room, byUser) {
  const key = roomToDmCall.get(room); if (!key) return { ok: false };
  const call = dmCalls.get(key); if (!call) return { ok: false };
  // Already ANSWERED (e.g. a native CallKit pickup on this user's other device, which bypasses our mesh so
  // the check below can't see it): a "decline" here is just the stale ring on a second device — dismiss it,
  // do NOT tear down the live call.
  if (call.answered && byUser.id !== call.startedBy) return { ok: true, alreadyAnswered: true };
  // #8: if this user has ALREADY accepted on another device (they're in the room), this is just the
  // ringing invite on a second device — dismiss it silently, do NOT tear down the active call.
  const inRoom = meetingRooms.get(room);
  if (inRoom) { for (const [, p] of inRoom) { if (p.ws && p.ws._meetingUserId === byUser.id) return { ok: true, alreadyJoined: true }; } }
  const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
  try {
    const mid = A.id();
    await R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
    const mm = await R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
    CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
    CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
  } catch (_) {}
  // Drop the caller who's still waiting in the (otherwise empty) mesh room.
  const peers = meetingRooms.get(room);
  if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
  endDmCallByRoom(room, true); // silent: we already posted "Call declined"
  return { ok: true };
}

module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, markDmAnswered, replayActiveCalls, finalizeTranscript, meetingContext, fmtDur, pairKey };
