#!/usr/bin/env node
/**
 * RuView Multi-Frequency RF Room Scanner
 *
 * Extended version of rf-scan.js that tracks CSI data per WiFi channel and
 * merges multi-channel data into a wideband view. Works when channel hopping
 * is enabled on ESP32 nodes via provision.py --hop-channels.
 *
 * Key capabilities:
 *   - Per-channel subcarrier tracking across 6 WiFi channels
 *   - Wideband merged spectrum (up to 6x subcarrier count)
 *   - Null diversity analysis (what one channel misses, another may see)
 *   - Frequency-dependent scattering identification
 *   - Neighbor network illuminator tracking
 *   - Per-channel penetration quality scoring
 *
 * Usage:
 *   node scripts/rf-scan-multifreq.js
 *   node scripts/rf-scan-multifreq.js --port 5006 --duration 60
 *   node scripts/rf-scan-multifreq.js --json
 *
 * ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
 */

'use strict';

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

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
  options: {
    port:     { type: 'string', short: 'p', default: '5006' },
    duration: { type: 'string', short: 'd' },
    json:     { type: 'boolean', default: false },
    interval: { type: 'string', short: 'i', default: '2000' },
  },
  strict: true,
});

const PORT = parseInt(args.port, 10);
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
const INTERVAL_MS = parseInt(args.interval, 10);
const JSON_OUTPUT = args.json;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC     = 0xC5110001;
const VITALS_MAGIC  = 0xC5110002;
const FEATURE_MAGIC = 0xC5110003;
const FUSED_MAGIC   = 0xC5110004;
const HEADER_SIZE   = 20;

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

const NULL_THRESHOLD      = 2.0;
const DYNAMIC_VAR_THRESH  = 0.15;
const STRONG_AMP_THRESH   = 0.85;

// WiFi 2.4 GHz channel -> center frequency
const CHANNEL_FREQ = {};
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
CHANNEL_FREQ[14] = 2484;

// Non-overlapping channel sets for 2-node mesh
const NODE1_CHANNELS = [1, 6, 11];  // non-overlapping
const NODE2_CHANNELS = [3, 5, 9];   // interleaved, near neighbor APs

// Known neighbor networks (from WiFi scan, used as illuminators)
const KNOWN_ILLUMINATORS = [
  { ssid: 'ruv.net',                    channel: 5,  freq: 2432, signal: 100 },
  { ssid: 'Cohen-Guest',                channel: 5,  freq: 2432, signal: 100 },
  { ssid: 'COGECO-21B20',               channel: 11, freq: 2462, signal: 100 },
  { ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5,  freq: 2432, signal: 94  },
  { ssid: 'conclusion mesh',            channel: 3,  freq: 2422, signal: 44  },
  { ssid: 'NETGEAR72',                  channel: 9,  freq: 2452, signal: 42  },
  { ssid: 'NETGEAR72-Guest',            channel: 9,  freq: 2452, signal: 42  },
  { ssid: 'COGECO-4321',                channel: 11, freq: 2462, signal: 30  },
  { ssid: 'Innanen',                    channel: 6,  freq: 2437, signal: 19  },
];

// ---------------------------------------------------------------------------
// Per-channel state within a node
// ---------------------------------------------------------------------------
class ChannelState {
  constructor(channel) {
    this.channel = channel;
    this.freqMhz = CHANNEL_FREQ[channel] || 0;
    this.nSubcarriers = 0;
    this.frameCount = 0;
    this.firstFrameMs = 0;
    this.lastFrameMs = 0;

    this.amplitudes = new Float64Array(256);
    this.phases = new Float64Array(256);

    // Welford variance per subcarrier
    this.ampMean  = new Float64Array(256);
    this.ampM2    = new Float64Array(256);
    this.ampCount = new Uint32Array(256);

    // Illuminators active on this channel
    this.illuminators = KNOWN_ILLUMINATORS.filter(n => n.channel === channel);
  }

  get fps() {
    if (this.firstFrameMs === 0) return 0;
    const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
    return elapsed > 0 ? this.frameCount / elapsed : 0;
  }

  update(amplitudes, phases) {
    const n = amplitudes.length;
    this.nSubcarriers = n;
    this.frameCount++;
    const now = Date.now();
    if (this.firstFrameMs === 0) this.firstFrameMs = now;
    this.lastFrameMs = now;

    for (let i = 0; i < n; i++) {
      this.amplitudes[i] = amplitudes[i];
      this.phases[i] = phases[i];

      this.ampCount[i]++;
      const delta = amplitudes[i] - this.ampMean[i];
      this.ampMean[i] += delta / this.ampCount[i];
      const delta2 = amplitudes[i] - this.ampMean[i];
      this.ampM2[i] += delta * delta2;
    }
  }

  getVariance(i) {
    return this.ampCount[i] > 1 ? this.ampM2[i] / (this.ampCount[i] - 1) : 0;
  }

  getNulls() {
    const nulls = [];
    for (let i = 0; i < this.nSubcarriers; i++) {
      if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
    }
    return nulls;
  }

  getNullPercent() {
    if (this.nSubcarriers === 0) return 0;
    return (this.getNulls().length / this.nSubcarriers) * 100;
  }

  classify() {
    const n = this.nSubcarriers;
    if (n === 0) return { nulls: [], dynamic: [], reflectors: [], walls: [] };

    let maxAmp = 0;
    for (let i = 0; i < n; i++) {
      if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
    }
    if (maxAmp === 0) maxAmp = 1;

    const nulls = [], dynamic = [], reflectors = [], walls = [];
    for (let i = 0; i < n; i++) {
      const normAmp = this.amplitudes[i] / maxAmp;
      const variance = this.getVariance(i);

      if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
      else if (variance > DYNAMIC_VAR_THRESH) dynamic.push(i);
      else if (normAmp > STRONG_AMP_THRESH) reflectors.push(i);
      else walls.push(i);
    }

    return { nulls, dynamic, reflectors, walls };
  }

  getSpectrumBar() {
    const n = this.nSubcarriers;
    if (n === 0) return '';

    let maxAmp = 0;
    for (let i = 0; i < n; i++) {
      if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
    }
    if (maxAmp === 0) maxAmp = 1;

    let bar = '';
    for (let i = 0; i < n; i++) {
      const level = Math.floor((this.amplitudes[i] / maxAmp) * 7.99);
      bar += BARS[Math.max(0, Math.min(7, level))];
    }
    return bar;
  }
}

// ---------------------------------------------------------------------------
// Per-node state (multi-channel)
// ---------------------------------------------------------------------------
class NodeState {
  constructor(nodeId) {
    this.nodeId = nodeId;
    this.address = null;
    this.channels = new Map();  // channel number -> ChannelState
    this.totalFrames = 0;
    this.firstFrameMs = Date.now();
    this.lastFrameMs = Date.now();
    this.rssi = 0;
    this.vitals = null;
    this.features = null;
  }

  get fps() {
    const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
    return elapsed > 0 ? this.totalFrames / elapsed : 0;
  }

  getOrCreateChannel(channel) {
    if (!this.channels.has(channel)) {
      this.channels.set(channel, new ChannelState(channel));
    }
    return this.channels.get(channel);
  }

  getActiveChannels() {
    return [...this.channels.values()]
      .filter(cs => cs.frameCount > 0)
      .sort((a, b) => a.channel - b.channel);
  }
}

// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const nodes = new Map();
const startTime = Date.now();
let totalFrames = 0;

// ---------------------------------------------------------------------------
// Packet parsing (same as rf-scan.js)
// ---------------------------------------------------------------------------
function parseCSIFrame(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 nAntennas    = buf.readUInt8(5) || 1;
  const nSubcarriers = buf.readUInt16LE(6);
  const freqMhz      = buf.readUInt32LE(8);
  const seq          = buf.readUInt32LE(12);
  const rssi         = buf.readInt8(16);
  const noiseFloor   = buf.readInt8(17);

  const iqLen = nSubcarriers * nAntennas * 2;
  if (buf.length < HEADER_SIZE + iqLen) return null;

  const amplitudes = new Float64Array(nSubcarriers);
  const phases = new Float64Array(nSubcarriers);

  for (let sc = 0; sc < nSubcarriers; sc++) {
    const offset = HEADER_SIZE + sc * 2;
    const I = buf.readInt8(offset);
    const Q = buf.readInt8(offset + 1);
    amplitudes[sc] = Math.sqrt(I * I + Q * Q);
    phases[sc] = Math.atan2(Q, I);
  }

  // Derive channel from frequency
  let channel = 0;
  if (freqMhz >= 2412 && freqMhz <= 2484) {
    channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
  } else if (freqMhz >= 5180) {
    channel = Math.round((freqMhz - 5000) / 5);
  }

  return {
    nodeId, nAntennas, nSubcarriers, freqMhz, seq, rssi, noiseFloor,
    amplitudes, phases, channel,
  };
}

function parseVitalsPacket(buf) {
  if (buf.length < 32) return null;
  const magic = buf.readUInt32LE(0);
  if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;

  return {
    nodeId:        buf.readUInt8(4),
    flags:         buf.readUInt8(5),
    presence:      !!(buf.readUInt8(5) & 0x01),
    fall:          !!(buf.readUInt8(5) & 0x02),
    motion:        !!(buf.readUInt8(5) & 0x04),
    breathingRate: buf.readUInt16LE(6) / 100,
    heartrate:     buf.readUInt32LE(8) / 10000,
    rssi:          buf.readInt8(12),
    nPersons:      buf.readUInt8(13),
    motionEnergy:  buf.readFloatLE(16),
    presenceScore: buf.readFloatLE(20),
    timestampMs:   buf.readUInt32LE(24),
  };
}

function parseFeaturePacket(buf) {
  if (buf.length < 48) return null;
  const magic = buf.readUInt32LE(0);
  if (magic !== FEATURE_MAGIC) return null;

  const features = [];
  for (let i = 0; i < 8; i++) features.push(buf.readFloatLE(12 + i * 4));
  return { nodeId: buf.readUInt8(4), seq: buf.readUInt16LE(6), features };
}

function handlePacket(buf, rinfo) {
  if (buf.length < 4) return;
  const magic = buf.readUInt32LE(0);

  if (magic === CSI_MAGIC) {
    const frame = parseCSIFrame(buf);
    if (!frame) return;

    totalFrames++;
    let node = nodes.get(frame.nodeId);
    if (!node) {
      node = new NodeState(frame.nodeId);
      nodes.set(frame.nodeId, node);
    }

    node.address = rinfo.address;
    node.rssi = frame.rssi;
    node.totalFrames++;
    node.lastFrameMs = Date.now();

    const cs = node.getOrCreateChannel(frame.channel);
    cs.update(frame.amplitudes, frame.phases);
    return;
  }

  if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
    const vitals = parseVitalsPacket(buf);
    if (!vitals) return;
    let node = nodes.get(vitals.nodeId);
    if (!node) { node = new NodeState(vitals.nodeId); nodes.set(vitals.nodeId, node); }
    node.vitals = vitals;
    return;
  }

  if (magic === FEATURE_MAGIC) {
    const feat = parseFeaturePacket(buf);
    if (!feat) return;
    let node = nodes.get(feat.nodeId);
    if (!node) { node = new NodeState(feat.nodeId); nodes.set(feat.nodeId, node); }
    node.features = feat;
  }
}

// ---------------------------------------------------------------------------
// Multi-frequency analysis
// ---------------------------------------------------------------------------

/**
 * Compute null diversity: how many null subcarriers on one channel are
 * resolved (non-null) on another channel. This is the core benefit of
 * multi-frequency scanning.
 */
function computeNullDiversity() {
  // Collect all channel states across all nodes
  const allChannelStates = [];
  for (const node of nodes.values()) {
    for (const cs of node.channels.values()) {
      if (cs.frameCount > 0) allChannelStates.push(cs);
    }
  }

  if (allChannelStates.length < 2) return null;

  // For each channel, get its null set
  const channelNulls = new Map();
  for (const cs of allChannelStates) {
    const key = cs.channel;
    if (!channelNulls.has(key)) {
      channelNulls.set(key, { channel: key, nulls: new Set(cs.getNulls()), nSub: cs.nSubcarriers });
    }
  }

  if (channelNulls.size < 2) return null;

  const channels = [...channelNulls.keys()].sort((a, b) => a - b);

  // Compute pairwise null diversity
  const pairwise = [];
  for (let i = 0; i < channels.length; i++) {
    for (let j = i + 1; j < channels.length; j++) {
      const c1 = channelNulls.get(channels[i]);
      const c2 = channelNulls.get(channels[j]);

      // Nulls on c1 that c2 resolves (non-null on c2)
      let c1ResolvedByC2 = 0;
      let c2ResolvedByC1 = 0;
      let sharedNulls = 0;

      for (const idx of c1.nulls) {
        if (!c2.nulls.has(idx)) c1ResolvedByC2++;
        else sharedNulls++;
      }
      for (const idx of c2.nulls) {
        if (!c1.nulls.has(idx)) c2ResolvedByC1++;
      }

      pairwise.push({
        ch1: channels[i], ch2: channels[j],
        ch1Nulls: c1.nulls.size, ch2Nulls: c2.nulls.size,
        sharedNulls,
        ch1ResolvedByC2: c1ResolvedByC2,
        ch2ResolvedByC1: c2ResolvedByC1,
      });
    }
  }

  // Global: union of all nulls vs intersection
  const allNullSets = [...channelNulls.values()].map(c => c.nulls);
  const unionNulls = new Set();
  for (const s of allNullSets) for (const idx of s) unionNulls.add(idx);

  let intersectionCount = 0;
  for (const idx of unionNulls) {
    if (allNullSets.every(s => s.has(idx))) intersectionCount++;
  }

  // Effective null rate after multi-channel fusion
  const maxSub = Math.max(...[...channelNulls.values()].map(c => c.nSub));
  const singleChannelNulls = allNullSets[0].size;
  const fusedNulls = intersectionCount;  // only nulls present on ALL channels

  return {
    channels,
    pairwise,
    singleChannelNulls,
    fusedNulls,
    unionNulls: unionNulls.size,
    maxSubcarriers: maxSub,
    singleNullPct: maxSub > 0 ? ((singleChannelNulls / maxSub) * 100).toFixed(1) : '0',
    fusedNullPct: maxSub > 0 ? ((fusedNulls / maxSub) * 100).toFixed(1) : '0',
    diversityGain: singleChannelNulls > 0
      ? ((1 - fusedNulls / singleChannelNulls) * 100).toFixed(1)
      : '0',
  };
}

/**
 * Find objects visible on some channels but not others.
 * These are frequency-dependent scatterers (interesting for material classification).
 */
function findFrequencyDependentObjects() {
  const allChannelStates = [];
  for (const node of nodes.values()) {
    for (const cs of node.channels.values()) {
      if (cs.frameCount > 0 && cs.nSubcarriers > 0) allChannelStates.push(cs);
    }
  }

  if (allChannelStates.length < 2) return [];

  const results = [];
  const nSub = Math.min(...allChannelStates.map(cs => cs.nSubcarriers));

  for (let i = 0; i < nSub; i++) {
    const amps = allChannelStates.map(cs => cs.amplitudes[i]);
    const vars = allChannelStates.map(cs => cs.getVariance(i));
    const maxAmp = Math.max(...amps);
    const minAmp = Math.min(...amps);

    // Large amplitude spread across channels = frequency-dependent scatterer
    if (maxAmp > 0 && (maxAmp - minAmp) / maxAmp > 0.5) {
      const bestCh = allChannelStates[amps.indexOf(maxAmp)].channel;
      const worstCh = allChannelStates[amps.indexOf(minAmp)].channel;
      results.push({
        subcarrier: i,
        maxAmp: maxAmp.toFixed(1),
        minAmp: minAmp.toFixed(1),
        bestChannel: bestCh,
        worstChannel: worstCh,
        spread: ((maxAmp - minAmp) / maxAmp * 100).toFixed(0),
      });
    }
  }

  return results.slice(0, 20);  // top 20
}

/**
 * Compute per-channel penetration quality score.
 * Lower frequency channels (ch 1 = 2412 MHz) have slightly longer wavelength
 * and better penetration through some materials.
 */
function computePenetrationScores() {
  const scores = [];

  for (const node of nodes.values()) {
    for (const cs of node.channels.values()) {
      if (cs.frameCount === 0 || cs.nSubcarriers === 0) continue;

      // Mean amplitude (higher = better penetration)
      let sumAmp = 0;
      for (let i = 0; i < cs.nSubcarriers; i++) sumAmp += cs.amplitudes[i];
      const meanAmp = sumAmp / cs.nSubcarriers;

      // Null rate (lower = better)
      const nullPct = cs.getNullPercent();

      // Spectrum flatness = geometric mean / arithmetic mean
      // Flatter spectrum = more uniform penetration
      let logSum = 0;
      let count = 0;
      for (let i = 0; i < cs.nSubcarriers; i++) {
        if (cs.amplitudes[i] > 0) {
          logSum += Math.log(cs.amplitudes[i]);
          count++;
        }
      }
      const geoMean = count > 0 ? Math.exp(logSum / count) : 0;
      const flatness = sumAmp > 0 ? geoMean / meanAmp : 0;

      // Quality score: weighted combination
      const quality = (meanAmp / 20) * 0.4 + (1 - nullPct / 100) * 0.3 + flatness * 0.3;

      scores.push({
        nodeId: node.nodeId,
        channel: cs.channel,
        freqMhz: cs.freqMhz,
        fps: cs.fps.toFixed(1),
        meanAmp: meanAmp.toFixed(1),
        nullPct: nullPct.toFixed(1),
        flatness: flatness.toFixed(3),
        quality: quality.toFixed(3),
        illuminators: cs.illuminators.map(il => il.ssid),
      });
    }
  }

  return scores.sort((a, b) => parseFloat(b.quality) - parseFloat(a.quality));
}

// ---------------------------------------------------------------------------
// Wideband merged view
// ---------------------------------------------------------------------------
function buildWidebandSpectrum() {
  // Collect all channel amplitudes into one wide view
  const allChannels = [];
  for (const node of nodes.values()) {
    for (const cs of node.getActiveChannels()) {
      allChannels.push(cs);
    }
  }

  if (allChannels.length === 0) return { bar: '', channels: 0, totalSubcarriers: 0 };

  // Sort by frequency
  allChannels.sort((a, b) => a.freqMhz - b.freqMhz);

  let totalSub = 0;
  for (const cs of allChannels) totalSub += cs.nSubcarriers;

  // Find global max amplitude for normalization
  let globalMax = 0;
  for (const cs of allChannels) {
    for (let i = 0; i < cs.nSubcarriers; i++) {
      if (cs.amplitudes[i] > globalMax) globalMax = cs.amplitudes[i];
    }
  }
  if (globalMax === 0) globalMax = 1;

  // Build wideband bar with channel separators
  let bar = '';
  let labels = '';
  for (let c = 0; c < allChannels.length; c++) {
    const cs = allChannels[c];
    if (c > 0) {
      bar += '|';
      labels += '|';
    }

    const chLabel = `ch${cs.channel}`;
    labels += chLabel + ' '.repeat(Math.max(0, cs.nSubcarriers - chLabel.length));

    for (let i = 0; i < cs.nSubcarriers; i++) {
      const level = Math.floor((cs.amplitudes[i] / globalMax) * 7.99);
      bar += BARS[Math.max(0, Math.min(7, level))];
    }
  }

  return { bar, labels, channels: allChannels.length, totalSubcarriers: totalSub };
}

// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function buildProgressBar(value, max, width) {
  const filled = Math.round((value / max) * width);
  return '\u2588'.repeat(Math.min(filled, width)) +
         '\u2591'.repeat(Math.max(0, width - filled));
}

function renderASCII() {
  const lines = [];
  const nodeList = [...nodes.values()];
  const activeNodes = nodeList.filter(n => n.totalFrames > 0);

  if (activeNodes.length === 0) {
    lines.push(`=== RUVIEW MULTI-FREQ RF SCAN === Listening on UDP :${PORT}`);
    lines.push('Waiting for CSI frames from ESP32 nodes...');
    lines.push('Enable channel hopping: python provision.py --port COMx --hop-channels 1,6,11');
    lines.push(`Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s | Frames: ${totalFrames}`);
    return lines.join('\n');
  }

  lines.push('=== RUVIEW MULTI-FREQUENCY RF SCAN ===');
  lines.push('');

  // Per-node, per-channel view
  for (const node of activeNodes) {
    lines.push(`--- Node ${node.nodeId} (${node.address || '?'}) | ${node.fps.toFixed(1)} fps total | RSSI ${node.rssi} dBm ---`);

    const activeChannels = node.getActiveChannels();
    if (activeChannels.length === 0) {
      lines.push('  (no channel data yet)');
      continue;
    }

    for (const cs of activeChannels) {
      const cls = cs.classify();
      const spectrum = cs.getSpectrumBar();
      const nullPct = cs.getNullPercent().toFixed(0);
      const ilNames = cs.illuminators.length > 0
        ? cs.illuminators.map(il => il.ssid).join(', ')
        : 'none';

      lines.push(`  Ch ${String(cs.channel).padStart(2)} (${cs.freqMhz} MHz) | ${cs.fps.toFixed(1)} fps | nulls: ${nullPct}% | illuminators: ${ilNames}`);
      if (spectrum.length > 0) {
        // Truncate spectrum to terminal width (approx)
        const maxWidth = 80;
        const truncated = spectrum.length > maxWidth
          ? spectrum.slice(0, maxWidth) + '...'
          : spectrum;
        lines.push(`    ${truncated}`);
      }
      lines.push(`    ${cls.nulls.length} null | ${cls.dynamic.length} dynamic | ${cls.reflectors.length} reflector | ${cls.walls.length} static`);
    }

    // Vitals
    if (node.vitals) {
      const v = node.vitals;
      lines.push(`  Vitals: BR ${v.breathingRate.toFixed(0)} BPM | HR ${v.heartrate.toFixed(0)} BPM | presence ${v.presenceScore.toFixed(2)} | ${v.nPersons} person(s)`);
    }

    lines.push('');
  }

  // Wideband merged view
  const wideband = buildWidebandSpectrum();
  if (wideband.channels > 1) {
    lines.push('--- Wideband Merged Spectrum ---');
    const maxWidth = 100;
    const truncBar = wideband.bar.length > maxWidth
      ? wideband.bar.slice(0, maxWidth) + '...'
      : wideband.bar;
    lines.push(`  ${truncBar}`);
    lines.push(`  ${wideband.channels} channels | ${wideband.totalSubcarriers} total subcarriers`);
    lines.push('');
  }

  // Null diversity analysis
  const diversity = computeNullDiversity();
  if (diversity) {
    lines.push('--- Null Diversity Analysis ---');
    lines.push(`  Single-channel nulls: ${diversity.singleChannelNulls} (${diversity.singleNullPct}%)`);
    lines.push(`  Multi-channel fused:  ${diversity.fusedNulls} (${diversity.fusedNullPct}%) -- only nulls on ALL channels`);
    lines.push(`  Diversity gain:       ${diversity.diversityGain}% of nulls resolved by other channels`);

    if (diversity.pairwise.length > 0) {
      lines.push('  Pairwise:');
      for (const p of diversity.pairwise) {
        lines.push(`    ch${p.ch1}<->ch${p.ch2}: ${p.sharedNulls} shared | ch${p.ch1} resolves ${p.ch2ResolvedByC1} of ch${p.ch2}'s nulls | ch${p.ch2} resolves ${p.ch1ResolvedByC2} of ch${p.ch1}'s nulls`);
      }
    }
    lines.push('');
  }

  // Penetration scores
  const penScores = computePenetrationScores();
  if (penScores.length > 0) {
    lines.push('--- Per-Channel Penetration Quality ---');
    lines.push('  Ch   Freq     FPS   MeanAmp  Null%  Flat   Quality  Illuminators');
    for (const s of penScores) {
      const ilStr = s.illuminators.length > 0 ? s.illuminators.slice(0, 2).join(', ') : '-';
      lines.push(`  ${String(s.channel).padStart(2)}   ${s.freqMhz} MHz  ${String(s.fps).padStart(5)}  ${String(s.meanAmp).padStart(7)}  ${String(s.nullPct).padStart(5)}  ${s.flatness}  ${s.quality}    ${ilStr}`);
    }
    lines.push('');
  }

  // Frequency-dependent scatterers
  const scatterers = findFrequencyDependentObjects();
  if (scatterers.length > 0) {
    lines.push(`--- Frequency-Dependent Scatterers (${scatterers.length} found) ---`);
    lines.push('  Sub#  Best Ch  Worst Ch  Spread  MaxAmp  MinAmp');
    for (const s of scatterers.slice(0, 10)) {
      lines.push(`  ${String(s.subcarrier).padStart(4)}  ch${String(s.bestChannel).padStart(2)}     ch${String(s.worstChannel).padStart(2)}      ${String(s.spread).padStart(3)}%    ${String(s.maxAmp).padStart(6)}  ${String(s.minAmp).padStart(6)}`);
    }
    lines.push('  (Objects visible on some frequencies but not others -- different materials)');
    lines.push('');
  }

  // Summary
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
  lines.push(`Elapsed: ${elapsed}s | Total frames: ${totalFrames} | Nodes: ${activeNodes.length}`);
  if (DURATION_MS) {
    const remaining = Math.max(0, (DURATION_MS - (Date.now() - startTime)) / 1000).toFixed(0);
    lines.push(`Remaining: ${remaining}s`);
  }

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

function buildJsonOutput() {
  const activeNodes = [...nodes.values()].filter(n => n.totalFrames > 0);

  return {
    timestamp: new Date().toISOString(),
    elapsedMs: Date.now() - startTime,
    totalFrames,
    nodes: activeNodes.map(node => ({
      nodeId: node.nodeId,
      address: node.address,
      fps: parseFloat(node.fps.toFixed(2)),
      totalFrames: node.totalFrames,
      channels: node.getActiveChannels().map(cs => {
        const cls = cs.classify();
        return {
          channel: cs.channel,
          freqMhz: cs.freqMhz,
          fps: parseFloat(cs.fps.toFixed(2)),
          nSubcarriers: cs.nSubcarriers,
          frameCount: cs.frameCount,
          classification: {
            nullCount: cls.nulls.length,
            dynamicCount: cls.dynamic.length,
            reflectorCount: cls.reflectors.length,
            staticCount: cls.walls.length,
            nullPercent: parseFloat(cs.getNullPercent().toFixed(1)),
          },
          illuminators: cs.illuminators.map(il => il.ssid),
          amplitudes: Array.from(cs.amplitudes.subarray(0, cs.nSubcarriers)),
          phases: Array.from(cs.phases.subarray(0, cs.nSubcarriers)),
        };
      }),
      vitals: node.vitals,
      features: node.features ? node.features.features : null,
    })),
    nullDiversity: computeNullDiversity(),
    penetrationScores: computePenetrationScores(),
    frequencyDependentScatterers: findFrequencyDependentObjects(),
    wideband: (() => {
      const wb = buildWidebandSpectrum();
      return { channels: wb.channels, totalSubcarriers: wb.totalSubcarriers };
    })(),
  };
}

function display() {
  if (JSON_OUTPUT) {
    process.stdout.write(JSON.stringify(buildJsonOutput()) + '\n');
  } else {
    process.stdout.write('\x1B[2J\x1B[H');
    process.stdout.write(renderASCII() + '\n');
  }
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
function main() {
  const server = dgram.createSocket('udp4');

  server.on('error', (err) => {
    console.error(`UDP error: ${err.message}`);
    server.close();
    process.exit(1);
  });

  server.on('message', (msg, rinfo) => {
    handlePacket(msg, rinfo);
  });

  server.on('listening', () => {
    const addr = server.address();
    if (!JSON_OUTPUT) {
      console.log(`RuView Multi-Frequency RF Scanner listening on ${addr.address}:${addr.port}`);
      console.log('Waiting for CSI frames from ESP32 nodes...');
      console.log('Tip: Enable channel hopping with provision.py --hop-channels 1,6,11\n');
    }
  });

  server.bind(PORT);

  const displayTimer = setInterval(display, INTERVAL_MS);

  if (DURATION_MS) {
    setTimeout(() => {
      clearInterval(displayTimer);

      if (JSON_OUTPUT) {
        const summary = buildJsonOutput();
        summary.final = true;
        process.stdout.write(JSON.stringify(summary) + '\n');
      } else {
        display();
        console.log('\n--- Multi-frequency scan complete ---');

        const diversity = computeNullDiversity();
        if (diversity) {
          console.log(`Null diversity gain: ${diversity.diversityGain}% (${diversity.singleNullPct}% -> ${diversity.fusedNullPct}%)`);
        }

        console.log(`Total frames: ${totalFrames}`);
        console.log(`Nodes: ${nodes.size}`);

        for (const node of nodes.values()) {
          const chList = node.getActiveChannels().map(cs => `ch${cs.channel}`).join(', ');
          console.log(`  Node ${node.nodeId}: ${node.totalFrames} frames, channels: [${chList}]`);
        }
      }

      server.close();
      process.exit(0);
    }, DURATION_MS);
  }

  process.on('SIGINT', () => {
    clearInterval(displayTimer);
    if (!JSON_OUTPUT) console.log('\nShutting down...');
    server.close();
    process.exit(0);
  });
}

main();
