// Push notifications — Web Push (browsers/PWA) + native FCM (Android) / APNs (iOS).
// Fully optional and additive: each transport is independently config-gated, and every
// call is a silent best-effort no-op when its credentials aren't set. The app is unaffected
// if none are configured.
//
// Web Push:   VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT  (npx web-push generate-vapid-keys)
// Android/FCM: FCM_SERVICE_ACCOUNT  = path to (or inline JSON of) a Firebase service account
// iOS/APNs:    APNS_KEY (path/inline .p8), APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID,
//              APNS_PRODUCTION=1 (use api.push.apple.com instead of the sandbox)
const crypto = require('crypto');
const fs = require('fs');
const http2 = require('http2');
const R = require('./repos');

const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const strData = (o) => { const d = {}; for (const k of Object.keys(o || {})) d[k] = typeof o[k] === 'string' ? o[k] : JSON.stringify(o[k]); return d; };

// ---------------- Web Push (VAPID) ----------------
let webpush = null;
try { webpush = require('web-push'); } catch (_) { /* package not installed -> web push disabled */ }
const PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
const PRIVATE = process.env.VAPID_PRIVATE_KEY || '';
const SUBJECT = process.env.VAPID_SUBJECT || 'mailto:support@bizgaze.com';
let webReady = false;
if (webpush && PUBLIC && PRIVATE) {
  try { webpush.setVapidDetails(SUBJECT, PUBLIC, PRIVATE); webReady = true; }
  catch (e) { console.warn('[push] invalid VAPID config:', e.message); }
}

// ---------------- FCM (Android), HTTP v1 ----------------
let fcmSA = null; // { client_email, private_key, project_id }
(function loadFcm() {
  const raw = process.env.FCM_SERVICE_ACCOUNT;
  if (!raw) return;
  try { fcmSA = JSON.parse(raw.trim().startsWith('{') ? raw : fs.readFileSync(raw, 'utf8')); }
  catch (e) { console.warn('[push] FCM service account unreadable:', e.message); }
})();
let fcmTok = { value: '', exp: 0 };
async function fcmAccessToken() {
  if (fcmTok.value && Date.now() < fcmTok.exp - 60000) return fcmTok.value;
  const now = Math.floor(Date.now() / 1000);
  const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
  const claim = b64url(JSON.stringify({ iss: fcmSA.client_email, scope: 'https://www.googleapis.com/auth/firebase.messaging', aud: 'https://oauth2.googleapis.com/token', iat: now, exp: now + 3600 }));
  const sig = b64url(crypto.sign('RSA-SHA256', Buffer.from(head + '.' + claim), fcmSA.private_key));
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: head + '.' + claim + '.' + sig }),
  });
  const j = await res.json();
  if (!j.access_token) throw new Error('FCM token exchange failed');
  fcmTok = { value: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 };
  return fcmTok.value;
}
async function sendFcm(token, payload) {
  const at = await fcmAccessToken();
  const notif = { title: payload.title || 'Biz Connect', body: payload.body || '' };
  if (payload.icon) notif.image = payload.icon; // sender/group DP shown by Android
  const msg = { message: { token, notification: notif, data: strData(payload.data || {}) } };
  const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', {
    method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg),
  });
  return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens
}

// ---------------- APNs (iOS), token-based over HTTP/2 ----------------
let apnsKey = null, apnsCfg = null;
(function loadApns() {
  const raw = process.env.APNS_KEY;
  if (!raw || !process.env.APNS_KEY_ID || !process.env.APNS_TEAM_ID || !process.env.APNS_BUNDLE_ID) return;
  try {
    apnsKey = raw.trim().startsWith('-----') ? raw : fs.readFileSync(raw, 'utf8');
    apnsCfg = { keyId: process.env.APNS_KEY_ID, teamId: process.env.APNS_TEAM_ID, bundle: process.env.APNS_BUNDLE_ID, host: process.env.APNS_PRODUCTION === '1' ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com' };
  } catch (e) { console.warn('[push] APNs key unreadable:', e.message); }
})();
let apnsJwt = { value: '', iat: 0 };
function apnsToken() {
  const now = Math.floor(Date.now() / 1000);
  if (apnsJwt.value && now - apnsJwt.iat < 3000) return apnsJwt.value; // refresh < 50 min
  const head = b64url(JSON.stringify({ alg: 'ES256', kid: apnsCfg.keyId }));
  const claim = b64url(JSON.stringify({ iss: apnsCfg.teamId, iat: now }));
  const sig = b64url(crypto.sign('SHA256', Buffer.from(head + '.' + claim), { key: apnsKey, dsaEncoding: 'ieee-p1363' }));
  apnsJwt = { value: head + '.' + claim + '.' + sig, iat: now };
  return apnsJwt.value;
}
// One short-lived HTTP/2 connection per send — simple and fine at low volume; pool for scale.
function sendApns(token, payload) {
  return new Promise((resolve) => {
    let client;
    try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
    client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
    // Custom bundled sound (notif.wav, shipped in the app via ios-patch.sh + add-share-extension.rb) gives
    // chat/call notifications an explicit, distinctive tone. A call site may override via payload.sound.
    const body = JSON.stringify({ aps: { alert: { title: payload.title || 'Biz Connect', body: payload.body || '' }, sound: payload.sound || 'notif.wav' }, ...(payload.data || {}) });
    const req = client.request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + apnsToken(), 'apns-topic': apnsCfg.bundle, 'apns-push-type': 'alert' });
    let status = 0;
    req.on('response', (h) => { status = h[':status']; });
    req.on('data', () => {});
    req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
    req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
    req.end(body);
  });
}

// ---------------- VoIP push (PushKit → CallKit), iOS ----------------
// Wakes the app even when force-killed so it can report an incoming call to CallKit. Uses the SAME
// token-based APNs auth (.p8) as alert pushes, but with apns-push-type 'voip' and the '<bundle>.voip'
// topic. The payload is arbitrary call data delivered to the app's PushKit handler (no aps alert). The
// destination is a PushKit "VoIP token" (distinct from the alert APNs token) that the native plugin
// registers as device_tokens.platform = 'ios-voip'.
function sendApnsVoip(token, data) {
  return new Promise((resolve) => {
    if (!apnsCfg) return resolve({});
    let client;
    try { client = http2.connect(apnsCfg.host); } catch (_) { return resolve({}); }
    client.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
    const body = JSON.stringify({ aps: {}, ...(data || {}) });
    const req = client.request({
      ':method': 'POST', ':path': '/3/device/' + token,
      authorization: 'bearer ' + apnsToken(),
      'apns-topic': apnsCfg.bundle + '.voip', 'apns-push-type': 'voip',
      'apns-priority': '10', 'apns-expiration': '0', // ring now or drop — never store a stale call
    });
    let status = 0;
    req.on('response', (h) => { status = h[':status']; });
    req.on('data', () => {});
    req.on('end', () => { try { client.close(); } catch (_) {} resolve({ ok: status >= 200 && status < 300, dead: status === 410 }); });
    req.on('error', () => { try { client.close(); } catch (_) {} resolve({}); });
    req.end(body);
  });
}

// Notify a user of an INCOMING CALL. Prefers a VoIP push (→ CallKit native ring, works when the app is
// killed) if the user has a registered 'ios-voip' token; otherwise falls back to a normal alert push
// (banner + ring sound) so Android / web / iOS-without-the-CallKit-build still get notified. So this is
// backward-compatible: with no VoIP tokens registered yet, it behaves exactly like the old call push.
async function sendCallNotification(userId, data) {
  // data: { callUUID, room, kind, callerId, callerName, groupId, groupName, title, body, hasVideo }
  let toks = [];
  try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
  const voip = toks.filter((t) => t.platform === 'ios-voip');
  if (voip.length && apnsCfg && process.env.CALLKIT_ENABLED === '1') { // kill-switch: off → fall through to a banner
    const payload = {
      type: 'invite',
      callUUID: data.callUUID, room: data.room, kind: data.kind,
      callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
      groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: !!data.hasVideo,
      // LiveKit join credentials so the native plugin can connect the room immediately on answer — even
      // from a killed state, before the WebView has loaded.
      livekitUrl: data.livekitUrl || '', livekitToken: data.livekitToken || '',
    };
    for (const t of voip) {
      try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
    }
    return 'voip';
  }
  await sendToUser(userId, {
    title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
    room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
  });
  return 'push';
}

// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
async function sendCallCancel(userId, callUUID) {
  if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
  let toks = [];
  try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
  for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
    try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
  }
}

// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
console.log(enabled.length ? '[push] enabled: ' + enabled.join(', ') : '[push] disabled (no VAPID / FCM / APNs configured)');

function isEnabled() { return webReady; }      // Web Push specifically (drives /api/push/vapid)
function publicKey() { return webReady ? PUBLIC : ''; }

// Fire-and-forget to every channel the user has: Web Push subscriptions + native device
// tokens. Dead endpoints/tokens are pruned. Never throws.
async function sendToUser(userId, payload) {
  if (!webReady && !nativeReady) return;
  const data = JSON.stringify(payload || {});
  if (webReady) {
    let subs = [];
    try { subs = await R.pushSubs.byUser(userId); } catch (_) { subs = []; }
    for (const s of subs) {
      try { await webpush.sendNotification({ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } }, data, { TTL: 600 }); }
      catch (err) { const c = err && err.statusCode; if (c === 404 || c === 410) { try { await R.pushSubs.removeByEndpoint(s.endpoint); } catch (_) {} } }
    }
  }
  if (nativeReady) {
    let toks = [];
    try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
    for (const t of toks) {
      try {
        let r = null;
        if (t.platform === 'android' && fcmSA) r = await sendFcm(t.token, payload);
        else if (t.platform === 'ios' && apnsCfg) r = await sendApns(t.token, payload);
        if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} }
      } catch (_) { /* best-effort */ }
    }
  }
}

module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel };
