'use strict'
// ══════════════════════════════════════════════════════════════════════════════
// olt-ops.js — ÉCRITURES cycle-de-vie ONU (activate / replace / speed / suspend /
// remove) pour la fibre TP-Link tech-3 (XGS-PON), en enveloppant les MÊMES verbes
// n8n que F (`/webhook/dostuff`, le VERBE HTTP = l'action). C'est le port G2 du
// plan « Do Stuff » : OPS lisait déjà tout (olt-snmp) mais n'écrivait RIEN à l'OLT.
//
// SÉCURITÉ (leçons PPA + F sans idempotence) :
//  • plan()  = LECTURE SEULE — résout le contexte (OLT/slot/port/profil depuis
//    Service Equipment), retourne le payload EXACT + effets + avertissements. 0 réseau.
//  • run()   = fire le verbe n8n PUIS miroir ERPNext. Exige confirm:true + une
//    idempotency-key ; re-jouer la MÊME clé ne re-tire PAS (cache /app/data/olt_ops.json).
//  • tech-2 (Raisecom, RCMG) = SNMP direct côté F → n8n renvoie « OLT non reconnue » ;
//    plan() le signale (écriture SNMP native = G3, pas encore branchée) et refuse run().
// ══════════════════════════════════════════════════════════════════════════════
const fs = require('fs')
const erp = require('./erp')
const { log } = require('./helpers')

const N8N_DOSTUFF = 'https://n8napi.targo.ca/webhook/dostuff'
const STORE = '/app/data/olt_ops.json'
const IDEMP_WINDOW_MS = 10 * 60 * 1000 // rejouer la même clé dans 10 min = même résultat (pas de 2e tir)
// Communauté SNMP EN ÉCRITURE (Raisecom tech-2). Secret → env seulement, JAMAIS bundlé. Absente ⇒ run() tech-2 refuse.
const OLT_RW_COMMUNITY = process.env.OLT_RW_COMMUNITY || ''
const OLT_RO_COMMUNITY = process.env.OLT_RO_COMMUNITY || 'targosnmp'

// OIDs Raisecom (MIB 8886) — EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source, pas reconstruits) :
//  reboot   1.3.6.1.4.1.8886.18.3.6.1.1.1.23.{olt_id}.4 = i 1        (olt_id = slot*1e7+port*1e5+ontid)
//  vlan     1.3.6.1.4.1.8886.18.3.6.6.1.1.7.{olt_id}.4  = i 40|666   (40=actif, 666=suspendu)
//  profile  1.3.6.1.4.1.8886.18.3.1.3.1.1.6.{onu_id}    = i profil   (onu_id = (slot+32)*8388608+port*65536+ontid)
//  save     1.3.6.1.4.1.8886.1.2.1.1.0                  = i 2        (APRÈS chaque set)
const RC = {
  REBOOT: (oltId) => `1.3.6.1.4.1.8886.18.3.6.1.1.1.23.${oltId}.4`,
  VLAN: (oltId) => `1.3.6.1.4.1.8886.18.3.6.6.1.1.7.${oltId}.4`,
  PROFILE: (onuId) => `1.3.6.1.4.1.8886.18.3.1.3.1.1.6.${onuId}`,
  SAVE: '1.3.6.1.4.1.8886.1.2.1.1.0',
  VLAN_ACTIVE: 40, VLAN_SUSPEND: 666,
}
const rcOltId = (s, p, o) => Number(s) * 10000000 + Number(p) * 100000 + Number(o)
const rcOnuId = (s, p, o) => (Number(s) + 32) * 8388608 + Number(p) * 65536 + Number(o)

// action → verbe n8n (tech-3), champs requis, techs supportées, effet (affiché dans le plan).
// tech-3 = n8n ; tech-2 = SNMP natif (G3).
const ACTIONS = {
  activate: { verb: 'POST', label: 'Activer / provisionner l’ONU', needs: ['sn', 'olt', 'profileid'], techs: [3], effect: 'Provisionne l’ONU sur l’OLT + met Service Equipment « Actif ».' },
  replace: { verb: 'PUT', label: 'Remplacer l’ONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'], techs: [3], effect: 'Ré-authentifie la nouvelle série sur l’OLT + met à jour le numéro de série de Service Equipment.' },
  speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' }, techs: [3, 2], effect: 'Change le profil de ligne (l’ONU redémarre).' },
  suspend: { verb: 'PATCH', label: 'Suspendre l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' }, techs: [3, 2], effect: 'Bascule le VLAN internet → coupe l’accès (facturation inchangée).' },
  unsuspend: { verb: 'PATCH', label: 'Rétablir l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' }, techs: [3, 2], effect: 'Rétablit le VLAN internet.' },
  remove: { verb: 'DELETE', label: 'Retirer / désactiver l’ONU', needs: ['sn', 'olt'], techs: [3], effect: 'Retire l’ONU de l’OLT + délie Service Equipment.' },
  reboot: { verb: null, label: 'Redémarrer l’ONU', needs: ['sn', 'olt'], techs: [2], effect: 'Redémarre l’ONU.' }, // tech-3 : utiliser le reboot TR-069
}

// SNMP SET Raisecom (tech-2) : applique l'action + SAVE, via net-snmp. Renvoie {ok,error}. LECTURE : voir olt-snmp.
function snmpRaisecom (action, { host, slot, port, ontid, profileid } = {}) {
  return new Promise((resolve) => {
    if (!OLT_RW_COMMUNITY) return resolve({ ok: false, error: 'OLT_RW_COMMUNITY non configurée (env) — écriture SNMP tech-2 désactivée' })
    if (host == null || slot == null || port == null || ontid == null) return resolve({ ok: false, error: 'slot/port/ontid/olt requis' })
    let snmp; try { snmp = require('net-snmp') } catch (e) { return resolve({ ok: false, error: 'net-snmp indisponible' }) }
    const oltId = rcOltId(slot, port, ontid); const onuId = rcOnuId(slot, port, ontid)
    let vb
    if (action === 'suspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_SUSPEND }
    else if (action === 'unsuspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_ACTIVE }
    else if (action === 'reboot') vb = { oid: RC.REBOOT(oltId), type: snmp.ObjectType.Integer, value: 1 }
    else if (action === 'speed') { const pr = parseInt(profileid, 10); if (!pr) return resolve({ ok: false, error: 'profileid (n° profil de ligne) requis pour le changement de vitesse' }); vb = { oid: RC.PROFILE(onuId), type: snmp.ObjectType.Integer, value: pr } }
    else return resolve({ ok: false, error: 'action SNMP tech-2 non supportée: ' + action })
    const session = snmp.createSession(String(host), OLT_RW_COMMUNITY, { timeout: 6000, retries: 1, version: snmp.Version2c })
    session.set([vb], (err) => {
      if (err) { try { session.close() } catch (e) {} return resolve({ ok: false, error: 'SNMP set: ' + err.message }) }
      // SAVE obligatoire après un set (sinon revert silencieux) — sauf reboot (F ne sauve pas après un reboot).
      if (action === 'reboot') { try { session.close() } catch (e) {} return resolve({ ok: true, oid: vb.oid }) }
      session.set([{ oid: RC.SAVE, type: snmp.ObjectType.Integer, value: 2 }], (err2) => {
        try { session.close() } catch (e) {}
        if (err2) return resolve({ ok: false, error: 'SNMP save: ' + err2.message, warn: 'set appliqué mais save échoué — peut se réverter' })
        resolve({ ok: true, oid: vb.oid, saved: true })
      })
    })
  })
}

function readStore () { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch (e) { return {} } }
function writeStore (o) { try { fs.writeFileSync(STORE, JSON.stringify(o)) } catch (e) { log('olt-ops store write: ' + e.message) } }

// tech-3 (TP-Link XGS-PON) = n8n ; tech-2 (Raisecom) = SNMP direct côté F (pas n8n).
function techOf (serial, equip) {
  const s = String(serial || (equip && equip.serial_number) || '').toUpperCase()
  if (equip && equip.fibre_tech) return Number(equip.fibre_tech)
  if (s.startsWith('TPLG')) return 3
  if (s.startsWith('RCMG')) return 2
  return null // inconnu
}

// Résout le contexte réseau d'un ONU depuis Service Equipment (miroir de la table `fibre` de F).
async function resolveEquip (serial) {
  // ⚠️ Ne PAS demander fibre_line_profile/fibre_service_profile ici : ce ne sont PAS des DocFields v16
  // (erp.list les strippe → bruit + latence). Le profil (activate/speed) est fourni par l'appelant
  // (l'UI le collecte) ; suspend/unsuspend/remove n'en ont pas besoin.
  const rows = await erp.list('Service Equipment', {
    filters: [['serial_number', '=', String(serial)]],
    fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'],
    limit: 1,
  }).catch(() => [])
  const equip = (rows && rows[0]) || null
  // Repli coordonnées ONU depuis le poller SNMP live (olt-snmp) : les coords Raisecom (tech-2) ne sont PAS mirorées
  // dans Service Equipment (elles vivent dans la table `fibre` de F) → sans ça, l'écriture SNMP tech-2 est impossible.
  if (!equip || equip.olt_ip == null || equip.olt_slot == null || equip.olt_ontid == null) {
    try {
      const onu = require('./olt-snmp').getOnuBySerial(String(serial))
      if (onu) {
        const merged = equip || { serial_number: String(serial) }
        if (merged.olt_ip == null) merged.olt_ip = onu.oltHost || null
        if (merged.olt_slot == null) merged.olt_slot = (onu.slot != null ? onu.slot : null)
        if (merged.olt_port == null) merged.olt_port = (onu.port != null ? onu.port : null)
        if (merged.olt_ontid == null) merged.olt_ontid = (onu.ontId != null ? onu.ontId : (onu.ontid != null ? onu.ontid : null))
        merged._coords_from_snmp = true
        return merged
      }
    } catch (e) { /* poller indispo → on garde equip tel quel */ }
  }
  return equip
}

// Requête n8n : VERBE HTTP arbitraire + body JSON via le module https (fetch refuse un body en GET). Renvoie le
// JSON brut. Base commune de dostuff() et wifiClients() (F fait pareil).
function n8nRequest (verb, targetUrl, body, timeoutMs) {
  return new Promise((resolve) => {
    let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
    const payload = JSON.stringify(body)
    const req = https.request(targetUrl, { method: verb, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, timeout: timeoutMs }, (res) => {
      let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
        let parsed; try { parsed = JSON.parse(d) } catch (e) { parsed = d }
        resolve({ ok: res.statusCode < 400, status: res.statusCode, data: parsed })
      })
    })
    req.on('error', e => resolve({ ok: false, error: e.message }))
    req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai n8n dépassé (~' + Math.round(timeoutMs / 1000) + ' s)' }) })
    req.write(payload); req.end()
  })
}

// dostuff : verbe = action ; « OLT non reconnue » (ErrorCode) = tech ≠ 3 → non reconnu.
async function dostuff (verb, payload, timeoutMs = 60000) {
  const res = await n8nRequest(verb, N8N_DOSTUFF, payload, timeoutMs)
  if (res.error) return { ok: false, error: res.error }
  const r = Array.isArray(res.data) ? res.data[0] : res.data
  const recognized = !(r && r.ErrorCode)
  return { ok: recognized && res.ok, status: res.status, recognized, error: (r && r.ErrorCode) || null, data: r }
}

// Construit le payload n8n EXACT pour une action, à partir du contexte résolu + overrides.
function buildPayload (action, equip, opts = {}) {
  const spec = ACTIONS[action]
  const olt = opts.olt || (equip && equip.olt_ip) || ''
  const sn = opts.sn || (equip && equip.serial_number) || ''
  const profileid = opts.profileid != null ? opts.profileid : (equip && equip.fibre_line_profile) || ''
  const base = { user: opts.user || 'ops', tel: opts.tel || '' }
  let p
  if (action === 'replace') p = { old_sn: opts.old_sn || sn, new_sn: opts.new_sn || '', olt, ...base }
  else p = { sn, olt, ...base }
  if (spec.needs.includes('profileid') || action === 'speed') p.profileid = profileid
  if (action === 'activate') { p.profileid = profileid; p.clientid = opts.clientid || (equip && equip.customer) || '' }
  if (spec.extra) Object.assign(p, spec.extra)
  return p
}

// PLAN — LECTURE SEULE. Résout le contexte, retourne le payload + effets + avertissements. Aucun appel réseau.
async function plan ({ serial, action, ...opts } = {}) {
  const spec = ACTIONS[action]
  if (!spec) return { ok: false, error: 'action inconnue: ' + action + ' (activate|replace|speed|suspend|unsuspend|remove)' }
  const equip = await resolveEquip(opts.old_sn || serial)
  const tech = techOf(opts.old_sn || serial, equip)
  const payload = buildPayload(action, equip, { ...opts, sn: serial, old_sn: opts.old_sn || serial })
  const method = tech === 2 ? 'snmp' : 'n8n' // tech-2 Raisecom = SNMP natif ; tech-3 TP-Link = n8n
  const supportsTech = tech != null && Array.isArray(spec.techs) && spec.techs.includes(tech)
  const warnings = []
  if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — l’OLT/profil doivent être fournis manuellement.')
  if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant d’exécuter.')
  else if (!supportsTech) warnings.push('« ' + spec.label + ' » n’est pas supporté pour tech-' + tech + (tech === 3 ? ' via ce module (reboot = utiliser TR-069).' : ' (activate/replace/remove Raisecom = script CLI, non branché).'))
  if (!payload.olt) warnings.push('IP OLT manquante — requise.')
  // tech-2 : besoin des coordonnées ONU (slot/port/ontid) résolues + communauté RW configurée
  let snmpReady = true
  if (method === 'snmp') {
    const haveCoords = equip && equip.olt_slot != null && equip.olt_port != null && equip.olt_ontid != null
    if (!haveCoords) { warnings.push('Coordonnées ONU (slot/port/ontid) introuvables sur Service Equipment — requises pour l’écriture SNMP.'); snmpReady = false }
    if (!OLT_RW_COMMUNITY) { warnings.push('Communauté SNMP en écriture non configurée (env OLT_RW_COMMUNITY) → exécution SNMP désactivée.'); snmpReady = false }
    if (action === 'speed' && !(opts.profileid || (equip && equip.fibre_line_profile))) { warnings.push('Profil de ligne (profileid) requis pour le changement de vitesse.'); snmpReady = false }
  }
  const missing = method === 'n8n' ? spec.needs.filter(k => !payload[k] && payload[k] !== 0) : []
  if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
  const canRun = supportsTech && !!payload.olt && (method === 'n8n' ? (tech === 3 && !missing.length) : snmpReady)
  const effects = spec.effect ? [spec.effect] : []
  return {
    ok: canRun,
    action, label: spec.label, verb: spec.verb, tech, method,
    target: { serial: serial || payload.sn, olt: payload.olt, slot: equip && equip.olt_slot, port: equip && equip.olt_port, ontid: equip && equip.olt_ontid, profile: payload.profileid || (equip && equip.fibre_line_profile) || '' },
    equipment: equip ? equip.name : null, customer: equip && equip.customer, service_location: equip && equip.service_location,
    payload: method === 'n8n' ? payload : { olt: payload.olt, action, snmp: 'MIB 8886 (voir olt-ops.js)' }, effects, warnings,
    can_run: canRun,
  }
}

// RUN — fire le verbe n8n PUIS miroir ERPNext. Idempotent par clé. confirm:true obligatoire.
async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts } = {}) {
  const spec = ACTIONS[action]
  if (!spec) return { ok: false, error: 'action inconnue' }
  if (!confirm) return { ok: false, error: 'confirm:true requis (action réseau)' }
  const pl = await plan({ serial, action, ...opts })
  if (!pl.can_run) return { ok: false, error: 'plan non exécutable', plan: pl }
  // Idempotence : même clé rejouée dans la fenêtre → renvoyer le résultat mémorisé (NE PAS re-tirer).
  const key = String(idempotencyKey || '')
  if (!key) return { ok: false, error: 'idempotencyKey requis' }
  const store = readStore()
  const prev = store[key]
  if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result }
  // FIRE — tech-3 = n8n dostuff ; tech-2 = SNMP set Raisecom (+ save).
  let net
  if (pl.method === 'snmp') {
    net = await snmpRaisecom(action, { host: pl.target.olt, slot: pl.target.slot, port: pl.target.port, ontid: pl.target.ontid, profileid: opts.profileid || (pl.target.profile) })
  } else {
    net = await dostuff(spec.verb, pl.payload)
  }
  const result = { action, serial: pl.target.serial, olt: pl.target.olt, method: pl.method, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
  if (net.ok && pl.equipment) {
    // Miroir ERPNext (fill-only, best-effort) — reflète l'effet sur Service Equipment.
    try {
      if (action === 'activate' || action === 'unsuspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Actif' }); result.mirrored.push('status=Actif') }
      else if (action === 'suspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Suspendu' }); result.mirrored.push('status=Suspendu') }
      else if (action === 'remove') { await erp.update('Service Equipment', pl.equipment, { status: 'Retiré' }); result.mirrored.push('status=Retiré') }
      else if (action === 'replace' && opts.new_sn) { await erp.update('Service Equipment', pl.equipment, { serial_number: opts.new_sn }); result.mirrored.push('serial=' + opts.new_sn) }
    } catch (e) { result.mirror_error = e.message }
  }
  store[key] = { at: Date.now(), action, actor: actor || '', result }
  writeStore(store)
  log('[olt-ops] ' + action + ' ' + pl.target.serial + ' → net_ok=' + result.net_ok + (result.net_error ? ' (' + result.net_error + ')' : '') + ' actor=' + (actor || '?'))
  return { ok: !!net.ok, ...result }
}

// G1 reste : clients WiFi connectés d'un ONU (n8n get_wifi_info) — LECTURE.
async function wifiClients ({ sn } = {}) {
  if (!sn) return { ok: false, error: 'sn requis' }
  const res = await n8nRequest('GET', 'https://n8napi.targo.ca/webhook/get_wifi_info', { sn: String(sn) }, 20000)
  if (res.error) return { ok: false, error: res.error }
  const j = res.data
  return { ok: !!j, clients: Array.isArray(j) ? j : (j && j.clients) || [], raw: j }
}

module.exports = { plan, run, wifiClients, ACTIONS }
