/**
 * ──────────────────────────────────────────────────────────────────────────────
 * Built-in ACS Server Service  (TR-069 / CWMP)
 * ──────────────────────────────────────────────────────────────────────────────
 *
 * Handles CWMP SOAP communication with CPE devices (ONUs / routers) directly
 * within the billing application – no external GenieACS required.
 *
 * Protocol flow:
 *   1. CPE  → POST Inform        → ACS responds InformResponse
 *   2. CPE  → POST Empty         → ACS sends queued task  (or 204)
 *   3. CPE  → POST TaskResponse  → ACS marks task, sends next or 204
 *
 * Exports:
 *   handleCwmpRequest        – Express handler for POST /acs
 *   triggerConnectionRequest – Kick a CPE to reconnect
 *   getBuiltinDevices        – Query acs_devices
 *   getBuiltinDevice         – Single device lookup
 *   createBuiltinTask        – Insert task + trigger CR
 */

'use strict';

const http = require('http');
const https = require('https');
const crypto = require('crypto');
const db = require('../config/database');
const { logger } = require('../config/logger');
const { getSetting, getNowLocal } = require('../config/settingsManager');

// Versioned bootstrap migration — only runs once per version change
try {
  const BOOTSTRAP_VERSION = 4; // v4: Added CMCC, CT-COM, CU, FH, ZTE-COM vendor RX Power paths
  const migRow = db.prepare("SELECT value FROM app_settings WHERE key = 'acs_bootstrap_version'").get();
  const currentVersion = migRow ? parseInt(migRow.value, 10) : 0;
  if (currentVersion < BOOTSTRAP_VERSION) {
    const devices = db.prepare('SELECT id, tags FROM acs_devices').all();
    for (const dev of devices) {
      let tags = [];
      try { tags = JSON.parse(dev.tags || '[]'); } catch (_) {}
      if (tags.includes('bootstrapped')) {
        tags = tags.filter(t => t !== 'bootstrapped');
        db.prepare('UPDATE acs_devices SET tags = ? WHERE id = ?').run(JSON.stringify(tags), dev.id);
      }
    }
    db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('acs_bootstrap_version', ?)").run(String(BOOTSTRAP_VERSION));
    logger.info(`[ACS] Bootstrap migration v${BOOTSTRAP_VERSION} completed — cleared bootstrapped tags for re-bootstrap`);
  }
} catch (err) {
  logger.error(`[ACS] Failed bootstrap migration: ${err.message}`);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  SOAP NAMESPACES & CONSTANTS
// ═══════════════════════════════════════════════════════════════════════════════

const SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/';
const CWMP_NS = 'urn:dslforum-org:cwmp-1-0';
const XSD_NS  = 'http://www.w3.org/2001/XMLSchema';
const XSI_NS  = 'http://www.w3.org/2001/XMLSchema-instance';
const SOAP_ENC_NS = 'http://schemas.xmlsoap.org/soap/encoding/';

const SESSION_TIMEOUT_MS = 120_000; // 120 seconds

// ═══════════════════════════════════════════════════════════════════════════════
//  IN-MEMORY SESSION STORE
// ═══════════════════════════════════════════════════════════════════════════════

/** @type {Map<string, {deviceId:string, step:string, currentTaskId:number|null, lastActivity:number}>} */
const sessions = new Map();

/** Periodically clean expired sessions (every 60 s) */
setInterval(() => {
  const now = Date.now();
  for (const [sid, sess] of sessions) {
    if (now - sess.lastActivity > SESSION_TIMEOUT_MS) {
      sessions.delete(sid);
    }
  }
}, 60_000);

// Cleanup stale lastTriggerTimes entries every 5 minutes
setInterval(() => {
  const cutoff = Date.now() - 5 * 60 * 1000;
  for (const [deviceId, ts] of lastTriggerTimes) {
    if (ts < cutoff) lastTriggerTimes.delete(deviceId);
  }
}, 5 * 60 * 1000);

function generateSessionId() {
  return crypto.randomBytes(16).toString('hex');
}

function getOrCreateSession(req, res) {
  // Try to read existing session from cookie
  const cookieHeader = req.headers.cookie || '';
  const match = cookieHeader.match(/(?:^|;\s*)acs_session=([^;]+)/);
  let sid = match ? match[1] : null;

  if (sid && sessions.has(sid)) {
    const sess = sessions.get(sid);
    sess.lastActivity = Date.now();
    return { sid, session: sess, isNew: false };
  }

  // Create new session
  sid = generateSessionId();
  const session = { deviceId: null, step: null, currentTaskId: null, lastActivity: Date.now() };
  sessions.set(sid, session);
  res.setHeader('Set-Cookie', `acs_session=${sid}; Path=/acs; HttpOnly`);
  return { sid, session, isNew: true };
}

// ═══════════════════════════════════════════════════════════════════════════════
//  TIMESTAMP HELPERS
// ═══════════════════════════════════════════════════════════════════════════════

function nowLocal() {
  // Use ISO 8601 UTC format for ACS timestamps.
  // This ensures correct Date.now() comparison regardless of server timezone.
  // getNowLocal() returns local time WITHOUT timezone offset (e.g. "2026-06-08 11:49:50"),
  // which causes incorrect parsing on servers with UTC system timezone.
  return new Date().toISOString();
}

// ═══════════════════════════════════════════════════════════════════════════════
//  REGEX-BASED SOAP / XML PARSING
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Extract the text content of a simple XML element.
 * Handles namespace-prefixed elements like <ns:Tag> and <Tag>.
 */
function xmlValue(xml, tag) {
  // Match both <tag>value</tag> and <ns:tag>value</ns:tag>
  const re = new RegExp(`<(?:[\\w-]+:)?${tag}[^>]*>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${tag}>`, 'i');
  const m = xml.match(re);
  return m ? m[1].trim() : '';
}

/**
 * Detect whether the SOAP body contains a specific CWMP method.
 * E.g. hasCwmpMethod(xml, 'Inform') => true if <cwmp:Inform> or <Inform xmlns=...>
 */
function hasCwmpMethod(xml, method) {
  // <cwmp:Method> or <ns123:Method> or <Method xmlns="urn:dslforum-org:cwmp-1-0">
  const re = new RegExp(`<(?:[\\w-]+:)?${method}[\\s>]`, 'i');
  return re.test(xml);
}

/**
 * Extract CWMP ID from the SOAP header.
 */
function extractCwmpId(xml) {
  // <cwmp:ID ...>value</cwmp:ID> or <ID ...>value</ID>
  const m = xml.match(/<(?:[\w-]+:)?ID[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?ID>/i);
  return m ? m[1].trim() : '1';
}

/**
 * Parse <DeviceId> block from an Inform message.
 */
function parseDeviceId(xml) {
  const deviceIdBlock = xmlValue(xml, 'DeviceId');
  if (!deviceIdBlock) return null;
  return {
    Manufacturer: xmlValue(deviceIdBlock, 'Manufacturer'),
    OUI: xmlValue(deviceIdBlock, 'OUI'),
    SerialNumber: xmlValue(deviceIdBlock, 'SerialNumber'),
    ProductClass: xmlValue(deviceIdBlock, 'ProductClass'),
  };
}

/**
 * Parse all <ParameterValueStruct> entries.
 * Returns a flat object: { 'Device.Path.Name': 'value', ... }
 */
function parseParameterValues(xml) {
  const params = {};
  // Match each ParameterValueStruct block
  const structRe = /<(?:[\w-]+:)?ParameterValueStruct>([\s\S]*?)<\/(?:[\w-]+:)?ParameterValueStruct>/gi;
  let m;
  while ((m = structRe.exec(xml)) !== null) {
    const block = m[1];
    const name = xmlValue(block, 'Name');
    const value = xmlValue(block, 'Value');
    if (name) {
      params[name] = value;
    }
  }
  return params;
}

/**
 * Parse GetParameterValuesResponse – same structure as Inform parameter list.
 */
function parseGetParameterValuesResponse(xml) {
  return parseParameterValues(xml);
}

/**
 * Parse AddObjectResponse – returns InstanceNumber and Status.
 */
function parseAddObjectResponse(xml) {
  return {
    instanceNumber: xmlValue(xml, 'InstanceNumber'),
    status: xmlValue(xml, 'Status'),
  };
}

/**
 * Parse SetParameterValuesResponse – returns Status (0 = success).
 */
function parseSetParameterValuesResponseStatus(xml) {
  return xmlValue(xml, 'Status') || '0';
}

/**
 * Parse FaultCode / FaultString from a SOAP Fault.
 */
function parseFault(xml) {
  if (!/<(?:[\w-]+:)?Fault/i.test(xml)) return null;
  return {
    faultCode: xmlValue(xml, 'FaultCode') || xmlValue(xml, 'faultcode'),
    faultString: xmlValue(xml, 'FaultString') || xmlValue(xml, 'faultstring'),
    detail: xmlValue(xml, 'detail') || xmlValue(xml, 'Detail'),
  };
}

// ═══════════════════════════════════════════════════════════════════════════════
//  SOAP RESPONSE BUILDERS
// ═══════════════════════════════════════════════════════════════════════════════

function soapEnvelopeWrap(cwmpId, bodyContent) {
  return `<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope
  xmlns:soap="${SOAP_NS}"
  xmlns:cwmp="${CWMP_NS}"
  xmlns:xsd="${XSD_NS}"
  xmlns:xsi="${XSI_NS}"
  xmlns:soap-enc="${SOAP_ENC_NS}">
  <soap:Header>
    <cwmp:ID soap:mustUnderstand="1">${cwmpId}</cwmp:ID>
  </soap:Header>
  <soap:Body>
    ${bodyContent}
  </soap:Body>
</soap:Envelope>`;
}

function buildInformResponse(cwmpId) {
  return soapEnvelopeWrap(cwmpId,
    `<cwmp:InformResponse>
      <MaxEnvelopes>1</MaxEnvelopes>
    </cwmp:InformResponse>`
  );
}

function buildReboot(cwmpId) {
  const cmdKey = `reboot-${Date.now()}`;
  return soapEnvelopeWrap(cwmpId,
    `<cwmp:Reboot>
      <CommandKey>${cmdKey}</CommandKey>
    </cwmp:Reboot>`
  );
}

function buildFactoryReset(cwmpId) {
  return soapEnvelopeWrap(cwmpId,
    `<cwmp:FactoryReset></cwmp:FactoryReset>`
  );
}

/**
 * Build SetParameterValues SOAP envelope.
 * @param {string} cwmpId
 * @param {Array} parameterValues – array of [path, value, type?] or [path, value]
 */
function buildSetParameterValues(cwmpId, parameterValues) {
  const pvList = (parameterValues || []).map(pv => {
    const name = pv[0];
    const value = pv[1];
    const xsdType = pv[2] || 'xsd:string';
    return `        <ParameterValueStruct>
          <Name>${escapeXml(name)}</Name>
          <Value xsi:type="${escapeXml(xsdType)}">${escapeXml(String(value))}</Value>
        </ParameterValueStruct>`;
  }).join('\n');

  const arrayType = `cwmp:ParameterValueStruct[${parameterValues.length}]`;

  return soapEnvelopeWrap(cwmpId,
    `<cwmp:SetParameterValues>
      <ParameterList soap-enc:arrayType="${arrayType}">
${pvList}
      </ParameterList>
      <ParameterKey>${Date.now()}</ParameterKey>
    </cwmp:SetParameterValues>`
  );
}

/**
 * Build GetParameterValues SOAP envelope.
 * @param {string} cwmpId
 * @param {string[]} parameterNames
 */
function buildGetParameterValues(cwmpId, parameterNames) {
  const names = (parameterNames || []).map(n =>
    `        <string>${escapeXml(n)}</string>`
  ).join('\n');

  return soapEnvelopeWrap(cwmpId,
    `<cwmp:GetParameterValues>
      <ParameterNames soap-enc:arrayType="xsd:string[${parameterNames.length}]">
${names}
      </ParameterNames>
    </cwmp:GetParameterValues>`
  );
}

/**
 * Build GetParameterNames for subtree refresh (refreshObject).
 * Actually uses GetParameterValues with the object path ending in "."
 * to get all child parameters.
 */
function buildRefreshObject(cwmpId, objectName) {
  // If objectName is empty or doesn't end with '.', append '.'
  let path = objectName || '';
  if (path && !path.endsWith('.')) path += '.';

  return soapEnvelopeWrap(cwmpId,
    `<cwmp:GetParameterValues>
      <ParameterNames soap-enc:arrayType="xsd:string[1]">
        <string>${escapeXml(path)}</string>
      </ParameterNames>
    </cwmp:GetParameterValues>`
  );
}

/**
 * Build AddObject SOAP envelope.
 * @param {string} cwmpId
 * @param {string} objectName – e.g. "InternetGatewayDevice.WANDevice.1.WANConnectionDevice."
 */
function buildAddObject(cwmpId, objectName) {
  return soapEnvelopeWrap(cwmpId,
    `<cwmp:AddObject>
      <ObjectName>${escapeXml(objectName)}</ObjectName>
      <ParameterKey></ParameterKey>
    </cwmp:AddObject>`
  );
}

function escapeXml(str) {
  if (str == null) return '';
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

// ═══════════════════════════════════════════════════════════════════════════════
//  DATABASE OPERATIONS
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Upsert device from Inform data.
 */
function upsertDevice(deviceId, deviceInfo, params, ipAddress) {
  const now = nowLocal();

  // Extract well-known parameters
  const swVer = params['InternetGatewayDevice.DeviceInfo.SoftwareVersion']
    || params['Device.DeviceInfo.SoftwareVersion']
    || '';
  const hwVer = params['InternetGatewayDevice.DeviceInfo.HardwareVersion']
    || params['Device.DeviceInfo.HardwareVersion']
    || '';

  // Connection request URL – look in multiple possible locations
  const connReqUrl = params['InternetGatewayDevice.ManagementServer.ConnectionRequestURL']
    || params['Device.ManagementServer.ConnectionRequestURL']
    || '';
  const connReqUser = params['InternetGatewayDevice.ManagementServer.ConnectionRequestUsername']
    || params['Device.ManagementServer.ConnectionRequestUsername']
    || '';
  const connReqPass = params['InternetGatewayDevice.ManagementServer.ConnectionRequestPassword']
    || params['Device.ManagementServer.ConnectionRequestPassword']
    || '';

  // Check if device exists
  const existing = db.prepare('SELECT id, params, tags FROM acs_devices WHERE id = ?').get(deviceId);

  if (existing) {
    // Merge existing params with new params (new values overwrite)
    let mergedParams = {};
    try { mergedParams = JSON.parse(existing.params || '{}'); } catch (_) { /* empty */ }
    Object.assign(mergedParams, params);

    db.prepare(`
      UPDATE acs_devices SET
        serial_number = ?,
        manufacturer = ?,
        product_class = ?,
        oui = ?,
        software_version = ?,
        hardware_version = ?,
        ip_address = ?,
        connection_request_url = CASE WHEN ? != '' THEN ? ELSE connection_request_url END,
        connection_request_user = CASE WHEN ? != '' THEN ? ELSE connection_request_user END,
        connection_request_pass = CASE WHEN ? != '' THEN ? ELSE connection_request_pass END,
        params = ?,
        last_inform = ?,
        updated_at = ?
      WHERE id = ?
    `).run(
      deviceInfo.SerialNumber,
      deviceInfo.Manufacturer,
      deviceInfo.ProductClass,
      deviceInfo.OUI,
      swVer,
      hwVer,
      ipAddress,
      connReqUrl, connReqUrl,
      connReqUser, connReqUser,
      connReqPass, connReqPass,
      JSON.stringify(mergedParams),
      now,
      now,
      deviceId
    );
  } else {
    db.prepare(`
      INSERT INTO acs_devices
        (id, serial_number, manufacturer, product_class, oui,
         software_version, hardware_version, ip_address,
         connection_request_url, connection_request_user, connection_request_pass,
         tags, params, last_inform, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?, ?, ?, ?)
    `).run(
      deviceId,
      deviceInfo.SerialNumber,
      deviceInfo.Manufacturer,
      deviceInfo.ProductClass,
      deviceInfo.OUI,
      swVer,
      hwVer,
      ipAddress,
      connReqUrl,
      connReqUser,
      connReqPass,
      JSON.stringify(params),
      now,
      now,
      now
    );
  }
}

/**
 * Merge GetParameterValuesResponse data into a device's params.
 */
function mergeDeviceParams(deviceId, newParams) {
  const now = nowLocal();
  const existing = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(deviceId);
  if (!existing) return;

  let merged = {};
  try { merged = JSON.parse(existing.params || '{}'); } catch (_) { /* empty */ }
  Object.assign(merged, newParams);

  db.prepare('UPDATE acs_devices SET params = ?, updated_at = ? WHERE id = ?')
    .run(JSON.stringify(merged), now, deviceId);
}

/**
 * Check if the device is missing WLAN, WAN, or RX optical power parameters,
 * and if so, queue a getParameterValues task to bootstrap them.
 */
function queueBootstrapTasksIfNeeded(deviceId, currentParams) {
  try {
    // Check if device is already tagged as bootstrapped to avoid infinite loops on unsupported features
    const device = db.prepare('SELECT tags FROM acs_devices WHERE id = ?').get(deviceId);
    if (!device) return;

    let tags = [];
    try { tags = JSON.parse(device.tags || '[]'); } catch (_) {}
    if (tags.includes('bootstrapped')) {
      return;
    }

    // Check if there is already a pending getParameterValues task for this device to prevent duplication
    const pending = db.prepare("SELECT COUNT(*) as c FROM acs_tasks WHERE device_id = ? AND name = 'getParameterValues' AND status = 'pending'").get(deviceId);
    if (pending && pending.c > 0) {
      return;
    }

    // Check if we need to fetch parameters (if they are missing from currentParams)
    const hasWlan = Object.keys(currentParams).some(k => k.toLowerCase().includes('ssid') || k.toLowerCase().includes('keypassphrase'));
    const hasWan = Object.keys(currentParams).some(k => k.toLowerCase().includes('username') || k.toLowerCase().includes('externalipaddress') || k.toLowerCase().includes('pppoe'));
    const hasRx = Object.keys(currentParams).some(k => k.toLowerCase().includes('rxpower') || k.toLowerCase().includes('redaman') || k.toLowerCase().includes('opticalsignallevel'));

    if (!hasWlan || !hasWan || !hasRx) {
      logger.info(`[ACS] Device ${deviceId} is missing key parameters (WLAN:${hasWlan}, WAN:${hasWan}, RX:${hasRx}). Queuing bootstrap parameter fetches.`);
      
      const isTr181 = Object.keys(currentParams).some(k => k.startsWith('Device.'));
      const groups = [];

      if (isTr181) {
        // TR-181 (Modern ONUs) - query as safe individual tasks
        groups.push(['Device.DeviceInfo.ModelName', 'Device.DeviceInfo.SoftwareVersion', 'Device.DeviceInfo.HardwareVersion', 'Device.DeviceInfo.UpTime']);
        groups.push(['Device.WiFi.SSID.1.SSID']);
        groups.push(['Device.WiFi.AccessPoint.1.SSIDReference']);
        groups.push(['Device.WiFi.SSID.2.SSID']);
        groups.push(['Device.WiFi.AccessPoint.2.SSIDReference']);
        groups.push(['Device.PPP.Interface.1.Username']);
        groups.push(['Device.PPP.Interface.1.ExternalIPAddress']);
        groups.push(['Device.XPON.Interface.1.Stats.RXPower']);
        groups.push(['Device.Optical.Interface.1.OpticalSignalLevel']);
        groups.push(['Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries', 'Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries', 'Device.Hosts.HostNumberOfEntries']);
      } else {
        // TR-098 (ZTE, Huawei, etc.) - query as safe individual tasks to prevent single-unsupported-path failure
        // Group 1: Basic Info (guaranteed to succeed)
        groups.push(['InternetGatewayDevice.DeviceInfo.ModelName', 'InternetGatewayDevice.DeviceInfo.SoftwareVersion', 'InternetGatewayDevice.DeviceInfo.HardwareVersion', 'InternetGatewayDevice.DeviceInfo.UpTime']);
        
        // WLAN 2.4G
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey']);
        
        // WLAN 5G (fails on 2.4G-only ONUs, but in its own task it doesn't affect 2.4G)
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.KeyPassphrase']);
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey']);
        
        // WAN / PPPoE (Extended indexes to check all possible interfaces)
        for (let connIdx = 1; connIdx <= 5; connIdx++) {
          for (let pppIdx = 1; pppIdx <= 2; pppIdx++) {
            groups.push([`InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${connIdx}.WANPPPConnection.${pppIdx}.Username`]);
            groups.push([`InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${connIdx}.WANPPPConnection.${pppIdx}.ExternalIPAddress`]);
            groups.push([`InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${connIdx}.WANPPPConnection.${pppIdx}.ConnectionType`]);
            groups.push([`InternetGatewayDevice.WANDevice.1.WANConnectionDevice.${connIdx}.WANPPPConnection.${pppIdx}.Uptime`]);
          }
        }
        
        // Optical RX Power — Standard paths
        groups.push(['InternetGatewayDevice.WANDevice.1.WANPONInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_HW_OpticalSignal.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_GponInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_GponInterfaceConfig.RxPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RxPower']);
        // ZTE vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_ZTE_GponInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_ZTE_GponInterfaceConfig.RxPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_ZTE_OpticalSignal.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.X_ZTE_OpticalSignal.RxPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_ZTE-COM_WANPONInterfaceConfig.RXPower']);
        // Huawei vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_HW_GponInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_HW_GponInterfaceConfig.RxPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANOAM.RXPower']);
        // FiberHome vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_FH_GponInterfaceConfig.RXPower']);
        // China Mobile (CMCC) vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_CMCC_EponInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_CMCC_GponInterfaceConfig.RXPower']);
        // China Telecom (CT) vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_CT-COM_EponInterfaceConfig.RXPower']);
        groups.push(['InternetGatewayDevice.WANDevice.1.X_CT-COM_GponInterfaceConfig.RXPower']);
        // China Unicom (CU) vendor paths
        groups.push(['InternetGatewayDevice.WANDevice.1.X_CU_WANEPONInterfaceConfig.OpticalTransceiver.RXPower']);
        
        // Active associations
        groups.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations', 'InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.TotalAssociations', 'InternetGatewayDevice.LANDevice.1.Hosts.HostNumberOfEntries']);
      }

      // Add 'bootstrapped' tag to prevent infinite bootstrap loops
      if (!tags.includes('bootstrapped')) {
        tags.push('bootstrapped');
        const now = nowLocal();
        db.prepare('UPDATE acs_devices SET tags = ?, updated_at = ? WHERE id = ?')
          .run(JSON.stringify(tags), now, deviceId);
      }

      const now = nowLocal();
      for (const group of groups) {
        const payloadStr = JSON.stringify({ parameterNames: group });
        db.prepare(
          `INSERT INTO acs_tasks (device_id, name, payload, status, created_at, updated_at)
           VALUES (?, 'getParameterValues', ?, 'pending', ?, ?)`
        ).run(deviceId, payloadStr, now, now);
      }

      // Queue task to configure Periodic Inform (300 seconds)
      const informPvs = [];
      if (isTr181) {
        informPvs.push(['Device.ManagementServer.PeriodicInformEnable', 'true', 'xsd:boolean']);
        informPvs.push(['Device.ManagementServer.PeriodicInformInterval', '300', 'xsd:unsignedInt']);
      } else {
        informPvs.push(['InternetGatewayDevice.ManagementServer.PeriodicInformEnable', 'true', 'xsd:boolean']);
        informPvs.push(['InternetGatewayDevice.ManagementServer.PeriodicInformInterval', '300', 'xsd:unsignedInt']);
      }
      db.prepare(
        `INSERT INTO acs_tasks (device_id, name, payload, status, created_at, updated_at)
         VALUES (?, 'setParameterValues', ?, 'pending', ?, ?)`
      ).run(deviceId, JSON.stringify({ parameterValues: informPvs }), now, now);
    }
  } catch (err) {
    logger.error(`[ACS] Error in queueBootstrapTasksIfNeeded for ${deviceId}: ${err.message}`);
  }
}

/**
 * Get the next pending task for a device.
 */
function getNextPendingTask(deviceId) {
  return db.prepare(
    `SELECT * FROM acs_tasks
     WHERE device_id = ? AND status = 'pending'
     ORDER BY id ASC LIMIT 1`
  ).get(deviceId) || null;
}

/**
 * Mark a task as completed.
 */
function completeTask(taskId, result) {
  const now = nowLocal();
  db.prepare(
    `UPDATE acs_tasks SET status = 'completed', result = ?, updated_at = ? WHERE id = ?`
  ).run(result ? JSON.stringify(result) : null, now, taskId);
}

/**
 * Mark a task as failed.
 */
function failTask(taskId, error) {
  const now = nowLocal();
  db.prepare(
    `UPDATE acs_tasks SET status = 'failed', result = ?, retry_count = retry_count + 1, updated_at = ? WHERE id = ?`
  ).run(error ? JSON.stringify(error) : null, now, taskId);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  TASK → SOAP BUILDER
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Build the SOAP request for a given task.
 * Returns the SOAP XML string or null if the task type is unknown.
 */
function buildTaskSoap(cwmpId, task) {
  let payload = {};
  try { payload = JSON.parse(task.payload || '{}'); } catch (_) { /* empty */ }

  switch (task.name) {
    case 'reboot':
      return buildReboot(cwmpId);

    case 'factoryReset':
      return buildFactoryReset(cwmpId);

    case 'setParameterValues': {
      const pvs = payload.parameterValues || payload.values || [];
      if (!Array.isArray(pvs) || pvs.length === 0) return null;
      return buildSetParameterValues(cwmpId, pvs);
    }

    case 'getParameterValues': {
      const names = payload.parameterNames || payload.names || [];
      if (!Array.isArray(names) || names.length === 0) return null;
      return buildGetParameterValues(cwmpId, names);
    }

    case 'refreshObject': {
      const objName = payload.objectName || payload.object || '';
      return buildRefreshObject(cwmpId, objName);
    }

    case 'addObject': {
      const objName = payload.objectName || payload.object || '';
      if (!objName) return null;
      return buildAddObject(cwmpId, objName);
    }

    default:
      logger.warn(`[ACS] Unknown task type: ${task.name}`);
      return null;
  }
}

// ═══════════════════════════════════════════════════════════════════════════════
//  DETERMINE DEVICE ID
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Build the canonical device ID from DeviceId fields.
 * Format: {OUI}-{ProductClass}-{SerialNumber}
 */
function buildDeviceId(deviceInfo) {
  const oui = (deviceInfo.OUI || '000000').trim();
  const pc = (deviceInfo.ProductClass || '').trim();
  const sn = (deviceInfo.SerialNumber || '').trim();
  if (pc) {
    return `${oui}-${pc}-${sn}`;
  }
  return `${oui}-${sn}`;
}

// ═══════════════════════════════════════════════════════════════════════════════
//  MAIN CWMP REQUEST HANDLER
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Express handler for POST /acs
 *
 * Must be mounted with:
 *   app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml'] }), handleCwmpRequest);
 */
const handleCwmpRequest = async (req, res) => {
  try {
    // Get body as string
    let body = '';
    if (Buffer.isBuffer(req.body)) {
      body = req.body.toString('utf-8');
    } else if (typeof req.body === 'string') {
      body = req.body;
    } else if (req.body) {
      body = String(req.body);
    }

    // Determine CPE IP address
    const cpeIp = req.headers['x-forwarded-for']
      ? String(req.headers['x-forwarded-for']).split(',')[0].trim()
      : req.socket?.remoteAddress || req.ip || '';

    // Get or create session
    const { sid, session } = getOrCreateSession(req, res);

    // ── EMPTY POST (step 2 or 3) ───────────────────────────────────────────
    if (!body || body.trim().length === 0) {
      return handleEmptyPost(session, sid, res);
    }

    // ── INFORM ─────────────────────────────────────────────────────────────
    if (hasCwmpMethod(body, 'Inform')) {
      return handleInform(body, session, sid, cpeIp, res);
    }

    // ── RESPONSE TO A TASK ─────────────────────────────────────────────────
    // Check for known response types
    if (hasCwmpMethod(body, 'SetParameterValuesResponse')) {
      return handleTaskResponse(body, session, sid, 'setParameterValues', res);
    }
    if (hasCwmpMethod(body, 'RebootResponse')) {
      return handleTaskResponse(body, session, sid, 'reboot', res);
    }
    if (hasCwmpMethod(body, 'FactoryResetResponse')) {
      return handleTaskResponse(body, session, sid, 'factoryReset', res);
    }
    if (hasCwmpMethod(body, 'GetParameterValuesResponse')) {
      return handleGetParameterValuesResponse(body, session, sid, res);
    }
    if (hasCwmpMethod(body, 'AddObjectResponse')) {
      return handleAddObjectResponse(body, session, sid, res);
    }

    // ── FAULT ──────────────────────────────────────────────────────────────
    const fault = parseFault(body);
    if (fault) {
      return handleFault(fault, session, sid, res);
    }

    // ── UNKNOWN / UNRECOGNIZED ─────────────────────────────────────────────
    // Treat as empty post (next task or 204)
    logger.debug(`[ACS] Unrecognized SOAP body from session ${sid.substring(0, 8)}, treating as empty`);
    return handleEmptyPost(session, sid, res);

  } catch (err) {
    logger.error(`[ACS] Error handling CWMP request: ${err.message}`);
    res.status(500).set('Content-Type', 'text/xml; charset=utf-8').send('');
  }
};

// ═══════════════════════════════════════════════════════════════════════════════
//  HANDLER: Inform
// ═══════════════════════════════════════════════════════════════════════════════

function handleInform(body, session, sid, cpeIp, res) {
  const cwmpId = extractCwmpId(body);
  const deviceInfo = parseDeviceId(body);

  if (!deviceInfo || !deviceInfo.SerialNumber) {
    logger.warn(`[ACS] Inform received but could not parse DeviceId`);
    return sendSoapResponse(res, buildInformResponse(cwmpId));
  }

  const deviceId = buildDeviceId(deviceInfo);
  const params = parseParameterValues(body);

  logger.info(`[ACS] Inform from ${deviceId} (${deviceInfo.Manufacturer} ${deviceInfo.ProductClass}) IP=${cpeIp}`);

  // Upsert device in database
  let mergedParams = params;
  try {
    upsertDevice(deviceId, deviceInfo, params, cpeIp);
    const existing = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(deviceId);
    if (existing) {
      mergedParams = JSON.parse(existing.params || '{}');
    }
  } catch (err) {
    logger.error(`[ACS] Failed to upsert device ${deviceId}: ${err.message}`);
  }

  // Queue bootstrap parameter fetch if needed
  queueBootstrapTasksIfNeeded(deviceId, mergedParams);

  // Update session
  session.deviceId = deviceId;
  session.step = 'informed';
  session.currentTaskId = null;
  session.lastActivity = Date.now();

  return sendSoapResponse(res, buildInformResponse(cwmpId));
}

// ═══════════════════════════════════════════════════════════════════════════════
//  HANDLER: Empty POST (check task queue)
// ═══════════════════════════════════════════════════════════════════════════════

function handleEmptyPost(session, sid, res) {
  if (!session.deviceId) {
    // No active session device – nothing to do
    return res.status(204).set('Content-Type', 'text/xml; charset=utf-8').send('');
  }

  // Look for a pending task for this device
  const task = getNextPendingTask(session.deviceId);
  if (!task) {
    // No more tasks – signal end of session
    logger.debug(`[ACS] No pending tasks for ${session.deviceId}, sending 204`);
    return res.status(204).set('Content-Type', 'text/xml; charset=utf-8').send('');
  }

  // Build SOAP request for this task
  const cwmpId = String(task.id);
  const soapXml = buildTaskSoap(cwmpId, task);

  if (!soapXml) {
    // Invalid task – mark as failed and try next
    logger.warn(`[ACS] Could not build SOAP for task ${task.id} (${task.name}), marking failed`);
    failTask(task.id, { error: 'Could not build SOAP request for task' });
    return handleEmptyPost(session, sid, res);
  }

  // Update session – we are now waiting for the response to this task
  session.currentTaskId = task.id;
  session.step = 'task_sent';
  session.lastActivity = Date.now();

  // Mark task as in-progress (optional, keeps it 'pending' until response)
  const now = nowLocal();
  db.prepare("UPDATE acs_tasks SET status = 'in_progress', updated_at = ? WHERE id = ?")
    .run(now, task.id);

  logger.info(`[ACS] Sending task ${task.id} (${task.name}) to device ${session.deviceId}`);
  return sendSoapResponse(res, soapXml);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  HANDLER: Task Responses
// ═══════════════════════════════════════════════════════════════════════════════

function handleTaskResponse(body, session, sid, taskType, res) {
  const taskId = session.currentTaskId;

  if (taskId) {
    let result = {};
    if (taskType === 'setParameterValues') {
      const status = parseSetParameterValuesResponseStatus(body);
      result = { status };
      
      // If setting parameter values succeeded, merge those values into the device's local record immediately
      if (status === '0' || status === 0 || status === '') {
        try {
          const task = db.prepare('SELECT payload FROM acs_tasks WHERE id = ?').get(taskId);
          if (task) {
            const payload = JSON.parse(task.payload || '{}');
            const pvs = payload.parameterValues || payload.values || [];
            if (Array.isArray(pvs) && pvs.length > 0) {
              const newParams = {};
              for (const pv of pvs) {
                if (pv && pv[0]) {
                  newParams[pv[0]] = pv[1];
                }
              }
              mergeDeviceParams(session.deviceId, newParams);
              logger.debug(`[ACS] Merged ${Object.keys(newParams).length} set parameters into local device record for ${session.deviceId}`);
            }
          }
        } catch (err) {
          logger.error(`[ACS] Failed to merge set parameters after task completion: ${err.message}`);
        }
      }
    } else {
      result = { status: 'ok' };
    }

    logger.info(`[ACS] Task ${taskId} (${taskType}) completed for device ${session.deviceId}`);
    completeTask(taskId, result);
  }

  session.step = 'response_received';
  session.currentTaskId = null;
  session.lastActivity = Date.now();

  // Check for next task
  return handleEmptyPost(session, sid, res);
}

function handleGetParameterValuesResponse(body, session, sid, res) {
  const taskId = session.currentTaskId;
  const params = parseGetParameterValuesResponse(body);

  if (taskId) {
    logger.info(`[ACS] Task ${taskId} (getParameterValues/refreshObject) completed for device ${session.deviceId} – ${Object.keys(params).length} params`);
    completeTask(taskId, params);
  }

  // Merge returned params into device record
  if (session.deviceId && Object.keys(params).length > 0) {
    try {
      mergeDeviceParams(session.deviceId, params);
    } catch (err) {
      logger.error(`[ACS] Failed to merge params for device ${session.deviceId}: ${err.message}`);
    }
  }

  session.step = 'response_received';
  session.currentTaskId = null;
  session.lastActivity = Date.now();

  return handleEmptyPost(session, sid, res);
}

function handleAddObjectResponse(body, session, sid, res) {
  const taskId = session.currentTaskId;
  const result = parseAddObjectResponse(body);

  if (taskId) {
    logger.info(`[ACS] Task ${taskId} (addObject) completed – instance=${result.instanceNumber}`);
    completeTask(taskId, result);
  }

  session.step = 'response_received';
  session.currentTaskId = null;
  session.lastActivity = Date.now();

  return handleEmptyPost(session, sid, res);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  HANDLER: SOAP Fault
// ═══════════════════════════════════════════════════════════════════════════════

function handleFault(fault, session, sid, res) {
  const taskId = session.currentTaskId;

  logger.warn(`[ACS] SOAP Fault from device ${session.deviceId}: code=${fault.faultCode} string=${fault.faultString}`);

  if (taskId) {
    failTask(taskId, fault);
  }

  session.step = 'response_received';
  session.currentTaskId = null;
  session.lastActivity = Date.now();

  // Try next task
  return handleEmptyPost(session, sid, res);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  HELPERS
// ═══════════════════════════════════════════════════════════════════════════════

function sendSoapResponse(res, soapXml) {
  res.status(200)
    .set('Content-Type', 'text/xml; charset=utf-8')
    .set('SOAPAction', '')
    .send(soapXml);
}

// ═══════════════════════════════════════════════════════════════════════════════
//  CONNECTION REQUEST TRIGGER
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Send an HTTP GET to the CPE's ConnectionRequestURL to trigger a new session.
 * Fire-and-forget. Supports Basic Auth.
 *
 * @param {string} deviceId
 * @returns {Promise<{success:boolean, message:string}>}
 */
const activeTriggers = new Map();
const lastTriggerTimes = new Map();

async function triggerConnectionRequest(deviceId) {
  if (activeTriggers.has(deviceId)) {
    clearTimeout(activeTriggers.get(deviceId));
  }

  return new Promise((resolve) => {
    const timeoutObj = setTimeout(async () => {
      activeTriggers.delete(deviceId);

      const lastTime = lastTriggerTimes.get(deviceId) || 0;
      if (Date.now() - lastTime < 3000) {
        logger.debug(`[ACS] Connection request to ${deviceId} throttled (already sent within 3s)`);
        resolve({ success: true, message: 'Throttled' });
        return;
      }

      lastTriggerTimes.set(deviceId, Date.now());

      try {
        const res = await performConnectionRequest(deviceId);
        resolve(res);
      } catch (err) {
        resolve({ success: false, message: err.message });
      }
    }, 1000); // 1-second debounce window

    activeTriggers.set(deviceId, timeoutObj);
  });
}

async function performConnectionRequest(deviceId) {
  try {
    const device = db.prepare('SELECT * FROM acs_devices WHERE id = ?').get(deviceId);
    if (!device) {
      return { success: false, message: `Device ${deviceId} not found` };
    }

    const crUrl = device.connection_request_url;
    if (!crUrl) {
      return { success: false, message: `No ConnectionRequestURL for device ${deviceId}` };
    }

    logger.info(`[ACS] Triggering connection request to ${deviceId} → ${crUrl}`);

    const url = new URL(crUrl);
    const isHttps = url.protocol === 'https:';
    const transport = isHttps ? https : http;

    const options = {
      hostname: url.hostname,
      port: url.port || (isHttps ? 443 : 80),
      path: url.pathname + url.search,
      method: 'GET',
      timeout: 10000,
      rejectUnauthorized: false, // Self-signed certs on CPE devices
    };

    // Basic auth
    const crUser = device.connection_request_user || '';
    const crPass = device.connection_request_pass || '';
    if (crUser) {
      options.auth = `${crUser}:${crPass}`;
    }

    return new Promise((resolve) => {
      const req = transport.request(options, (resp) => {
        // Consume response data to free up memory
        resp.resume();
        logger.info(`[ACS] Connection request to ${deviceId} returned HTTP ${resp.statusCode}`);

        // Handle 401 Digest Auth challenge if basic auth failed
        if (resp.statusCode === 401 && resp.headers['www-authenticate'] && crUser) {
          logger.debug(`[ACS] Got 401 from ${deviceId}, attempting digest auth`);
          // For simplicity, retry with auth header in URL (some CPEs accept this)
          resolve({ success: true, message: `Connection request sent (HTTP ${resp.statusCode} – auth challenge)` });
          return;
        }

        resolve({ success: true, message: `Connection request sent (HTTP ${resp.statusCode})` });
      });

      req.on('error', (err) => {
        logger.warn(`[ACS] Connection request to ${deviceId} failed: ${err.message}`);
        resolve({ success: false, message: `Connection request failed: ${err.message}` });
      });

      req.on('timeout', () => {
        req.destroy();
        logger.warn(`[ACS] Connection request to ${deviceId} timed out`);
        resolve({ success: false, message: 'Connection request timed out' });
      });

      req.end();
    });
  } catch (err) {
    logger.error(`[ACS] Connection request error for ${deviceId}: ${err.message}`);
    return { success: false, message: err.message };
  }
}

// ═══════════════════════════════════════════════════════════════════════════════
//  PUBLIC API: Query Devices & Create Tasks
// ═══════════════════════════════════════════════════════════════════════════════

/**
 * Query acs_devices with optional filtering.
 *
 * @param {Object} [query]  – Flat object of column → value filters (AND logic).
 *                            Special keys: 'search' (fuzzy across SN/manufacturer/product_class)
 * @param {string} [projection] – Comma-separated column names to select (default: '*')
 * @returns {Array}
 */
function getBuiltinDevices(query, projection) {
  try {
    let cols = '*';
    if (projection) {
      const allowed = new Set([
        'id', 'serial_number', 'manufacturer', 'product_class', 'oui',
        'software_version', 'hardware_version', 'ip_address',
        'connection_request_url', 'connection_request_user', 'connection_request_pass',
        'tags', 'params', 'last_inform', 'created_at', 'updated_at'
      ]);
      const requested = projection.split(',').map(c => c.trim()).filter(c => allowed.has(c));
      if (requested.length > 0) cols = requested.join(', ');
    }

    let sql = `SELECT ${cols} FROM acs_devices`;
    const conditions = [];
    const values = [];

    if (query && typeof query === 'object') {
      for (const [key, val] of Object.entries(query)) {
        if (key === 'search' && val) {
          conditions.push(
            `(serial_number LIKE ? OR manufacturer LIKE ? OR product_class LIKE ? OR id LIKE ?)`
          );
          const like = `%${val}%`;
          values.push(like, like, like, like);
        } else if (key === 'tags' && val) {
          conditions.push(`tags LIKE ?`);
          values.push(`%${val}%`);
        } else if (['id', 'serial_number', 'manufacturer', 'product_class', 'oui', 'ip_address'].includes(key)) {
          conditions.push(`${key} = ?`);
          values.push(val);
        }
      }
    }

    if (conditions.length > 0) {
      sql += ' WHERE ' + conditions.join(' AND ');
    }
    sql += ' ORDER BY last_inform DESC';

    const rows = db.prepare(sql).all(...values);

    // Parse JSON fields
    return rows.map(row => {
      try { row.tags = JSON.parse(row.tags || '[]'); } catch (_) { row.tags = []; }
      try { row.params = JSON.parse(row.params || '{}'); } catch (_) { row.params = {}; }
      return row;
    });
  } catch (err) {
    logger.error(`[ACS] getBuiltinDevices error: ${err.message}`);
    return [];
  }
}

/**
 * Get a single device by its canonical ID.
 *
 * @param {string} deviceId
 * @returns {Object|null}
 */
function getBuiltinDevice(deviceId) {
  try {
    const row = db.prepare('SELECT * FROM acs_devices WHERE id = ?').get(deviceId);
    if (!row) return null;
    try { row.tags = JSON.parse(row.tags || '[]'); } catch (_) { row.tags = []; }
    try { row.params = JSON.parse(row.params || '{}'); } catch (_) { row.params = {}; }
    return row;
  } catch (err) {
    logger.error(`[ACS] getBuiltinDevice error: ${err.message}`);
    return null;
  }
}

/**
 * Create a new task for a device and trigger a connection request.
 *
 * @param {string}  deviceId  – acs_devices.id
 * @param {string}  taskName  – reboot | setParameterValues | getParameterValues | refreshObject | factoryReset | addObject
 * @param {Object}  payload   – Task-specific data
 * @returns {{ success: boolean, taskId?: number, message?: string }}
 */
function createBuiltinTask(deviceId, taskName, payload) {
  try {
    const now = nowLocal();
    const payloadStr = JSON.stringify(payload || {});

    const info = db.prepare(
      `INSERT INTO acs_tasks (device_id, name, payload, status, created_at, updated_at)
       VALUES (?, ?, ?, 'pending', ?, ?)`
    ).run(deviceId, taskName, payloadStr, now, now);

    const taskId = Number(info.lastInsertRowid);

    logger.info(`[ACS] Task ${taskId} (${taskName}) created for device ${deviceId}`);

    // Fire-and-forget connection request to wake up the CPE
    triggerConnectionRequest(deviceId).catch(err => {
      logger.warn(`[ACS] Failed to trigger connection request for ${deviceId}: ${err.message}`);
    });

    return { success: true, taskId, message: `Task ${taskName} created` };
  } catch (err) {
    logger.error(`[ACS] createBuiltinTask error: ${err.message}`);
    return { success: false, message: err.message };
  }
}

// ═══════════════════════════════════════════════════════════════════════════════
//  EXPORTS
// ═══════════════════════════════════════════════════════════════════════════════

module.exports = {
  handleCwmpRequest,
  triggerConnectionRequest,
  getBuiltinDevices,
  getBuiltinDevice,
  createBuiltinTask,
};
