// ── Lecteur Gmail (compte de service + délégation domaine) — navigue/relève cc@ sans MFA ni mot de passe ──
// Auth = JWT RS256 signé avec la clé du compte de service (crypto natif) → access_token IMPERSONNÉ sur la boîte.
// AUCUNE dépendance npm (pas de googleapis) → pas de rebuild d'image. Clé dans /app/data/gmail_sa.json (ou env GMAIL_SA_JSON).
const fs = require('fs')
const crypto = require('crypto')
const { log, loadSeenSet, saveSeenSet } = require('./helpers')

const TOKEN_URL = 'https://oauth2.googleapis.com/token'
const SCOPE = 'https://mail.google.com/' // accès COMPLET Gmail : lire + envoyer/répondre + supprimer/corbeille + libellés
function mailbox () { return (process.env.GMAIL_MAILBOX || 'cc@targointernet.com').trim() }
// From de TOUT envoi sortant (réponses, files, group-send). support@targo.ca est un alias « send-as » VÉRIFIÉ sur cc@ → Gmail l'autorise. Surchargeable par GMAIL_SEND_FROM.
function sendFrom () { return (process.env.GMAIL_SEND_FROM || 'support@targo.ca').trim() }
function loadSA () {
  if (process.env.GMAIL_SA_JSON) { try { return JSON.parse(process.env.GMAIL_SA_JSON) } catch (e) {} }
  try { return JSON.parse(fs.readFileSync('/app/data/gmail_sa.json', 'utf8')) } catch (e) { return null }
}
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')

const _toks = {} // cache token PAR boîte impersonée (cc@, factures@…)
async function getToken (mb) {
  mb = mb || mailbox()
  const c = _toks[mb]
  if (c && Date.now() < c.exp - 60000) return c.tok
  const sa = loadSA()
  if (!sa || !sa.client_email || !sa.private_key) throw new Error('Clé compte de service Gmail absente (/app/data/gmail_sa.json)')
  const iat = Math.floor(Date.now() / 1000); const exp = iat + 3600
  const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
  const claims = b64url(JSON.stringify({ iss: sa.client_email, sub: mb, scope: SCOPE, aud: TOKEN_URL, iat, exp }))
  const signer = crypto.createSign('RSA-SHA256'); signer.update(header + '.' + claims)
  const assertion = header + '.' + claims + '.' + b64url(signer.sign(sa.private_key))
  const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=' + assertion })
  const j = await res.json().catch(() => ({}))
  if (!res.ok || !j.access_token) throw new Error('Token Gmail (' + mb + '): ' + (j.error_description || j.error || res.status))
  _toks[mb] = { tok: j.access_token, exp: Date.now() + (j.expires_in || 3600) * 1000 }
  return _toks[mb].tok
}

async function gapi (path, qs, mb) {
  mb = mb || mailbox()
  const tok = await getToken(mb)
  const u = new URL('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mb) + path)
  if (qs) for (const k in qs) if (qs[k] != null) u.searchParams.set(k, qs[k])
  const res = await fetch(u, { headers: { Authorization: 'Bearer ' + tok } })
  const j = await res.json().catch(() => ({}))
  if (!res.ok) throw new Error('gmail ' + path + ': ' + ((j.error && j.error.message) || res.status))
  return j
}

const hdr = (m, name) => { const h = ((m.payload && m.payload.headers) || []).find(x => x.name.toLowerCase() === name.toLowerCase()); return h ? h.value : '' }
const walkMime = (p, mime) => { if (!p) return ''; if (p.mimeType === mime && p.body && p.body.data) return Buffer.from(p.body.data, 'base64').toString('utf8'); if (p.parts) { for (const c of p.parts) { const r = walkMime(c, mime); if (r) return r } } return '' }
function decodeBody (payload) {
  let txt = walkMime(payload, 'text/plain')
  if (!txt) { const html = walkMime(payload, 'text/html'); if (html) txt = html.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim() }
  return txt
}
// HTML brut du courriel (pour affichage WYSIWYG dans un iframe sandbox côté Ops). '' si le message est texte seul.
function decodeHtml (payload) { return walkMime(payload, 'text/html') || '' }

async function listMessages ({ q = 'newer_than:2d', max = 25, mb } = {}) { const j = await gapi('/messages', { q, maxResults: max }, mb); return j.messages || [] }
// Content-Id (sans <>) d'une part MIME — identifie les images inline référencées par « cid: » dans le HTML.
const partCid = (p) => { const h = ((p.headers || []).find(x => x.name && x.name.toLowerCase() === 'content-id')); return h ? String(h.value).replace(/^<|>$/g, '').trim() : '' }
// Liste les pièces jointes (filename + attachmentId + mimeType). EXCLUT les images inline (cid) : elles sont rendues dans le HTML, pas en pièce jointe.
function listAttachments (payload) {
  const out = []
  const walk = (p) => { if (!p) return; const inlineImg = partCid(p) && /^image\//i.test(p.mimeType || ''); if (!inlineImg && p.filename && p.body && p.body.attachmentId) out.push({ filename: p.filename, mimeType: p.mimeType || 'application/octet-stream', attachmentId: p.body.attachmentId, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
  walk(payload)
  return out
}
// Collecte les images inline (cid) : { cid, mimeType, attachmentId, data(base64url si inline), size }.
function collectInlineImages (payload) {
  const out = []
  const walk = (p) => { if (!p) return; const cid = partCid(p); if (cid && /^image\//i.test(p.mimeType || '') && p.body) out.push({ cid, mimeType: p.mimeType, attachmentId: p.body.attachmentId || null, data: p.body.data || null, size: p.body.size || 0 }); if (p.parts) p.parts.forEach(walk) }
  walk(payload)
  return out
}
// Remplace les <img src="cid:…"> par des data: URLs → l'image s'affiche dans l'inbox (sinon : image cassée). Cap 500 Ko/image (pas de bloat du stockage).
async function inlineCidImages (html, payload, msgId, mb) {
  if (!html || !/cid:/i.test(html)) return html
  let out = html
  for (const im of collectInlineImages(payload)) {
    if (im.size && im.size > 500000) continue
    let b64 = im.data ? im.data.replace(/-/g, '+').replace(/_/g, '/') : null
    if (!b64 && im.attachmentId) { try { b64 = await getAttachment(msgId, im.attachmentId, mb) } catch (e) { b64 = null } }
    if (!b64) continue
    out = out.replace(new RegExp('cid:' + im.cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), 'data:' + (im.mimeType || 'image/png') + ';base64,' + b64)
  }
  return out
}
async function getMessage (id, mb) {
  const m = await gapi('/messages/' + encodeURIComponent(id), { format: 'full' }, mb)
  const html = await inlineCidImages(decodeHtml(m.payload), m.payload, m.id, mb)
  return { id: m.id, threadId: m.threadId, from: hdr(m, 'From'), to: hdr(m, 'To'), cc: hdr(m, 'Cc'), subject: hdr(m, 'Subject'), date: hdr(m, 'Date'), messageId: hdr(m, 'Message-Id') || m.id, snippet: m.snippet || '', body: decodeBody(m.payload), html, attachments: listAttachments(m.payload) }
}
// Récupère TOUT le fil Gmail (messages REÇUS *et* ENVOYÉS, dans l'ordre Gmail) → « re-synchroniser » une conversation : récupère les réponses envoyées hors hub (directement dans Gmail) et les courriels arrivés pendant une panne / sortis de la boîte (donc jamais vus par le poller `in:inbox newer_than`).
async function getThread (threadId, mb) {
  const t = await gapi('/threads/' + encodeURIComponent(threadId), { format: 'full' }, mb)
  const out = []
  for (const m of (t.messages || [])) {
    const html = await inlineCidImages(decodeHtml(m.payload), m.payload, m.id, mb)
    out.push({ id: m.id, threadId: m.threadId, from: hdr(m, 'From'), to: hdr(m, 'To'), cc: hdr(m, 'Cc'), subject: hdr(m, 'Subject'), date: hdr(m, 'Date'), messageId: hdr(m, 'Message-Id') || m.id, snippet: m.snippet || '', body: decodeBody(m.payload), html, attachments: listAttachments(m.payload) })
  }
  return out
}
// Télécharge une pièce jointe → base64 standard (pour OCR vision). mb = boîte impersonée.
async function getAttachment (msgId, attId, mb) {
  const j = await gapi('/messages/' + encodeURIComponent(msgId) + '/attachments/' + encodeURIComponent(attId), null, mb)
  return (j.data || '').replace(/-/g, '+').replace(/_/g, '/') // base64url → base64
}

// ── Écriture : envoyer / répondre / supprimer ──
function encodeHeader (s) { return /[^\x00-\x7F]/.test(String(s)) ? '=?UTF-8?B?' + Buffer.from(String(s)).toString('base64') + '?=' : String(s) } // RFC 2047 pour sujets accentués
// Encode le NOM d'affichage d'une adresse « Nom <email> » en RFC 2047 (accents),
// en laissant <email> intact. Sans <>, renvoie tel quel (email nu).
function encodeAddress (addr) {
  const m = String(addr || '').match(/^(.*?)\s*<([^>]+)>\s*$/)
  if (!m) return String(addr || '')
  const name = m[1].trim().replace(/^"(.*)"$/, '$1')
  return (name ? encodeHeader(name) + ' ' : '') + '<' + m[2] + '>'
}
const htmlToText = (html) => String(html || '')
  .replace(/<!--[\s\S]*?-->/g, ' ')          // commentaires HTML, incl. conditionnels MSO (<o:PixelsPerInch>96</o:…>)
  .replace(/<head[\s\S]*?<\/head>/gi, ' ')   // <head> entier (style/meta/xml/title) — hors du texte lisible
  .replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
// Encode une LISTE d'adresses séparées par des virgules (« a@x, Nom <b@y> ») — chaque adresse en RFC 2047.
function encodeAddressList (s) { return String(s || '').split(',').map(x => x.trim()).filter(Boolean).map(encodeAddress).join(', ') }
// Entité MIME du CORPS (sans en-têtes d'enveloppe) : multipart/alternative si HTML, sinon texte simple.
function bodyEntity ({ body, html }) {
  if (html) { // repli texte + HTML (déliverabilité + clients sans HTML)
    const bnd = 'alt_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
    const plain = body || htmlToText(html)
    return [
      'Content-Type: multipart/alternative; boundary="' + bnd + '"', '',
      '--' + bnd, 'Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', plain, '',
      '--' + bnd, 'Content-Type: text/html; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', html, '',
      '--' + bnd + '--',
    ].join('\r\n')
  }
  return ['Content-Type: text/plain; charset="UTF-8"', 'Content-Transfer-Encoding: 8bit', '', (body || '')].join('\r\n')
}
// Une pièce jointe { filename, mime, content(base64) } → partie MIME pour multipart/mixed.
function attachmentPart (a, bnd) {
  const fn = encodeHeader(String(a.filename || 'piece').replace(/["\r\n]/g, ''))
  const b64 = String(a.content || '').replace(/[^A-Za-z0-9+/=]/g, '').replace(/(.{76})/g, '$1\r\n')
  return [
    '--' + bnd,
    'Content-Type: ' + (a.mime || 'application/octet-stream') + '; name="' + fn + '"',
    'Content-Transfer-Encoding: base64',
    'Content-Disposition: attachment; filename="' + fn + '"', '',
    b64,
  ].join('\r\n')
}
function buildRfc822 ({ to, cc, bcc, subject, body, html, attachments, inReplyTo, references, from }) {
  const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddressList(to)]
  if (cc) h.push('Cc: ' + encodeAddressList(cc))
  if (bcc) h.push('Bcc: ' + encodeAddressList(bcc))
  h.push('Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0')
  if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
  if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
  const atts = Array.isArray(attachments) ? attachments.filter(a => a && a.content) : []
  if (atts.length) { // pièces jointes → multipart/mixed { corps, pièce(s) }
    const mix = 'mix_' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
    h.push('Content-Type: multipart/mixed; boundary="' + mix + '"')
    const parts = ['--' + mix, bodyEntity({ body, html }), ...atts.map(a => attachmentPart(a, mix)), '--' + mix + '--', '']
    return h.join('\r\n') + '\r\n\r\n' + parts.join('\r\n')
  }
  return h.join('\r\n') + '\r\n' + bodyEntity({ body, html })
}
// Envoie (ou répond, si threadId/inReplyTo) un courriel DEPUIS la boîte impersonnée. `html` => multipart/alternative.
async function sendMessage ({ to, cc, bcc, subject, body, html, attachments, threadId, inReplyTo, references, from } = {}) {
  if (!to) throw new Error('destinataire requis')
  const tok = await getToken()
  const payload = { raw: b64url(buildRfc822({ to, cc, bcc, subject, body, html, attachments, inReplyTo, references, from })) }
  if (threadId) payload.threadId = threadId
  const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/send', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
  const j = await res.json().catch(() => ({}))
  if (!res.ok) throw new Error('gmail send: ' + ((j.error && j.error.message) || res.status))
  return { ok: true, id: j.id, threadId: j.threadId }
}
async function trashMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trash: ' + res.status); return { ok: true, trashed: id } }
async function deleteMessage (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id), { method: 'DELETE', headers: { Authorization: 'Bearer ' + tok } }); if (!(res.status === 204 || res.ok)) throw new Error('gmail delete: ' + res.status); return { ok: true, deleted: id } }
// Marque SPAM (label SPAM + retire INBOX/UNREAD) → Gmail apprend + sort de la boîte. Réversible (le message reste dans Spam).
async function markSpam (id) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/messages/' + encodeURIComponent(id) + '/modify', { method: 'POST', headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' }, body: JSON.stringify({ addLabelIds: ['SPAM'], removeLabelIds: ['INBOX', 'UNREAD'] }) }); if (!res.ok) throw new Error('gmail spam: ' + res.status); return { ok: true, spam: id } }
// Corbeille TOUT le fil (réversible 30 j) — pour « supprimer la conversation = sortir les courriels de la boîte ».
async function trashThread (threadId) { const tok = await getToken(); const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/' + encodeURIComponent(mailbox()) + '/threads/' + encodeURIComponent(threadId) + '/trash', { method: 'POST', headers: { Authorization: 'Bearer ' + tok } }); if (!res.ok) throw new Error('gmail trashThread: ' + res.status); return { ok: true, trashedThread: threadId } }

// Poll : nouveaux courriels → ingestEmail (dédup via seen). Opt-in GMAIL_INGEST=on.
const SEEN_FILE = '/app/data/gmail_seen.json'
const loadSeen = () => loadSeenSet(SEEN_FILE)
const saveSeen = (s) => saveSeenSet(SEEN_FILE, s)
async function poll () {
  // Exclut les courriels adressés à factures@ : ils sont traités par le pipeline FACTURE FOURNISSEUR (pas des conversations).
  const invTo = (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim()
  // `in:inbox` = on n'ingère QUE le courrier REÇU. Sans ça, la requête ramassait
  // aussi le dossier Envoyés (ex. les 105 courriels d'une campagne partis depuis
  // cette boîte) → fils parasites dans l'Inbox Ops. Les réponses des agents sont
  // déjà ajoutées au fil à l'envoi, pas besoin de les ré-ingérer.
  const ids = await listMessages({ q: 'in:inbox newer_than:7d' + (invTo ? ' -to:' + invTo : ''), max: 40 })
  const seen = loadSeen(); let ingested = 0
  const { ingestEmail } = require('./conversation')
  for (const { id } of ids.slice().reverse()) {
    if (seen.has(id)) continue
    try { const m = await getMessage(id); await ingestEmail({ from: m.from, to: m.to, cc: m.cc, date: m.date, subject: m.subject, body: m.body || m.snippet, html: m.html, message_id: m.messageId, thread_id: m.threadId, gmail_id: m.id }); seen.add(id); ingested++ } catch (e) { log('gmail poll msg ' + id + ': ' + e.message) }
  }
  saveSeen(seen)
  return { ok: true, scanned: ids.length, ingested }
}

let _timer = null
function start () {
  if (_timer) return
  if (String(process.env.GMAIL_INGEST || '').toLowerCase() === 'off') { log('Gmail ingest: OFF (GMAIL_INGEST=off)'); return }
  if (!loadSA()) { log('Gmail ingest: clé absente → OFF (déposer /app/data/gmail_sa.json puis docker restart)'); return }
  const ms = (Number(process.env.GMAIL_POLL_MIN) || 3) * 60000
  _timer = setInterval(() => poll().catch(e => log('gmail poll error: ' + e.message)), ms)
  log('Gmail ingest poller ON (' + (ms / 60000) + ' min) sur ' + mailbox())
  poll().catch(e => log('gmail first poll: ' + e.message))
}
function stop () { if (_timer) { clearInterval(_timer); _timer = null } }

async function handle (req, res, method, p, url) {
  const json = (c, o) => { res.writeHead(c, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)) }
  try {
    if (p === '/gmail/status' && method === 'GET') { const sa = loadSA(); return json(200, { configured: !!sa, sa_email: sa ? sa.client_email : null, mailbox: mailbox(), poller_on: !!_timer }) }
    if (p === '/gmail/ping' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); await getToken(mb); const ids = await listMessages({ q: 'newer_than:30d', max: 1, mb }); return json(200, { ok: true, mailbox: mb, sample_found: ids.length }) }
    if (p === '/gmail/poll' && method === 'POST') return json(200, await poll())
    if (p === '/gmail/list' && method === 'GET') { const mb = (url && url.searchParams.get('mailbox')) || mailbox(); const q = (url && url.searchParams.get('q')) || 'newer_than:7d'; const ids = await listMessages({ q, max: 25, mb }); const out = []; for (const { id } of ids.slice(0, 20)) { try { out.push(await getMessage(id, mb)) } catch (e) {} } return json(200, { mailbox: mb, count: out.length, messages: out.map(m => ({ id: m.id, from: m.from, subject: m.subject, date: m.date, snippet: m.snippet, attachments: (m.attachments || []).map(a => a.filename) })) }) }
    if (p === '/gmail/message' && method === 'GET') { const id = url && url.searchParams.get('id'); if (!id) return json(400, { error: 'id requis' }); return json(200, await getMessage(id)) }
    if (p === '/gmail/send' && method === 'POST') { const b = await readBody(req); return json(200, await sendMessage(b || {})) }
    if (p === '/gmail/trash' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await trashMessage(b.id)) }
    if (p === '/gmail/delete' && method === 'POST') { const b = await readBody(req); if (!b || !b.id) return json(400, { error: 'id requis' }); return json(200, await deleteMessage(b.id)) }
  } catch (e) { return json(500, { error: e.message }) }
  return json(404, { error: 'not found' })
}
function readBody (req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 5e6) req.destroy() }); req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { resolve({}) } }); req.on('error', () => resolve({})) }) }

module.exports = { getToken, listMessages, getMessage, getThread, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox, sendFrom }
