#!/usr/bin/env node
/**
 * ADR-076: Multi-Node Graph Transformer for CSI Fusion
 *
 * Builds a graph from multiple ESP32 nodes and applies graph attention to
 * fuse their CSI feature vectors (either 8-dim hand-crafted or 128-dim CNN)
 * into a single multi-viewpoint representation.
 *
 * The graph structure:
 *   - Each ESP32 node = graph node with a feature vector
 *   - Edge between nodes weighted by cross-node correlation
 *   - Attention learns which node to trust more per prediction
 *
 * Modes:
 *   --live           Listen on UDP for real-time multi-node CSI
 *   --file FILE      Read from a .csi.jsonl recording with multiple node_ids
 *   --dim DIM        Feature dimension (8 for hand-crafted, 128 for CNN)
 *   --heads H        Number of attention heads (default: 4)
 *   --json           JSON output
 *
 * Usage:
 *   node scripts/mesh-graph-transformer.js --file data/recordings/pretrain-1775182186.csi.jsonl
 *   node scripts/mesh-graph-transformer.js --live --port 5006 --dim 128
 *
 * ADR: docs/adr/ADR-076-csi-spectrogram-embeddings.md
 */

'use strict';

const dgram = require('dgram');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const { parseArgs } = require('util');

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
  options: {
    file:     { type: 'string', short: 'f' },
    live:     { type: 'boolean', default: false },
    port:     { type: 'string', short: 'p', default: '5006' },
    dim:      { type: 'string', short: 'd', default: '8' },
    heads:    { type: 'string', short: 'h', default: '4' },
    window:   { type: 'string', short: 'w', default: '20' },
    json:     { type: 'boolean', default: false },
    limit:    { type: 'string', short: 'l' },
  },
  strict: true,
});

const FEAT_DIM = parseInt(args.dim, 10);
const NUM_HEADS = parseInt(args.heads, 10);
const WINDOW_SIZE = parseInt(args.window, 10);
const PORT = parseInt(args.port, 10);
const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
const JSON_OUTPUT = args.json;

// ADR-018 packet constants
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;

// ---------------------------------------------------------------------------
// IQ Parsing (shared with csi-spectrogram.js)
// ---------------------------------------------------------------------------

function parseIqHex(iqHex, nSubcarriers) {
  const amps = new Float32Array(nSubcarriers);
  for (let sc = 0; sc < nSubcarriers; sc++) {
    const offset = sc * 4;
    if (offset + 4 > iqHex.length) break;
    const iVal = parseInt(iqHex.substring(offset, offset + 2), 16);
    const qVal = parseInt(iqHex.substring(offset + 2, offset + 4), 16);
    amps[sc] = Math.sqrt(iVal * iVal + qVal * qVal);
  }
  return amps;
}

// ---------------------------------------------------------------------------
// 8-dim Hand-Crafted Feature Extraction
// ---------------------------------------------------------------------------

/**
 * Extract 8-dim feature vector from subcarrier amplitudes.
 * Matches the features used by seed_csi_bridge.py (ADR-069).
 * @param {Float32Array} amplitudes
 * @param {number} rssi
 * @returns {Float32Array}
 */
function extract8DimFeatures(amplitudes, rssi) {
  const n = amplitudes.length;
  if (n === 0) return new Float32Array(8);

  let sum = 0, sumSq = 0, maxAmp = 0;
  for (let i = 0; i < n; i++) {
    const v = amplitudes[i];
    sum += v;
    sumSq += v * v;
    if (v > maxAmp) maxAmp = v;
  }
  const mean = sum / n;
  const variance = sumSq / n - mean * mean;

  // Phase: approximate from I/Q sign pattern (simplified)
  const phaseMean = 0; // Would need raw I/Q for true phase
  const phaseVariance = 0;

  // Bandwidth: number of subcarriers above noise floor
  const noiseFloor = mean * 0.1;
  let bw = 0;
  for (let i = 0; i < n; i++) {
    if (amplitudes[i] > noiseFloor) bw++;
  }

  // Spectral centroid
  let weightedSum = 0;
  for (let i = 0; i < n; i++) {
    weightedSum += i * amplitudes[i];
  }
  const centroid = sum > 0 ? weightedSum / sum : n / 2;

  return new Float32Array([
    mean,
    variance,
    maxAmp,
    phaseMean,
    phaseVariance,
    bw / n,               // normalized bandwidth
    centroid / n,          // normalized centroid
    Math.abs(rssi) / 100,  // normalized RSSI
  ]);
}

// ---------------------------------------------------------------------------
// Graph Attention Layer (Pure JS, no WASM dependency)
// ---------------------------------------------------------------------------

/**
 * Multi-head graph attention network (GATv2-style).
 *
 * For a graph with N nodes each having D-dimensional features:
 *   1. Project features to Q, K, V using learned weights
 *   2. Compute attention scores with edge weight bias
 *   3. Aggregate via softmax-weighted sum
 *   4. Produce fused D-dimensional output
 */
class GraphAttentionLayer {
  /**
   * @param {number} inputDim - Feature dimension per node
   * @param {number} numHeads - Number of attention heads
   */
  constructor(inputDim, numHeads) {
    this.inputDim = inputDim;
    this.numHeads = numHeads;
    this.headDim = Math.max(1, Math.floor(inputDim / numHeads));

    // Initialize projection weights (Xavier uniform)
    this.Wq = this._initWeights(inputDim, this.headDim * numHeads);
    this.Wk = this._initWeights(inputDim, this.headDim * numHeads);
    this.Wv = this._initWeights(inputDim, this.headDim * numHeads);
    this.Wo = this._initWeights(this.headDim * numHeads, inputDim);

    // Edge weight bias scale
    this.edgeBiasScale = 0.5;
  }

  /** Xavier-uniform weight initialization. */
  _initWeights(rows, cols) {
    const limit = Math.sqrt(6 / (rows + cols));
    const w = new Float32Array(rows * cols);
    for (let i = 0; i < w.length; i++) {
      w[i] = (Math.random() * 2 - 1) * limit;
    }
    return { data: w, rows, cols };
  }

  /** Matrix-vector multiply: out = W * x. */
  _matvec(W, x) {
    const out = new Float32Array(W.rows);
    for (let r = 0; r < W.rows; r++) {
      let sum = 0;
      for (let c = 0; c < W.cols; c++) {
        sum += W.data[r * W.cols + c] * x[c];
      }
      out[r] = sum;
    }
    return out;
  }

  /**
   * Compute attention-fused output for a set of nodes.
   *
   * @param {Float32Array[]} nodeFeatures - Array of D-dim feature vectors, one per node
   * @param {Map<string, number>} edgeWeights - Map of "i-j" -> weight (cross-correlation)
   * @returns {{ fused: Float32Array, attentionWeights: number[][] }}
   */
  forward(nodeFeatures, edgeWeights) {
    const N = nodeFeatures.length;
    if (N === 0) return { fused: new Float32Array(this.inputDim), attentionWeights: [] };
    if (N === 1) return { fused: new Float32Array(nodeFeatures[0]), attentionWeights: [[1.0]] };

    const D = this.headDim;
    const H = this.numHeads;

    // Project to Q, K, V for each node
    const queries = nodeFeatures.map(f => this._matvec(this.Wq, f));
    const keys = nodeFeatures.map(f => this._matvec(this.Wk, f));
    const values = nodeFeatures.map(f => this._matvec(this.Wv, f));

    // Compute per-head attention scores with edge bias
    const scale = 1 / Math.sqrt(D);
    const allAttentionWeights = [];

    // Aggregate output per node (we produce a fused vector for each node)
    const nodeOutputs = [];

    for (let i = 0; i < N; i++) {
      const headOutputs = [];

      for (let h = 0; h < H; h++) {
        const hOff = h * D;

        // Compute attention scores from node i to all other nodes
        const scores = new Float32Array(N);
        for (let j = 0; j < N; j++) {
          let dot = 0;
          for (let d = 0; d < D; d++) {
            dot += queries[i][hOff + d] * keys[j][hOff + d];
          }
          // Add edge weight bias
          const edgeKey = i < j ? `${i}-${j}` : `${j}-${i}`;
          const ew = edgeWeights.get(edgeKey) || 0;
          scores[j] = dot * scale + ew * this.edgeBiasScale;
        }

        // Softmax
        let maxScore = -Infinity;
        for (let j = 0; j < N; j++) {
          if (scores[j] > maxScore) maxScore = scores[j];
        }
        let sumExp = 0;
        const attn = new Float32Array(N);
        for (let j = 0; j < N; j++) {
          attn[j] = Math.exp(scores[j] - maxScore);
          sumExp += attn[j];
        }
        for (let j = 0; j < N; j++) {
          attn[j] /= sumExp;
        }

        if (i === 0 && h === 0) {
          allAttentionWeights.push(Array.from(attn));
        }

        // Weighted sum of values
        const headOut = new Float32Array(D);
        for (let j = 0; j < N; j++) {
          for (let d = 0; d < D; d++) {
            headOut[d] += attn[j] * values[j][hOff + d];
          }
        }
        headOutputs.push(headOut);
      }

      // Concatenate heads
      const concat = new Float32Array(H * D);
      for (let h = 0; h < H; h++) {
        concat.set(headOutputs[h], h * D);
      }

      // Project back to input dimension
      nodeOutputs.push(this._matvec(this.Wo, concat));
    }

    // Fuse all node outputs via mean pooling
    const fused = new Float32Array(this.inputDim);
    for (let i = 0; i < N; i++) {
      for (let d = 0; d < this.inputDim; d++) {
        fused[d] += nodeOutputs[i][d] / N;
      }
    }

    return { fused, attentionWeights: allAttentionWeights };
  }
}

// ---------------------------------------------------------------------------
// Cross-Node Correlation
// ---------------------------------------------------------------------------

/**
 * Compute Pearson correlation between two amplitude vectors.
 * Used as edge weight in the graph.
 */
function pearsonCorrelation(a, b) {
  const n = Math.min(a.length, b.length);
  if (n === 0) return 0;

  let sumA = 0, sumB = 0, sumAB = 0, sumA2 = 0, sumB2 = 0;
  for (let i = 0; i < n; i++) {
    sumA += a[i];
    sumB += b[i];
    sumAB += a[i] * b[i];
    sumA2 += a[i] * a[i];
    sumB2 += b[i] * b[i];
  }

  const num = n * sumAB - sumA * sumB;
  const den = Math.sqrt((n * sumA2 - sumA * sumA) * (n * sumB2 - sumB * sumB));
  return den > 0 ? num / den : 0;
}

// ---------------------------------------------------------------------------
// Graph Builder
// ---------------------------------------------------------------------------

/**
 * Build and maintain a graph of ESP32 nodes.
 * Stores the latest feature vector per node and computes edge weights.
 */
class MeshGraph {
  constructor(featDim, numHeads) {
    this.featDim = featDim;
    /** @type {Map<number, { features: Float32Array, amplitudes: Float32Array, rssi: number, timestamp: number }>} */
    this.nodes = new Map();
    this.attention = new GraphAttentionLayer(featDim, numHeads);
    this.fusionCount = 0;
  }

  /**
   * Update a node's features.
   * @param {number} nodeId
   * @param {Float32Array} features - D-dim feature vector
   * @param {Float32Array} amplitudes - Raw subcarrier amplitudes (for cross-correlation)
   * @param {number} rssi
   * @param {number} timestamp
   */
  updateNode(nodeId, features, amplitudes, rssi, timestamp) {
    this.nodes.set(nodeId, { features, amplitudes, rssi, timestamp });
  }

  /**
   * Compute edge weights between all node pairs.
   * @returns {Map<string, number>}
   */
  computeEdgeWeights() {
    const weights = new Map();
    const nodeIds = Array.from(this.nodes.keys()).sort();

    for (let i = 0; i < nodeIds.length; i++) {
      for (let j = i + 1; j < nodeIds.length; j++) {
        const a = this.nodes.get(nodeIds[i]);
        const b = this.nodes.get(nodeIds[j]);
        const corr = pearsonCorrelation(a.amplitudes, b.amplitudes);
        weights.set(`${i}-${j}`, corr);
      }
    }

    return weights;
  }

  /**
   * Run graph attention to produce a fused feature vector.
   * @returns {{ fused: Float32Array, attentionWeights: number[][], nodeIds: number[], edgeWeights: Map<string, number> } | null}
   */
  fuse() {
    if (this.nodes.size < 2) return null;

    const nodeIds = Array.from(this.nodes.keys()).sort();
    const features = nodeIds.map(id => this.nodes.get(id).features);
    const edgeWeights = this.computeEdgeWeights();

    const { fused, attentionWeights } = this.attention.forward(features, edgeWeights);
    this.fusionCount++;

    return { fused, attentionWeights, nodeIds, edgeWeights };
  }

  /** Pretty-print graph state. */
  toString() {
    const nodeIds = Array.from(this.nodes.keys()).sort();
    const lines = [`Graph: ${nodeIds.length} nodes [${nodeIds.join(', ')}]`];

    if (nodeIds.length >= 2) {
      const edgeWeights = this.computeEdgeWeights();
      for (const [key, weight] of edgeWeights) {
        const [i, j] = key.split('-').map(Number);
        lines.push(`  Edge ${nodeIds[i]}->${nodeIds[j]}: correlation=${weight.toFixed(4)}`);
      }
    }

    return lines.join('\n');
  }
}

// ---------------------------------------------------------------------------
// Optional: Graph-WASM Visualization
// ---------------------------------------------------------------------------

let graphDb = null;

/**
 * Initialize @ruvector/graph-wasm for persistent graph storage.
 * Optional -- only used if the WASM file exists.
 */
async function initGraphDb() {
  try {
    const graphWasmPath = path.resolve(
      __dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'graph-wasm'
    );
    const graphWasm = require(graphWasmPath);
    await graphWasm.default();
    graphDb = new graphWasm.GraphDB('cosine');
    if (!JSON_OUTPUT) console.log('[graph-wasm] Initialized persistent graph DB');
    return true;
  } catch {
    if (!JSON_OUTPUT) console.log('[graph-wasm] Not available, using in-memory graph only');
    return false;
  }
}

/**
 * Persist the mesh graph to @ruvector/graph-wasm.
 * @param {MeshGraph} mesh
 * @param {object} fusionResult
 */
function persistToGraphDb(mesh, fusionResult) {
  if (!graphDb) return;

  const { nodeIds, edgeWeights, fused, attentionWeights } = fusionResult;

  // Create/update nodes
  for (const nodeId of nodeIds) {
    const node = mesh.nodes.get(nodeId);
    const existingId = `esp32-node-${nodeId}`;
    try { graphDb.deleteNode(existingId); } catch { /* ignore */ }
    graphDb.createNode(['ESP32', 'SensingNode'], {
      id: existingId,
      node_id: nodeId,
      rssi: node.rssi,
      timestamp: node.timestamp,
      feature_dim: mesh.featDim,
    });
  }

  // Create edges with correlation weights
  for (const [key, weight] of edgeWeights) {
    const [i, j] = key.split('-').map(Number);
    try {
      graphDb.createEdge(
        `esp32-node-${nodeIds[i]}`,
        `esp32-node-${nodeIds[j]}`,
        'CSI_CORRELATION',
        { weight, fusion_count: mesh.fusionCount }
      );
    } catch { /* ignore duplicate edges */ }
  }
}

// ---------------------------------------------------------------------------
// File Mode
// ---------------------------------------------------------------------------

async function processFile(filePath) {
  await initGraphDb();

  const mesh = new MeshGraph(FEAT_DIM, NUM_HEADS);
  const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let frameCount = 0;
  let fusionCount = 0;
  const nodeFrameCounts = new Map();

  for await (const line of rl) {
    if (frameCount >= LIMIT) break;

    let frame;
    try {
      frame = JSON.parse(line);
    } catch {
      continue;
    }

    const nodeId = frame.node_id || 0;
    const nSubcarriers = frame.subcarriers || 64;
    const iqHex = frame.iq_hex || '';
    if (!iqHex) continue;

    const amplitudes = parseIqHex(iqHex, nSubcarriers);
    const rssi = frame.rssi || 0;

    // Extract feature vector based on configured dimension
    let features;
    if (FEAT_DIM === 8) {
      features = extract8DimFeatures(amplitudes, rssi);
    } else {
      // For CNN embeddings, we need the csi-spectrogram.js pipeline.
      // In file mode without CNN, use padded 8-dim features as a placeholder.
      const base = extract8DimFeatures(amplitudes, rssi);
      features = new Float32Array(FEAT_DIM);
      features.set(base.subarray(0, Math.min(8, FEAT_DIM)));
    }

    mesh.updateNode(nodeId, features, amplitudes, rssi, frame.timestamp || 0);
    frameCount++;

    const nc = (nodeFrameCounts.get(nodeId) || 0) + 1;
    nodeFrameCounts.set(nodeId, nc);

    // Attempt fusion every WINDOW_SIZE frames (when we have data from multiple nodes)
    if (frameCount % WINDOW_SIZE === 0 && mesh.nodes.size >= 2) {
      const result = mesh.fuse();
      if (result) {
        fusionCount++;
        persistToGraphDb(mesh, result);

        if (JSON_OUTPUT) {
          console.log(JSON.stringify({
            type: 'fusion',
            fusionIdx: fusionCount,
            nodeIds: result.nodeIds,
            edgeWeights: Object.fromEntries(result.edgeWeights),
            attentionWeights: result.attentionWeights,
            fused: Array.from(result.fused).map(v => +v.toFixed(6)),
          }));
        } else {
          console.log(`\n[fusion ${fusionCount}] ${mesh.toString()}`);
          if (result.attentionWeights.length > 0) {
            const aw = result.attentionWeights[0].map(w => w.toFixed(3));
            console.log(`  Attention (head 0): [${aw.join(', ')}]`);
          }
          const fusedSnippet = Array.from(result.fused.subarray(0, 4)).map(v => v.toFixed(4)).join(', ');
          console.log(`  Fused: [${fusedSnippet}, ...] (dim=${FEAT_DIM})`);
        }
      }
    }
  }

  if (!JSON_OUTPUT) {
    console.log(`\nProcessed ${frameCount} frames from ${nodeFrameCounts.size} nodes`);
    console.log(`Produced ${fusionCount} fusions with ${NUM_HEADS}-head attention`);
    for (const [nodeId, count] of nodeFrameCounts) {
      console.log(`  Node ${nodeId}: ${count} frames`);
    }
    if (graphDb) {
      const stats = graphDb.stats();
      console.log(`Graph DB: ${stats.nodeCount} nodes, ${stats.edgeCount} edges`);
    }
  }
}

// ---------------------------------------------------------------------------
// Live Mode
// ---------------------------------------------------------------------------

async function processLive() {
  await initGraphDb();

  const mesh = new MeshGraph(FEAT_DIM, NUM_HEADS);
  let frameCount = 0;
  let fusionCount = 0;

  const server = dgram.createSocket('udp4');

  server.on('message', (msg) => {
    let nodeId, nSubcarriers, amplitudes, rssi;

    // Try binary ADR-018 format
    if (msg.length >= HEADER_SIZE && msg.readUInt32LE(0) === CSI_MAGIC) {
      nodeId = msg.readUInt8(4);
      rssi = msg.readInt8(5);
      nSubcarriers = msg.readUInt16LE(6);
      amplitudes = new Float32Array(nSubcarriers);
      for (let sc = 0; sc < nSubcarriers; sc++) {
        const off = HEADER_SIZE + sc * 2;
        if (off + 2 > msg.length) break;
        amplitudes[sc] = Math.sqrt(msg[off] ** 2 + msg[off + 1] ** 2);
      }
    } else {
      // Try JSONL
      try {
        const frame = JSON.parse(msg.toString());
        nodeId = frame.node_id || 0;
        nSubcarriers = frame.subcarriers || 64;
        amplitudes = parseIqHex(frame.iq_hex || '', nSubcarriers);
        rssi = frame.rssi || 0;
      } catch {
        return;
      }
    }

    let features;
    if (FEAT_DIM === 8) {
      features = extract8DimFeatures(amplitudes, rssi);
    } else {
      const base = extract8DimFeatures(amplitudes, rssi);
      features = new Float32Array(FEAT_DIM);
      features.set(base.subarray(0, Math.min(8, FEAT_DIM)));
    }

    mesh.updateNode(nodeId, features, amplitudes, rssi, Date.now() / 1000);
    frameCount++;

    if (frameCount % WINDOW_SIZE === 0 && mesh.nodes.size >= 2) {
      const result = mesh.fuse();
      if (result) {
        fusionCount++;
        persistToGraphDb(mesh, result);

        if (JSON_OUTPUT) {
          console.log(JSON.stringify({
            type: 'fusion',
            fusionIdx: fusionCount,
            nodeIds: result.nodeIds,
            edgeWeights: Object.fromEntries(result.edgeWeights),
            attentionWeights: result.attentionWeights,
            fused: Array.from(result.fused).map(v => +v.toFixed(6)),
          }));
        } else {
          console.log(`[fusion ${fusionCount}] nodes=${result.nodeIds.join(',')}` +
            ` corr=${Array.from(result.edgeWeights.values()).map(v => v.toFixed(3)).join(',')}`);
        }
      }
    }
  });

  server.on('listening', () => {
    const addr = server.address();
    console.log(`[live] Mesh graph transformer on UDP ${addr.address}:${addr.port}`);
    console.log(`[live] Feature dim: ${FEAT_DIM}, heads: ${NUM_HEADS}, window: ${WINDOW_SIZE}`);
  });

  server.bind(PORT);
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function main() {
  if (!args.file && !args.live) {
    console.error('Usage: node scripts/mesh-graph-transformer.js --file <path> [--dim 8|128] [--heads 4]');
    console.error('       node scripts/mesh-graph-transformer.js --live [--port 5006] [--dim 128]');
    process.exit(1);
  }

  if (args.file) {
    const filePath = path.resolve(args.file);
    if (!fs.existsSync(filePath)) {
      console.error(`File not found: ${filePath}`);
      process.exit(1);
    }
    await processFile(filePath);
  } else {
    await processLive();
  }
}

main().catch((err) => {
  console.error('Fatal:', err);
  process.exit(1);
});
