'use strict'
const http = require('http')
const { URL } = require('url')
const cfg = require('./lib/config')
const { log, json, parseBody } = require('./lib/helpers')
const rateLimit = require('./lib/ratelimit')
// Limites anti-abus sur les routes PUBLIQUES (token dans l'URL = énumérable) — généreuses pour ne pas gêner un vrai
// usager ; strictes sur l'OTP (brute-force du code SMS). req/fenêtre PAR IP. Webhooks (Twilio/Stripe) EXCLUS
// (authentifiés par signature/token + rafales légitimes) ; /c/ /book /portal /payments exclus (usage interactif).
const RL_RULES = [
  { prefix: '/api/otp', max: 15, win: 60000 },
  { prefix: '/rate', max: 40, win: 60000 },
  { prefix: '/diag/', max: 40, win: 60000 },
  { prefix: '/accept', max: 40, win: 60000 },
  { prefix: '/t/', max: 60, win: 60000 },
  { prefix: '/g/', max: 60, win: 60000 },
  { prefix: '/signup', max: 20, win: 60000 },
  { prefix: '/acte', max: 40, win: 60000 },
]

// Garde-fous de crash : une erreur réseau SORTANTE non interceptée (ex. ECONNRESET « socket hang up » quand
// ERPNext/Frappe — peu de workers — reset une connexion sous rafale) ne doit JAMAIS tuer le gateway. Sans ça, le hub
// plantait et Docker (unless-stopped) le redémarrait → Traefk renvoyait 502 le temps du redémarrage (symptôme :
// « Roster API 502 » en naviguant dans Planification = ~5 appels roster simultanés). On log et on CONTINUE.
process.on('unhandledRejection', (e) => { try { log('unhandledRejection:', (e && e.stack) || e) } catch (_) { /* no-op */ } })
process.on('uncaughtException', (e) => { try { log('uncaughtException:', (e && e.stack) || e) } catch (_) { /* no-op */ } })
const sse = require('./lib/sse')
const twilio = require('./lib/twilio')
const pbx = require('./lib/pbx')
const telephony = require('./lib/telephony')
const devices = require('./lib/devices')
const provision = require('./lib/provision')
const auth = require('./lib/auth')
const conversation = require('./lib/conversation')
const traccar = require('./lib/traccar')
const dispatch = require('./lib/dispatch')
const ical = require('./lib/ical')
const vision = require('./lib/vision')
const giftbit = require('./lib/giftbit')
let voiceAgent
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) }
// Oktopus stack is decommissioned. Set OKTOPUS_DISABLED=1 (default) to skip
// loading the module and the MQTT monitor — both would otherwise attempt
// connections to a dead broker and spam reconnect errors into stdout. The
// Mongo `devices` collection updates from MQTT heartbeats are no longer
// produced; nothing in ops or the field app reads from that collection.
// Re-enable by setting OKTOPUS_DISABLED=0 in the hub env.
const OKTOPUS_DISABLED = process.env.OKTOPUS_DISABLED !== '0'
let oktopus = null
let oktopusMqtt = null
if (!OKTOPUS_DISABLED) {
  try { oktopus = require('./lib/oktopus') } catch (e) { console.log('Oktopus module not loaded:', e.message) }
  try { oktopusMqtt = require('./lib/oktopus-mqtt') } catch (e) { console.log('Oktopus MQTT monitor not loaded:', e.message) }
} else {
  console.log('Oktopus integration disabled (OKTOPUS_DISABLED=1)')
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
  const path = url.pathname
  const method = req.method

  res.setHeader('Access-Control-Allow-Origin', '*')
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authentik-Email, X-Authentik-Groups')
  if (method === 'OPTIONS') { res.writeHead(204); return res.end() }

  try {
    // Rate-limit anti-abus (énumération de tokens / brute-force OTP) sur les routes PUBLIQUES sensibles — par IP, fail-open.
    {
      rateLimit.maybeSweep(60000)
      for (const r of RL_RULES) {
        if (path.startsWith(r.prefix)) {
          if (rateLimit.limited(req, r.prefix, r.max, r.win)) { res.setHeader('Retry-After', '60'); return json(res, 429, { error: 'Trop de requêtes — réessayez dans un instant' }) }
          break
        }
      }
    }
    // ── Hub auth gate — staged rollout ───────────────────────────────────
    // Tiers: PUBLIC (no auth) · customer self-service (own internal auth, also
    // listed PUBLIC here, e.g. /payments /portal) · staff-ADMIN (Bearer
    // HUB_SERVICE_TOKEN, injected server-side by the ops nginx /hub/ proxy).
    // Cross-origin apps with no token (client portal, field-tech) must only
    // use PUBLIC routes below.
    //   HUB_GATE = 'enforce' (401 on deny) | 'report' (log + allow) | unset(off)
    // ALWAYS_ENFORCE = already locked & verified (no legit anon traffic) → 401
    // regardless of mode. /campaigns/webhook stays public (Mailjet → us).
    {
      const PUBLIC = [
        '/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
        '/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate',
        '/rsvp/',                    // inscription événement (RSVP) : page + soumission publiques ; ?t=<jwt client> pré-remplit. La vue staff /events reste gatée (ALWAYS_ENFORCE).
        '/acte/form', '/acte/data', '/acte/submit', '/acte/photo', '/acte/entente/doc', // page sous-traitant payé à l'acte (token vérifié dans le handler) — /acte/send /acte/rates /acte/list /acte/img /acte/entente (édition) restent gatés
        '/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/after-dial', '/voice/twiml', '/voice/status',
        '/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
        '/api/referral', '/api/accept-for-client',
        // /payments : routes CLIENT/portail publiques (le portail apps/client appelle
        // msg.gigafibre.ca en direct, sans token). Les routes AGENT à risque
        // (charge/refund/ppa-run/send-link = mouvement d'argent / envoi) sont
        // VOLONTAIREMENT absentes → elles tombent dans le gate et exigent le
        // HUB_SERVICE_TOKEN (Ops passe par le proxy /ops/hub qui l'injecte).
        // Phase 2 FAIT : balance/methods/invoice/checkout/setup/portal/toggle-ppa sont
        // dual-usage → autorisées PAR CLIENT dans lib/payments.js (authorizeCustomer :
        // staff token OU JWT magic-link dont `sub` == customer ciblé), bloquant 401 si
        // PAYMENTS_AUTH=enforce. Le portail envoie son JWT (sessionStorage targo_portal_jwt).
        // Toute NOUVELLE route /payments exposant des données client doit appeler denyIfUnauthorized.
        '/payments/pay/', '/payments/return', '/payments/balance/', '/payments/methods/',
        '/payments/invoice/', '/payments/checkout', '/payments/setup', '/payments/portal',
        '/payments/toggle-ppa',
        '/webhook/', '/campaigns/webhook',
        '/dispatch/calendar/',       // token-authed .ics feed
        '/giftbit-demo',             // démo sandbox testbed (validation Giftbit) — PUBLIC ; l'API /giftbit/* reste gatée
        '/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
        '/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
        '/prefs',                    // préférences d'affichage par utilisateur : identité = x-authentik-email (forward-auth SSO), impersonation admin gatée dans le handler (groupes)
        '/avatars',                  // photos de profil : GET public (chargées par <img>), POST/DELETE keyés x-authentik-email (self), ?as= admin — gaté dans le handler
        '/chatwoot',                 // Dashboard App Chatwoot : /chatwoot/panel (iframe) PUBLIC ; /chatwoot/lookup protégé par CHATWOOT_PANEL_KEY (PII) dans le handler
      ]
      // Toujours exiger le token (indépendant de HUB_GATE) pour toute route qui
      // bouge de l'argent, contrôle réseau/modems, envoie, ou expose PII/RBAC.
      // Les carve-outs PUBLIC (convChat, /dispatch/calendar/, routes client /payments)
      // gagnent quand même car `isPublic` court-circuite ce bloc en amont.
      const ALWAYS_ENFORCE = [
        '/devices', '/email-queue', '/campaigns', '/giftbit',
        '/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab', '/staff-agent',
        '/network', '/sync', '/legacy-payments', '/telephony', '/flow',
        '/conversations', '/dispatch', '/sla', '/events', // /events/* = vue staff RSVP (PII : noms/courriels/décompte) → token requis
        '/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
        '/payments/record-invoice', // enregistrement manuel d'un paiement contre facture (crée un Payment Entry) → staff only
      ]
      // Web-chat client (lien magique /c/<token>) : le token EST le secret → routes CLIENT publiques.
      // GET /conversations/<token>, GET .../sse, POST .../messages, POST .../push UNIQUEMENT.
      // Exclut resolve/archive/discussion (réservées agent) ; le reste de /conversations reste gaté.
      const convChat = (() => {
        const mm = path.match(/^\/conversations\/([A-Za-z0-9_-]{5,10})(?:\/(messages|sse|push))?$/)
        if (!mm || ['resolve', 'archive', 'bulk', 'discussion'].includes(mm[1])) return false
        const sub = mm[2]
        if (!sub) return method === 'GET'           // charger le fil
        if (sub === 'sse') return method === 'GET'  // flux SSE client
        return method === 'POST'                    // messages | push
      })()
      // Lecture seule des actifs (images de courriels + bibliothèque) : GET /campaigns/assets/<hash>.<ext> PUBLIC.
      // Les <img> (courriels chez le client, vignettes Ops) n'ont pas de token ; le nom = hash de contenu (64 hex, indevinable).
      // Le POST /campaigns/assets/upload reste GATÉ (≠ GET et « upload » ne matche pas le motif hash.ext).
      const assetGet = method === 'GET' && /^\/campaigns\/assets\/[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(path)
      // « Voir dans le navigateur » des courriels (destinataire anonyme) : PUBLIC ; la route /view
      // vérifie elle-même ?t=<gift_token> pour les courriels-cadeaux (id rewards-adhoc devinable → non énumérable).
      const viewGet = method === 'GET' && /^\/campaigns\/[^/]+\/recipients\/\d+\/view$/.test(path)
      const isPublic = convChat || assetGet || viewGet || PUBLIC.some(p => path.startsWith(p))
      if (!isPublic) {
        const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
        const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN
        if (!hasToken) {
          const mustEnforce = ALWAYS_ENFORCE.some(p => path.startsWith(p))
          if (mustEnforce || process.env.HUB_GATE === 'enforce') {
            return json(res, 401, { error: 'Unauthorized' })
          }
          if (process.env.HUB_GATE === 'report') log(`[hub-gate] would-deny ${method} ${path}`)
        }
      }
    }
    // Vérif signature Twilio (voix + SMS + statut) — détectée par le header X-Twilio-Signature. Mode REPORT par
    // défaut (log, ne bloque PAS) ; TWILIO_VERIFY=enforce → 403. parseBody est mis en cache → le handler re-parse sans souci.
    if (method === 'POST' && cfg.TWILIO_AUTH_TOKEN && req.headers['x-twilio-signature']) {
      const params = await parseBody(req)
      if (!twilio.verifyTwilioSignature(req, params)) {
        if (process.env.TWILIO_VERIFY === 'enforce') return json(res, 403, { error: 'Invalid Twilio signature' })
        log(`[twilio-sig] would-reject ${method} ${path} (signature invalide — vérifier X-Forwarded-Host/Proto)`)
      }
    }
    if (path === '/health') {
      return json(res, 200, { ok: true, clients: sse.clientCount(), topics: sse.topicCount(), uptime: Math.floor(process.uptime()) })
    }

    if (path === '/sse' && method === 'GET') {
      const email = req.headers['x-authentik-email'] || 'anonymous'
      const topics = (url.searchParams.get('topics') || '').split(',').map(t => t.trim()).filter(Boolean)
      if (!topics.length) return json(res, 400, { error: 'Missing topics parameter' })
      res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' })
      // `retry:` = délai de reconnexion natif ; puis un VRAI event `ready` (pas un commentaire `:`) → le client peut s'y
      // abonner pour RESYNCHRONISER à chaque (re)connexion (ex. après un redéploiement du hub qui a coupé le flux).
      res.write('retry: 3000\n')
      res.write('event: ready\ndata: {"ts":' + Date.now() + '}\n\n')
      sse.addClient(topics, res, email)
      const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
      res.on('close', () => clearInterval(keepalive))
      return
    }

    if (path === '/broadcast' && method === 'POST') {
      const a = req.headers.authorization || ''
      if (cfg.INTERNAL_TOKEN && a !== 'Bearer ' + cfg.INTERNAL_TOKEN) return json(res, 401, { error: 'Unauthorized' })
      const body = await parseBody(req)
      if (!body.topic || !body.event) return json(res, 400, { error: 'Missing topic or event' })
      return json(res, 200, { ok: true, delivered: sse.broadcast(body.topic, body.event, body.data || {}) })
    }

    if (path === '/webhook/twilio/sms-incoming' && method === 'POST') return twilio.smsIncoming(req, res)
    if (path === '/webhook/twilio/sms-status' && method === 'POST') return twilio.smsStatus(req, res)
    if (path === '/send/sms' && method === 'POST') return twilio.sendSms(req, res)
    if (path === '/voice/token' && method === 'GET') return twilio.voiceToken(req, res, url)
    if (path === '/voice/sip-config' && method === 'GET') return twilio.sipConfig(req, res)
    if (path === '/voice/twiml' && method === 'POST') return twilio.voiceTwiml(req, res)
    if (path === '/voice/status' && method === 'POST') return twilio.voiceStatus(req, res)
    if (path === '/voice/inbound' && method === 'POST') return voiceAgent.handleInboundCall(req, res)
    if (path === '/voice/gather' && method === 'POST') return voiceAgent.handleGather(req, res)
    if (path === '/voice/connect-agent' && method === 'POST') return voiceAgent.handleConnectAgent(req, res)
    if (path === '/voice/after-dial' && method === 'POST') return voiceAgent.handleAfterDial(req, res)
    if (path === '/voice/heartbeat' && method === 'POST') return voiceAgent.handleHeartbeat(req, res)
    if (path === '/voice/to-ai' && method === 'POST') return voiceAgent.handleToAI(req, res)
    if (path === '/webhook/3cx/call-event' && method === 'POST') return pbx.callEvent(req, res)

    // Uptime-Kuma webhook — synthesized outage alerts
    if (path === '/webhook/kuma' && method === 'POST') {
      const body = await parseBody(req)
      const { handleKumaWebhook } = require('./lib/outage-monitor')
      const result = await handleKumaWebhook(body)
      return json(res, 200, result)
    }
    if (path.startsWith('/telephony/')) return telephony.handle(req, res, method, path, url)
    if (path.startsWith('/devices')) return devices.handle(req, res, method, path, url)
    if (path.startsWith('/acs/')) return devices.handleACSConfig(req, res, method, path, url)
    if (path.startsWith('/provision/')) return provision.handle(req, res, method, path)
    if (path.startsWith('/oktopus/')) {
      if (!oktopus) return require('./lib/helpers').json(res, 410, { error: 'Oktopus integration removed' })
      return oktopus.handle(req, res, method, path)
    }
    if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
    if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
    if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité)
    if (path === '/avatars' || path.startsWith('/avatars/')) return require('./lib/avatars').handle(req, res, method, path) // photos de profil par utilisateur
    if (path.startsWith('/identity')) return require('./lib/identity').handle(req, res, method, path, url) // identité unifiée (courriels alias + label + lien tech) ; écritures admin
    if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings
    if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé)
    if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff
    if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
    if (path.startsWith('/giftbit')) return giftbit.handle(req, res, method, path, url)
    if (path.startsWith('/municipality')) return require('./lib/municipality').handle(req, res, method, path, url)
    if (path.startsWith('/legacy-sync')) return require('./lib/legacy-sync').handle(req, res, method, path, url)
    if (path.startsWith('/legacy-payments')) return require('./lib/legacy-payments').handle(req, res, method, path, url)
    if (path.startsWith('/sync/')) return require('./lib/sync-orchestrator').handle(req, res, method, path, url)
    if (path.startsWith('/coupons/')) return require('./lib/coupon-triage').handle(req, res, method, path)
    if (path.startsWith('/acte')) return require('./lib/subcontractor').handle(req, res, method, path, url) // sous-traitants payés à l'acte (page token + barème + soumissions)
    if (path.startsWith('/outbox')) return require('./lib/outbox').handle(req, res, method, path)
    if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
    if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
    if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
    if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
    // Lightweight tech mobile page: /t/{token}[/action]
    if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path)
    if (path.startsWith('/diag/')) return require('./lib/client-diag').route(req, res, method, path) // diagnostic client libre-service (token signé)
    if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
    if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path)
    if (path.startsWith('/api/referral/')) return require('./lib/referral').handle(req, res, method, path)
    // iCal token: /dispatch/ical-token/TECH-001 (auth required — returns token for building URL)
    const icalTokenMatch = path.match(/^\/dispatch\/ical-token\/(.+)$/)
    if (icalTokenMatch && method === 'GET') {
      const techId = icalTokenMatch[1]
      const token = ical.generateToken(techId)
      return json(res, 200, { techId, token, url: `/dispatch/calendar/${techId}.ics?token=${token}` })
    }
    // iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO)
    const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
    if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
    if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
    if (path.startsWith('/gmail')) return require('./lib/gmail').handle(req, res, method, path, url)
    if (path.startsWith('/supplier-invoices')) return require('./lib/supplier-invoices').handle(req, res, method, path, url)
    if (path.startsWith('/collab')) return require('./lib/ticket-collab').handle(req, res, method, path, url)
    if (path.startsWith('/staff-agent')) return require('./lib/staff-agent').handle(req, res, method, path) // copilote OPS langage naturel (function-calling) — plan/confirm/run
    if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
    if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
    // Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
    // /reports handler so the /reports/legacy/* prefix isn't swallowed.
    if (path.startsWith('/reports/legacy')) return require('./lib/legacy-reports').handle(req, res, method, path, url)
    if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
    // Per-address competitor/provider lookup via Québec IHV open data (ADR+FRN).
    if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path)
    // Admin view of ERPNext outbound Email Queue (view/delete/purge).
    if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url)
    // Planification (Roster AI) — modèles de shifts, génération via solveur OR-Tools, pause/vacances.
    // Copilote roster (Gemini Flash) + politique de reprise — avant le catch-all /roster
    if (path === '/roster/assistant' || path === '/roster/policy') return require('./lib/roster-assistant').handle(req, res, method, path)
    if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
    // Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
    if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
    // App technicien — capture passive du temps (checkpoints GPS/scan), token signé par job, PUBLIC (pas de SSO).
    if (path === '/field' || path.startsWith('/field/')) return require('./lib/roster').handleFieldTech(req, res, method, path, url)
    // Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
    if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
    // Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.
    if (path === '/store' || path.startsWith('/store/')) return require('./lib/store').handle(req, res, method, path)
    if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
    // Gift redirect wrapper — short public URLs in campaign emails that
    // 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
    if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
    if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url)
    if (path.startsWith('/rsvp') || path.startsWith('/events')) return require('./lib/events').handle(req, res, method, path, url)
    if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
    if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
    if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
    if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)
    if (path === '/vision/invoice'   && method === 'POST') return vision.handleInvoice(req, res)
    if (path === '/vision/payment'   && method === 'POST') return vision.handlePayment(req, res)
    if (path.startsWith('/ai/')) return require('./lib/ai').handle(req, res, method, path)
    if (path.startsWith('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
    if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
    if (path.startsWith('/agent/')) return require('./lib/agent').handleAgentApi(req, res, method, path)
    if (path.startsWith('/flow/templates')) return require('./lib/flow-templates').handle(req, res, method, path, url)
    if (path.startsWith('/flow/runs') || path === '/flow/start' || path === '/flow/instantiate' || path === '/flow/advance' || path === '/flow/complete' || path === '/flow/event') {
      return require('./lib/flow-api').handle(req, res, method, path, url)
    }

    if (path.startsWith('/c/') && method === 'GET') {
      const fs = require('fs')
      const chatPath = require('path').join(__dirname, 'public', 'chat.html')
      try {
        const html = fs.readFileSync(chatPath, 'utf8')
        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
        return res.end(html)
      } catch { return json(res, 404, { error: 'Chat page not found' }) }
    }

    if (path.startsWith('/olt')) {
      try {
        const oltSnmp = require('./lib/olt-snmp')
        const oltParts = path.replace('/olt', '').split('/').filter(Boolean)

        if ((!oltParts.length || oltParts[0] === 'stats') && method === 'GET') {
          return json(res, 200, { olts: oltSnmp.getOltStats() })
        }
        if (oltParts[0] === 'onus' && method === 'GET') {
          const serial = url.searchParams.get('serial')
          if (serial) return json(res, 200, oltSnmp.getOnuBySerial(serial) || { error: 'ONU not found' })
          return json(res, 200, { onus: oltSnmp.getAllOnus() })
        }
        if (oltParts[0] === 'poll' && method === 'POST') {
          oltSnmp.pollAllOlts().catch(e => log('Manual OLT poll error:', e.message))
          return json(res, 200, { ok: true, message: 'OLT poll triggered' })
        }
        if ((oltParts[0] === 'register' || oltParts[0] === 'config') && method === 'POST') {
          const body = await parseBody(req)
          if (!body.host || !body.community) return json(res, 400, { error: 'host and community required' })
          oltSnmp.registerOlt(body)
          return json(res, 200, { ok: true })
        }
        // ── Écritures cycle-de-vie ONU (G2, tech-3 via n8n) — plan(=lecture seule) / run(=fire, gardé) ──
        if (oltParts[0] === 'onu') {
          const oltOps = require('./lib/olt-ops')
          if (oltParts[1] === 'plan' && method === 'GET') {
            const q = url.searchParams
            return json(res, 200, await oltOps.plan({ serial: q.get('serial') || '', action: q.get('action') || '', olt: q.get('olt') || undefined, new_sn: q.get('new_sn') || undefined, profileid: q.get('profileid') || undefined }))
          }
          if (oltParts[1] === 'run' && method === 'POST') {
            const body = await parseBody(req)
            return json(res, 200, await oltOps.run({ ...body, actor: req.headers['x-authentik-email'] || body.actor || '' }))
          }
          return json(res, 404, { error: 'OLT ONU endpoint not found' })
        }
        if (oltParts[0] === 'wifi-clients' && method === 'GET') {
          return json(res, 200, await require('./lib/olt-ops').wifiClients({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '' }))
        }
        return json(res, 404, { error: 'OLT endpoint not found' })
      } catch (e) {
        return json(res, 500, { error: 'OLT module error: ' + e.message })
      }
    }

    json(res, 404, { error: 'Not found' })
  } catch (e) {
    log('ERROR:', e.message)
    json(res, 500, { error: 'Internal error' })
  }
})

// WebSocket upgrade for Twilio Media Streams
server.on('upgrade', (req, socket, head) => {
  const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
  if (url.pathname === '/voice/ws' && voiceAgent) {
    const { WebSocketServer } = require('ws')
    const wss = new WebSocketServer({ noServer: true })
    wss.handleUpgrade(req, socket, head, (ws) => voiceAgent.handleMediaStream(ws, req))
  } else {
    socket.destroy()
  }
})

server.listen(cfg.PORT, '0.0.0.0', () => {
  log(`targo-hub listening on :${cfg.PORT}`)
  if (voiceAgent) log('Voice agent: enabled')
  pbx.startPoller()
  devices.startPoller()
  try { require('./lib/olt-snmp').startOltPoller() }
  catch (e) { log('OLT SNMP poller failed to start:', e.message) }
  // Tampon d'envoi sortant (undo-send des notifs de file) : réarme les envois retenus après redémarrage
  try { require('./lib/outbox').startup() }
  catch (e) { log('outbox startup failed:', e.message) }
  if (oktopusMqtt) oktopusMqtt.start()
  else log('Oktopus MQTT monitor: skipped (disabled)')
  // Cron PPA (auto-paiement carte) — DÉSACTIVÉ par défaut. Politique : scheduler en pause jusqu'au cutover, F autoritaire pour la facturation.
  // A causé des charges multiples (pas d'idempotence Stripe + le sync F→ERPNext remet le solde dû → recharge). Ne réactiver (PPA_CRON_ENABLED=on) qu'APRÈS le correctif d'idempotence.
  if (String(process.env.PPA_CRON_ENABLED || '').toLowerCase() === 'on') {
    try { require('./lib/payments').startPPACron() }
    catch (e) { log('PPA cron failed to start:', e.message) }
  } else { log('PPA cron DÉSACTIVÉ (PPA_CRON_ENABLED!=on) — auto-paiement carte coupé') }
  // Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
  try { require('./lib/legacy-dispatch-sync').startSync() }
  catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
  // Matérialisation des quarts récurrents (weekly_schedule → Shift Assignment) sur horizon glissant 6 sem. Désactivable SHIFT_MATERIALIZE_CRON=off.
  try { require('./lib/roster').startShiftMaterializer() }
  catch (e) { log('shift materializer failed to start:', e.message) }
  // Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
  try { require('./lib/gmail').start() }
  catch (e) { log('gmail ingest failed to start:', e.message) }
  // Factures fournisseurs : courriels to:factures@ (via cc@) → OCR → candidats à revoir (Purchase Invoice draft)
  try { require('./lib/supplier-invoices').start() }
  catch (e) { log('supplier-invoices failed to start:', e.message) }
  // Scan SLA Chatwoot (émulation EE « SLA ») : convs sans 1re réponse au-delà de la cible → label + note. Opt-in CHATWOOT_SLA_SCAN=on
  try { require('./lib/chatwoot').startSlaScan() }
  catch (e) { log('chatwoot SLA scan failed to start:', e.message) }
  // Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on
  try { require('./lib/campaigns').startGiftReclaimCron() }
  catch (e) { log('gift reclaim cron failed to start:', e.message) }
  // Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off
  try { require('./lib/geofence').startScan() }
  catch (e) { log('geofence scan failed to start:', e.message) }
})
