#!/usr/bin/env node
/**
 * ADR-075: CSI Subcarrier Correlation Graph Visualizer
 *
 * ASCII visualization of the subcarrier correlation graph used by the
 * min-cut person counter. Shows per-person subcarrier clusters, graph
 * connectivity, and correlation heatmap in real-time.
 *
 * Usage:
 *   # Live from ESP32 nodes via UDP
 *   node scripts/csi-graph-visualizer.js --port 5006
 *
 *   # Replay from recorded CSI data
 *   node scripts/csi-graph-visualizer.js --replay data/recordings/pretrain-1775182186.csi.jsonl
 *
 *   # Show correlation heatmap only
 *   node scripts/csi-graph-visualizer.js --replay FILE --mode heatmap
 *
 * ADR: docs/adr/ADR-075-mincut-person-separation.md
 */

'use strict';

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

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
  options: {
    port:       { type: 'string', short: 'p', default: '5006' },
    replay:     { type: 'string', short: 'r' },
    interval:   { type: 'string', short: 'i', default: '2000' },
    window:     { type: 'string', short: 'w', default: '2000' },
    mode:       { type: 'string', short: 'm', default: 'all' },
    node:       { type: 'string', short: 'n', default: '0' },
    'corr-threshold': { type: 'string', default: '0.3' },
    'cut-threshold':  { type: 'string', default: '2.0' },
    'var-floor':      { type: 'string', default: '0.5' },
    width:      { type: 'string', default: '80' },
  },
  strict: true,
});

const PORT           = parseInt(args.port, 10);
const INTERVAL_MS    = parseInt(args.interval, 10);
const WINDOW_MS      = parseInt(args.window, 10);
const CORR_THRESHOLD = parseFloat(args['corr-threshold']);
const CUT_THRESHOLD  = parseFloat(args['cut-threshold']);
const VAR_FLOOR      = parseFloat(args['var-floor']);
const MODE           = args.mode; // 'all', 'heatmap', 'clusters', 'spectrum'
const TARGET_NODE    = parseInt(args.node, 10);
const WIDTH          = parseInt(args.width, 10);

const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;

// Color palette for person clusters (ANSI 256)
const PERSON_COLORS = [
  '\x1b[31m', // red
  '\x1b[32m', // green
  '\x1b[34m', // blue
  '\x1b[33m', // yellow
  '\x1b[35m', // magenta
  '\x1b[36m', // cyan
  '\x1b[91m', // bright red
  '\x1b[92m', // bright green
];
const RESET = '\x1b[0m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';

// Heatmap characters (11 levels of intensity)
const HEAT = [' ', '\u2591', '\u2591', '\u2592', '\u2592', '\u2593', '\u2593', '\u2588', '\u2588', '\u2588', '\u2588'];

// Bar chart characters
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];

// ---------------------------------------------------------------------------
// Sliding window (same as mincut-person-counter.js)
// ---------------------------------------------------------------------------
class SubcarrierWindow {
  constructor(maxAgeMs) {
    this.maxAgeMs = maxAgeMs;
    this.frames = [];
    this.nSubcarriers = 0;
  }

  push(timestamp, amplitudes) {
    this.nSubcarriers = amplitudes.length;
    this.frames.push({ timestamp, amplitudes: Float64Array.from(amplitudes) });
    const cutoff = timestamp - this.maxAgeMs;
    while (this.frames.length > 0 && this.frames[0].timestamp < cutoff) {
      this.frames.shift();
    }
  }

  get length() { return this.frames.length; }

  correlationMatrix() {
    const nFrames = this.frames.length;
    const nSc = this.nSubcarriers;
    if (nFrames < 5 || nSc === 0) return null;

    const mean = new Float64Array(nSc);
    const std = new Float64Array(nSc);

    for (let f = 0; f < nFrames; f++) {
      const amp = this.frames[f].amplitudes;
      for (let i = 0; i < nSc; i++) mean[i] += amp[i];
    }
    for (let i = 0; i < nSc; i++) mean[i] /= nFrames;

    for (let f = 0; f < nFrames; f++) {
      const amp = this.frames[f].amplitudes;
      for (let i = 0; i < nSc; i++) {
        const d = amp[i] - mean[i];
        std[i] += d * d;
      }
    }
    for (let i = 0; i < nSc; i++) std[i] = Math.sqrt(std[i] / (nFrames - 1));

    const activeIndices = [];
    for (let i = 0; i < nSc; i++) {
      if (std[i] > VAR_FLOOR) activeIndices.push(i);
    }

    const n = activeIndices.length;
    if (n < 2) return { matrix: null, n: 0, activeIndices, mean, std };

    const matrix = new Float64Array(n * n);
    for (let ai = 0; ai < n; ai++) {
      matrix[ai * n + ai] = 1.0;
      const si = activeIndices[ai];
      for (let aj = ai + 1; aj < n; aj++) {
        const sj = activeIndices[aj];
        let cov = 0;
        for (let f = 0; f < nFrames; f++) {
          const amp = this.frames[f].amplitudes;
          cov += (amp[si] - mean[si]) * (amp[sj] - mean[sj]);
        }
        cov /= (nFrames - 1);
        const denom = std[si] * std[sj];
        const r = denom > 1e-10 ? cov / denom : 0;
        matrix[ai * n + aj] = r;
        matrix[aj * n + ai] = r;
      }
    }

    return { matrix, n, activeIndices, mean, std };
  }

  /** Get latest amplitudes */
  latestAmplitudes() {
    if (this.frames.length === 0) return null;
    return this.frames[this.frames.length - 1].amplitudes;
  }
}

// ---------------------------------------------------------------------------
// Graph + Stoer-Wagner (minimal copy from mincut-person-counter.js)
// ---------------------------------------------------------------------------
class WeightedGraph {
  constructor(n) {
    this.n = n;
    this.adj = new Array(n);
    for (let i = 0; i < n; i++) this.adj[i] = new Map();
    this.edgeCount = 0;
  }
  addEdge(u, v, w) {
    if (u === v) return;
    if (!this.adj[u].has(v)) this.edgeCount++;
    this.adj[u].set(v, w);
    this.adj[v].set(u, w);
  }
  static fromCorrelation(matrix, n, threshold) {
    const g = new WeightedGraph(n);
    for (let i = 0; i < n; i++) {
      for (let j = i + 1; j < n; j++) {
        const r = Math.abs(matrix[i * n + j]);
        if (r > threshold) g.addEdge(i, j, r);
      }
    }
    return g;
  }
  connectedComponents() {
    const visited = new Uint8Array(this.n);
    const components = [];
    for (let start = 0; start < this.n; start++) {
      if (visited[start]) continue;
      const comp = [];
      const queue = [start];
      visited[start] = 1;
      while (queue.length > 0) {
        const u = queue.shift();
        comp.push(u);
        for (const [v] of this.adj[u]) {
          if (!visited[v]) { visited[v] = 1; queue.push(v); }
        }
      }
      components.push(comp);
    }
    return components;
  }
  subgraph(vertices) {
    const newIdx = new Map();
    vertices.forEach((v, i) => newIdx.set(v, i));
    const sub = new WeightedGraph(vertices.length);
    for (const u of vertices) {
      for (const [v, w] of this.adj[u]) {
        if (newIdx.has(v) && u < v) sub.addEdge(newIdx.get(u), newIdx.get(v), w);
      }
    }
    return { graph: sub, mapping: vertices };
  }
}

function stoerWagner(graph) {
  const n = graph.n;
  if (n <= 1) return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };

  const adj = new Array(n);
  for (let i = 0; i < n; i++) adj[i] = new Map(graph.adj[i]);
  const groups = new Array(n);
  for (let i = 0; i < n; i++) groups[i] = [i];

  let activeVertices = Array.from({length: n}, (_, i) => i);
  let bestCut = Infinity;
  let bestPartitionSide = null;

  while (activeVertices.length > 1) {
    const key = new Float64Array(n);
    const inA = new Uint8Array(n);
    let s = -1, t = -1;

    for (let iter = 0; iter < activeVertices.length; iter++) {
      let best = -1, bestKey = -Infinity;
      for (const v of activeVertices) {
        if (!inA[v] && key[v] > bestKey) { bestKey = key[v]; best = v; }
      }
      if (best === -1) {
        for (const v of activeVertices) { if (!inA[v]) { best = v; break; } }
      }
      s = t; t = best; inA[best] = 1;
      if (adj[best]) {
        for (const [nb, w] of adj[best]) {
          if (activeVertices.includes(nb) && !inA[nb]) key[nb] += w;
        }
      }
    }

    let cutOfPhase = 0;
    if (adj[t]) {
      for (const [nb, w] of adj[t]) {
        if (activeVertices.includes(nb) && nb !== t) cutOfPhase += w;
      }
    }

    if (s === -1 || t === -1) break;
    if (cutOfPhase < bestCut) { bestCut = cutOfPhase; bestPartitionSide = [...groups[t]]; }

    if (adj[t]) {
      for (const [nb, w] of adj[t]) {
        if (nb === s) continue;
        const ex = adj[s].get(nb) || 0;
        adj[s].set(nb, ex + w);
        adj[nb].delete(t);
        adj[nb].set(s, ex + w);
      }
    }
    adj[s].delete(t);
    groups[s] = groups[s].concat(groups[t]);
    groups[t] = [];
    activeVertices = activeVertices.filter(v => v !== t);
  }

  if (!bestPartitionSide || bestPartitionSide.length === 0) {
    return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] };
  }
  const sideSet = new Set(bestPartitionSide);
  const sideA = [], sideB = [];
  for (let i = 0; i < n; i++) { (sideSet.has(i) ? sideA : sideB).push(i); }
  return { minCutValue: bestCut, partition: [sideA, sideB] };
}

function separatePersons(graph, cutThreshold, maxPersons) {
  const components = graph.connectedComponents();
  const personGroups = [];
  for (const comp of components) {
    if (comp.length < 2) continue;
    _split(graph, comp, cutThreshold, maxPersons, personGroups);
  }
  return personGroups;
}

function _split(graph, vertices, cutThreshold, maxPersons, result) {
  if (vertices.length < 2 || result.length >= maxPersons) {
    if (vertices.length >= 2) result.push(vertices);
    return;
  }
  const { graph: sub, mapping } = graph.subgraph(vertices);
  const { minCutValue, partition } = stoerWagner(sub);
  if (minCutValue >= cutThreshold || partition[0].length === 0 || partition[1].length === 0) {
    result.push(vertices);
    return;
  }
  _split(graph, partition[0].map(i => mapping[i]), cutThreshold, maxPersons, result);
  _split(graph, partition[1].map(i => mapping[i]), cutThreshold, maxPersons, result);
}

// ---------------------------------------------------------------------------
// Visualization renderers
// ---------------------------------------------------------------------------

/**
 * Render correlation heatmap (downsampled to fit terminal width).
 * Rows and columns = active subcarrier indices.
 */
function renderHeatmap(corr, width) {
  if (!corr || !corr.matrix) return ['  (insufficient data for heatmap)'];
  const { matrix, n, activeIndices } = corr;

  const lines = [];
  lines.push(`${BOLD}Correlation Heatmap${RESET} (${n} active subcarriers, threshold=${CORR_THRESHOLD})`);

  // Downsample if needed
  const maxCols = Math.min(n, width - 8);
  const step = Math.max(1, Math.ceil(n / maxCols));
  const displayN = Math.ceil(n / step);

  // Header row: subcarrier indices
  let header = '      ';
  for (let j = 0; j < displayN; j++) {
    const sc = activeIndices[j * step];
    header += (sc < 10 ? `${sc} ` : `${sc}`).slice(0, 2);
  }
  lines.push(DIM + header + RESET);

  for (let i = 0; i < displayN; i++) {
    const sc = activeIndices[i * step];
    let row = `  ${String(sc).padStart(3)} `;

    for (let j = 0; j < displayN; j++) {
      const ii = i * step, jj = j * step;
      const val = Math.abs(matrix[ii * n + jj]);
      const level = Math.min(10, Math.floor(val * 10));

      if (val > CORR_THRESHOLD) {
        row += `\x1b[33m${HEAT[level]}${RESET} `;
      } else {
        row += `${DIM}${HEAT[level]}${RESET} `;
      }
    }
    lines.push(row);
  }

  return lines;
}

/**
 * Render subcarrier spectrum bar with person cluster coloring.
 */
function renderSpectrum(window, personGroups, activeIndices) {
  const amp = window.latestAmplitudes();
  if (!amp) return ['  (no data)'];

  const lines = [];
  const nSc = window.nSubcarriers;

  // Build subcarrier-to-person mapping
  const scToPerson = new Int8Array(nSc).fill(-1);
  if (personGroups && activeIndices) {
    for (let p = 0; p < personGroups.length; p++) {
      for (const graphIdx of personGroups[p]) {
        if (graphIdx < activeIndices.length) {
          scToPerson[activeIndices[graphIdx]] = p;
        }
      }
    }
  }

  // Find max amplitude for normalization
  let maxAmp = 0;
  for (let i = 0; i < nSc; i++) {
    if (amp[i] > maxAmp) maxAmp = amp[i];
  }
  if (maxAmp === 0) maxAmp = 1;

  lines.push(`${BOLD}Spectrum${RESET} (${nSc} subcarriers, colored by person cluster)`);

  // Render bar
  let bar = '  ';
  for (let i = 0; i < nSc; i++) {
    const level = Math.floor((amp[i] / maxAmp) * 7.99);
    const ch = BARS[Math.max(0, Math.min(7, level))];
    const personIdx = scToPerson[i];
    if (personIdx >= 0 && personIdx < PERSON_COLORS.length) {
      bar += PERSON_COLORS[personIdx] + ch + RESET;
    } else {
      bar += DIM + ch + RESET;
    }
  }
  lines.push(bar);

  // Legend
  let legend = '  ';
  for (let i = 0; i < nSc; i++) {
    const p = scToPerson[i];
    if (p >= 0 && p < PERSON_COLORS.length) {
      legend += PERSON_COLORS[p] + (p + 1) + RESET;
    } else {
      legend += DIM + '.' + RESET;
    }
  }
  lines.push(legend);

  return lines;
}

/**
 * Render cluster summary with per-person statistics.
 */
function renderClusters(personGroups, activeIndices, corr) {
  if (!personGroups || personGroups.length === 0) {
    return ['  No person clusters detected'];
  }

  const lines = [];
  lines.push(`${BOLD}Person Clusters${RESET} (${personGroups.length} detected)`);

  for (let p = 0; p < personGroups.length; p++) {
    const group = personGroups[p];
    const color = p < PERSON_COLORS.length ? PERSON_COLORS[p] : '';

    // Map back to subcarrier indices
    const scIds = group.map(i => activeIndices[i]);
    const scStr = scIds.length <= 16
      ? scIds.join(', ')
      : scIds.slice(0, 14).join(', ') + `, ...+${scIds.length - 14}`;

    // Compute intra-cluster average correlation
    let avgCorr = 0, count = 0;
    if (corr && corr.matrix) {
      for (let i = 0; i < group.length; i++) {
        for (let j = i + 1; j < group.length; j++) {
          avgCorr += Math.abs(corr.matrix[group[i] * corr.n + group[j]]);
          count++;
        }
      }
      if (count > 0) avgCorr /= count;
    }

    lines.push(`  ${color}Person ${p + 1}${RESET}: ${group.length} subcarriers, avg intra-corr=${avgCorr.toFixed(3)}`);
    lines.push(`    ${DIM}SC: [${scStr}]${RESET}`);
  }

  return lines;
}

/**
 * Render graph connectivity summary.
 */
function renderGraphStats(graph, corr) {
  if (!graph) return ['  (no graph)'];

  const lines = [];
  const components = graph.connectedComponents();
  const density = graph.n > 1 ? (2 * graph.edgeCount) / (graph.n * (graph.n - 1)) : 0;

  lines.push(`${BOLD}Graph${RESET}: ${graph.n} nodes, ${graph.edgeCount} edges, density=${density.toFixed(3)}, components=${components.length}`);

  // Degree distribution summary
  const degrees = new Array(graph.n);
  let minDeg = Infinity, maxDeg = 0, sumDeg = 0;
  for (let i = 0; i < graph.n; i++) {
    degrees[i] = graph.adj[i].size;
    if (degrees[i] < minDeg) minDeg = degrees[i];
    if (degrees[i] > maxDeg) maxDeg = degrees[i];
    sumDeg += degrees[i];
  }
  const avgDeg = graph.n > 0 ? sumDeg / graph.n : 0;
  lines.push(`  Degree: min=${minDeg} max=${maxDeg} avg=${avgDeg.toFixed(1)}`);

  return lines;
}

// ---------------------------------------------------------------------------
// Full render
// ---------------------------------------------------------------------------
function render(window, nodeId) {
  const corr = window.correlationMatrix();
  const lines = [];

  const ts = new Date().toISOString().slice(11, 19);
  lines.push(`${BOLD}ADR-075 CSI Graph Visualizer${RESET} [${ts}] Node ${nodeId} | ${window.length} frames`);
  lines.push('═'.repeat(WIDTH));

  let graph = null;
  let personGroups = null;
  let activeIndices = corr ? corr.activeIndices : [];

  if (corr && corr.matrix && corr.n >= 2) {
    graph = WeightedGraph.fromCorrelation(corr.matrix, corr.n, CORR_THRESHOLD);
    personGroups = separatePersons(graph, CUT_THRESHOLD, 8);
  }

  const personCount = personGroups ? personGroups.length : 0;
  lines.push(`${BOLD}Persons: ${personCount}${RESET}  |  Active subcarriers: ${activeIndices.length}/${window.nSubcarriers}`);
  lines.push('');

  if (MODE === 'all' || MODE === 'spectrum') {
    lines.push(...renderSpectrum(window, personGroups, activeIndices));
    lines.push('');
  }

  if (MODE === 'all' || MODE === 'clusters') {
    lines.push(...renderClusters(personGroups, activeIndices, corr));
    lines.push('');
  }

  if (MODE === 'all' || MODE === 'heatmap') {
    lines.push(...renderHeatmap(corr, WIDTH));
    lines.push('');
  }

  if (graph) {
    lines.push(...renderGraphStats(graph, corr));
  }

  lines.push('═'.repeat(WIDTH));
  lines.push(`${DIM}Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}${RESET}`);

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

// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
function parseIqHex(iqHex, nSubcarriers) {
  const bytes = Buffer.from(iqHex, 'hex');
  const amplitudes = new Float64Array(nSubcarriers);
  for (let sc = 0; sc < nSubcarriers; sc++) {
    const offset = 2 + sc * 2;
    if (offset + 1 >= bytes.length) break;
    let I = bytes[offset]; let Q = bytes[offset + 1];
    if (I > 127) I -= 256;
    if (Q > 127) Q -= 256;
    amplitudes[sc] = Math.sqrt(I * I + Q * Q);
  }
  return amplitudes;
}

function parseUdpPacket(buf) {
  if (buf.length < HEADER_SIZE) return null;
  const magic = buf.readUInt32LE(0);
  if (magic !== CSI_MAGIC) return null;
  const nodeId       = buf.readUInt8(4);
  const nSubcarriers = buf.readUInt16LE(6);
  const amplitudes = new Float64Array(nSubcarriers);
  for (let sc = 0; sc < nSubcarriers; sc++) {
    const offset = HEADER_SIZE + sc * 2;
    if (offset + 1 >= buf.length) break;
    const I = buf.readInt8(offset);
    const Q = buf.readInt8(offset + 1);
    amplitudes[sc] = Math.sqrt(I * I + Q * Q);
  }
  return { nodeId, nSubcarriers, amplitudes, timestamp: Date.now() };
}

// ---------------------------------------------------------------------------
// Main: live mode
// ---------------------------------------------------------------------------
function startLive() {
  const windows = new Map();
  const server = dgram.createSocket('udp4');

  server.on('message', (buf) => {
    const frame = parseUdpPacket(buf);
    if (!frame) return;
    if (!windows.has(frame.nodeId)) {
      windows.set(frame.nodeId, new SubcarrierWindow(WINDOW_MS));
    }
    windows.get(frame.nodeId).push(frame.timestamp, frame.amplitudes);
  });

  setInterval(() => {
    process.stdout.write('\x1b[2J\x1b[H');
    for (const [nodeId, window] of windows) {
      if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue;
      console.log(render(window, nodeId));
      console.log();
    }
    if (windows.size === 0) {
      console.log('Waiting for CSI frames on UDP port ' + PORT + '...');
    }
  }, INTERVAL_MS);

  server.bind(PORT, () => {
    console.log(`CSI Graph Visualizer listening on UDP port ${PORT}`);
  });
}

// ---------------------------------------------------------------------------
// Main: replay mode
// ---------------------------------------------------------------------------
async function startReplay(filePath) {
  if (!fs.existsSync(filePath)) {
    console.error(`File not found: ${filePath}`);
    process.exit(1);
  }

  const windows = new Map();
  const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let lastRenderTs = 0;
  let frameCount = 0;

  for await (const line of rl) {
    if (!line.trim()) continue;
    let record;
    try { record = JSON.parse(line); } catch { continue; }
    if (record.type !== 'raw_csi' || !record.iq_hex) continue;

    const nSc = record.subcarriers || 64;
    const amplitudes = parseIqHex(record.iq_hex, nSc);
    const nodeId = record.node_id;
    const tsMs = record.timestamp * 1000;

    if (!windows.has(nodeId)) {
      windows.set(nodeId, new SubcarrierWindow(WINDOW_MS));
    }
    windows.get(nodeId).push(tsMs, amplitudes);
    frameCount++;

    if (lastRenderTs === 0) lastRenderTs = tsMs;
    if (tsMs - lastRenderTs >= INTERVAL_MS) {
      process.stdout.write('\x1b[2J\x1b[H');
      for (const [nid, window] of windows) {
        if (TARGET_NODE !== 0 && nid !== TARGET_NODE) continue;
        console.log(render(window, nid));
        console.log();
      }
      lastRenderTs = tsMs;

      // Small delay for visual effect during replay
      await new Promise(r => setTimeout(r, 100));
    }
  }

  // Final render
  console.log();
  console.log('═'.repeat(WIDTH));
  console.log(`${BOLD}Replay complete${RESET}: ${frameCount} frames`);
  for (const [nodeId, window] of windows) {
    if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue;
    console.log();
    console.log(render(window, nodeId));
  }
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
if (args.replay) {
  startReplay(args.replay);
} else {
  startLive();
}
