#!/usr/bin/env node
/**
 * Through-Wall Motion Detection — Multi-Frequency Mesh Application
 *
 * Detects motion behind walls by exploiting the fact that lower WiFi frequencies
 * penetrate walls better than higher frequencies. With 6 channels spanning
 * 2412-2462 MHz, we can:
 *
 *   1. Baseline each channel's attenuation through the wall (calibration phase)
 *   2. Detect changes above baseline = motion behind wall
 *   3. Weight lower channels more heavily (better through-wall SNR)
 *   4. Cross-validate across channels (real motion is coherent; noise is not)
 *
 * Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping
 * across channels 1, 3, 5, 6, 9, 11.
 *
 * Usage:
 *   node scripts/through-wall-detector.js --calibrate 60
 *   node scripts/through-wall-detector.js --port 5006 --duration 300
 *   node scripts/through-wall-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
 *   node scripts/through-wall-detector.js --threshold 3.0
 *
 * ADR: docs/adr/ADR-078-multifreq-mesh-applications.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' },
    duration:  { type: 'string', short: 'd' },
    replay:    { type: 'string', short: 'r' },
    interval:  { type: 'string', short: 'i', default: '1000' },
    calibrate: { type: 'string', short: 'c', default: '30' },
    threshold: { type: 'string', short: 't', default: '2.5' },
    json:      { type: 'boolean', default: false },
    'consecutive-frames': { type: 'string', default: '3' },
  },
  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 CALIBRATE_S = parseInt(args.calibrate, 10);
const ALERT_THRESHOLD = parseFloat(args.threshold);
const CONSECUTIVE_FRAMES = parseInt(args['consecutive-frames'], 10);
const JSON_OUTPUT = args.json;

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CSI_MAGIC = 0xC5110001;
const HEADER_SIZE = 20;

const CHANNEL_FREQ = {};
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;

const NODE1_CHANNELS = [1, 6, 11];
const NODE2_CHANNELS = [3, 5, 9];

// Channel penetration weights: lower freq = better wall penetration
// Approximate wall loss at each channel for drywall+stud:
//   ch1 (2412 MHz) = 2.5 dB, ch11 (2462 MHz) = 2.7 dB
// Weight inversely proportional to loss
const PENETRATION_WEIGHT = {
  1:  1.00,  // 2412 MHz - best penetration
  3:  0.96,
  5:  0.92,
  6:  0.90,
  9:  0.85,
  11: 0.80,  // 2462 MHz - worst penetration
};

// Status display
const STATUS = {
  CALIBRATING: 'CALIBRATING',
  MONITORING:  'MONITORING',
  ALERT:       'ALERT',
};

// ---------------------------------------------------------------------------
// Per-channel baseline
// ---------------------------------------------------------------------------
class ChannelBaseline {
  constructor(channel) {
    this.channel = channel;
    this.freqMhz = CHANNEL_FREQ[channel] || 2432;
    this.weight = PENETRATION_WEIGHT[channel] || 0.9;

    // Welford online mean/variance
    this.nSub = 0;
    this.count = 0;
    this.mean = null;    // Float64Array
    this.m2 = null;      // Float64Array
    this.calibrated = false;
  }

  /** Ingest a frame during calibration */
  calibrate(amplitudes) {
    const n = amplitudes.length;
    if (!this.mean) {
      this.nSub = n;
      this.mean = new Float64Array(n);
      this.m2 = new Float64Array(n);
    }

    this.count++;
    for (let i = 0; i < n && i < this.nSub; i++) {
      const delta = amplitudes[i] - this.mean[i];
      this.mean[i] += delta / this.count;
      const delta2 = amplitudes[i] - this.mean[i];
      this.m2[i] += delta * delta2;
    }
  }

  /** Finalize calibration */
  finalize() {
    if (this.count < 5) return;
    this.calibrated = true;
  }

  /** Get standard deviation per subcarrier */
  getStd() {
    if (!this.mean || this.count < 2) return null;
    const std = new Float64Array(this.nSub);
    for (let i = 0; i < this.nSub; i++) {
      std[i] = Math.sqrt(this.m2[i] / (this.count - 1));
      // Minimum std to avoid division by zero
      if (std[i] < 0.1) std[i] = 0.1;
    }
    return std;
  }

  /**
   * Compute deviation score for a new frame.
   * Score = mean(|amplitude - baseline_mean| / baseline_std) across subcarriers
   */
  computeDeviation(amplitudes) {
    if (!this.calibrated || !this.mean) return 0;

    const std = this.getStd();
    if (!std) return 0;

    let sumDeviation = 0;
    let count = 0;
    for (let i = 0; i < amplitudes.length && i < this.nSub; i++) {
      const z = Math.abs(amplitudes[i] - this.mean[i]) / std[i];
      sumDeviation += z;
      count++;
    }

    return count > 0 ? sumDeviation / count : 0;
  }
}

// ---------------------------------------------------------------------------
// Through-wall detector
// ---------------------------------------------------------------------------
class ThroughWallDetector {
  constructor(calibrateDuration, alertThreshold, consecutiveFrames) {
    this.calibrateDuration = calibrateDuration;
    this.alertThreshold = alertThreshold;
    this.consecutiveFrames = consecutiveFrames;

    this.baselines = new Map(); // channel -> ChannelBaseline
    this.status = STATUS.CALIBRATING;
    this.startTime = null;

    // Detection state
    this.perChannelScores = new Map();
    this.fusedScore = 0;
    this.alertStreak = 0;
    this.alertActive = false;
    this.alerts = [];

    // History for display
    this.scoreHistory = []; // { timestamp, fusedScore, perChannel }
    this.maxHistory = 60;

    this.totalFrames = 0;
  }

  ingestFrame(channel, amplitudes, timestamp) {
    this.totalFrames++;

    if (!this.startTime) this.startTime = timestamp;

    // Get or create baseline
    if (!this.baselines.has(channel)) {
      this.baselines.set(channel, new ChannelBaseline(channel));
    }
    const baseline = this.baselines.get(channel);

    // Calibration phase
    if (this.status === STATUS.CALIBRATING) {
      baseline.calibrate(amplitudes);

      if (timestamp - this.startTime >= this.calibrateDuration) {
        // Finalize all baselines
        for (const bl of this.baselines.values()) bl.finalize();
        this.status = STATUS.MONITORING;
      }
      return;
    }

    // Detection phase
    const deviation = baseline.computeDeviation(amplitudes);
    const weight = PENETRATION_WEIGHT[channel] || 0.9;
    const weightedScore = deviation * weight;

    this.perChannelScores.set(channel, {
      deviation: deviation,
      weighted: weightedScore,
      channel,
      freqMhz: CHANNEL_FREQ[channel],
    });

    // Fused score: weighted average across all channels
    let sumWeighted = 0, sumWeights = 0;
    for (const [ch, score] of this.perChannelScores) {
      sumWeighted += score.weighted;
      sumWeights += PENETRATION_WEIGHT[ch] || 0.9;
    }
    this.fusedScore = sumWeights > 0 ? sumWeighted / sumWeights : 0;

    // Cross-channel coherence: how many channels agree on motion?
    let agreeCount = 0;
    for (const score of this.perChannelScores.values()) {
      if (score.deviation > this.alertThreshold * 0.5) agreeCount++;
    }
    const coherence = this.perChannelScores.size > 0
      ? agreeCount / this.perChannelScores.size
      : 0;

    // Alert logic
    if (this.fusedScore > this.alertThreshold && coherence > 0.4) {
      this.alertStreak++;
    } else {
      this.alertStreak = Math.max(0, this.alertStreak - 1);
    }

    const wasAlert = this.alertActive;
    this.alertActive = this.alertStreak >= this.consecutiveFrames;

    if (this.alertActive && !wasAlert) {
      this.status = STATUS.ALERT;
      this.alerts.push({
        timestamp,
        fusedScore: this.fusedScore,
        coherence,
        channels: [...this.perChannelScores.values()].map(s => ({
          ch: s.channel, dev: s.deviation.toFixed(2),
        })),
      });
    } else if (!this.alertActive && wasAlert) {
      this.status = STATUS.MONITORING;
    }

    // Store history
    this.scoreHistory.push({
      timestamp,
      fusedScore: this.fusedScore,
      coherence,
      perChannel: [...this.perChannelScores.entries()].map(([ch, s]) => ({
        ch, dev: s.deviation.toFixed(2), weight: (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2),
      })),
    });
    if (this.scoreHistory.length > this.maxHistory) this.scoreHistory.shift();
  }

  getState() {
    return {
      status: this.status,
      fusedScore: this.fusedScore,
      alertActive: this.alertActive,
      alertStreak: this.alertStreak,
      totalFrames: this.totalFrames,
      calibratedChannels: [...this.baselines.values()]
        .filter(b => b.calibrated)
        .map(b => b.channel)
        .sort((a, b) => a - b),
      perChannelScores: [...this.perChannelScores.entries()]
        .sort((a, b) => a[0] - b[0])
        .map(([ch, s]) => ({ ch, deviation: s.deviation.toFixed(2), weighted: s.weighted.toFixed(2) })),
      alertCount: this.alerts.length,
      scoreHistory: this.scoreHistory,
    };
  }
}

// ---------------------------------------------------------------------------
// CSI 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 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 nSubcarriers = buf.readUInt16LE(6);
  const freqMhz = buf.readUInt32LE(8);

  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);
  }

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

  return { nodeId, nSubcarriers, freqMhz, amplitudes, channel };
}

const nodeChannelIdx = { 1: 0, 2: 0 };
function assignChannel(nodeId) {
  const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS;
  const ch = channels[nodeChannelIdx[nodeId] % channels.length];
  nodeChannelIdx[nodeId]++;
  return ch;
}

// ---------------------------------------------------------------------------
// Visualization
// ---------------------------------------------------------------------------
function renderStatus(detector) {
  const state = detector.getState();
  const lines = [];

  lines.push('');
  lines.push('  THROUGH-WALL MOTION DETECTOR');
  lines.push('  ' + '='.repeat(55));
  lines.push('');

  // Status banner
  const statusBanner = {
    [STATUS.CALIBRATING]: '  [ CALIBRATING ] Establishing wall baseline...',
    [STATUS.MONITORING]:  '  [ MONITORING  ] Watching for through-wall motion',
    [STATUS.ALERT]:       '  [ ** ALERT ** ] Motion detected behind wall!',
  };
  lines.push(statusBanner[state.status] || `  [ ${state.status} ]`);
  lines.push('');

  if (state.status === STATUS.CALIBRATING) {
    const progress = Math.min(100, (state.totalFrames / (CALIBRATE_S * 12)) * 100);
    const barLen = Math.floor(progress / 2);
    const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen);
    lines.push(`  Calibration progress: [${bar}] ${progress.toFixed(0)}%`);
    lines.push(`  Frames collected: ${state.totalFrames}`);
    lines.push(`  Channels: ${state.calibratedChannels.length > 0 ? state.calibratedChannels.join(', ') : 'accumulating...'}`);
    return lines.join('\n');
  }

  // Fused score meter
  const maxMeter = 40;
  const meterFill = Math.min(maxMeter, Math.floor((state.fusedScore / (ALERT_THRESHOLD * 2)) * maxMeter));
  const meterChar = state.alertActive ? '\u2588' : '\u2593';
  const meterEmpty = '\u2591';
  const meter = meterChar.repeat(meterFill) + meterEmpty.repeat(maxMeter - meterFill);
  const threshMark = Math.floor((ALERT_THRESHOLD / (ALERT_THRESHOLD * 2)) * maxMeter);
  lines.push(`  Fused score: [${meter}] ${state.fusedScore.toFixed(2)}`);
  lines.push(`  ${''.padStart(15 + threshMark)}^ threshold=${ALERT_THRESHOLD}`);

  // Per-channel breakdown
  lines.push('');
  lines.push('  Per-Channel Deviation (weighted by penetration quality):');
  lines.push('  ' + '-'.repeat(55));
  lines.push('  Ch  Freq(MHz)  Weight  Deviation  Weighted   Status');

  for (const score of state.perChannelScores) {
    const ch = score.ch;
    const freq = CHANNEL_FREQ[ch] || 0;
    const wt = (PENETRATION_WEIGHT[ch] || 0.9).toFixed(2);
    const dev = score.deviation;
    const wtd = score.weighted;
    const above = parseFloat(dev) > ALERT_THRESHOLD * 0.5;
    const marker = above ? ' <--' : '';
    lines.push(`  ${String(ch).padStart(2)}    ${freq}      ${wt}     ${dev.padStart(6)}     ${wtd.padStart(6)}  ${marker}`);
  }

  // Score timeline (last 30 readings)
  const history = state.scoreHistory.slice(-30);
  if (history.length > 0) {
    lines.push('');
    lines.push('  Score Timeline (last 30 readings):');
    const SPARK = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
    let timeline = '  ';
    for (const h of history) {
      const level = Math.min(7, Math.floor((h.fusedScore / (ALERT_THRESHOLD * 2)) * 7.99));
      timeline += SPARK[level];
    }
    lines.push(timeline);
    lines.push(`  ${''.padStart(2)}${'oldest'.padEnd(15)}${''.padEnd(Math.max(0, history.length - 21))}newest`);
  }

  // Alert summary
  lines.push('');
  lines.push(`  Alert history: ${state.alertCount} alert(s)`);
  lines.push(`  Consecutive frames above threshold: ${state.alertStreak}/${CONSECUTIVE_FRAMES}`);
  lines.push(`  Calibrated channels: ${state.calibratedChannels.join(', ')}`);
  lines.push(`  Total frames: ${state.totalFrames}`);

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

// ---------------------------------------------------------------------------
// Global state
// ---------------------------------------------------------------------------
const detector = new ThroughWallDetector(CALIBRATE_S, ALERT_THRESHOLD, CONSECUTIVE_FRAMES);
let lastDisplayMs = 0;

function displayUpdate() {
  const state = detector.getState();

  if (JSON_OUTPUT) {
    console.log(JSON.stringify({
      timestamp: Date.now() / 1000,
      status: state.status,
      fusedScore: +state.fusedScore.toFixed(3),
      alertActive: state.alertActive,
      perChannel: state.perChannelScores,
      alertCount: state.alertCount,
    }));
  } else {
    process.stdout.write('\x1B[2J\x1B[H');
    console.log(renderStatus(detector));
    console.log('');
    console.log('  Press Ctrl+C to exit');
  }
}

// ---------------------------------------------------------------------------
// Live mode
// ---------------------------------------------------------------------------
function startLive() {
  const sock = dgram.createSocket('udp4');

  sock.on('message', (buf) => {
    if (buf.length < 4) return;
    const magic = buf.readUInt32LE(0);
    if (magic !== CSI_MAGIC) return;

    const frame = parseCSIFrame(buf);
    if (!frame) return;

    detector.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000);

    const now = Date.now();
    if (now - lastDisplayMs >= INTERVAL_MS) {
      displayUpdate();
      lastDisplayMs = now;
    }
  });

  sock.bind(PORT, () => {
    if (!JSON_OUTPUT) {
      console.log(`Through-Wall Detector listening on UDP port ${PORT}`);
      console.log(`Calibration period: ${CALIBRATE_S}s`);
      console.log(`Alert threshold: ${ALERT_THRESHOLD}`);
      console.log('Waiting for CSI frames...');
    }
  });

  if (DURATION_MS) {
    setTimeout(() => { displayUpdate(); sock.close(); process.exit(0); }, DURATION_MS);
  }
}

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

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

  let frameCount = 0;
  let lastAnalysisTs = 0;
  let windowCount = 0;
  let firstAlertTs = null;
  let totalAlertWindows = 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 amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64);
    const channel = record.channel || assignChannel(record.node_id);

    detector.ingestFrame(channel, amplitudes, record.timestamp);
    frameCount++;

    const tsMs = record.timestamp * 1000;
    if (lastAnalysisTs === 0) lastAnalysisTs = tsMs;

    if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
      windowCount++;
      const state = detector.getState();

      if (state.alertActive) {
        totalAlertWindows++;
        if (!firstAlertTs) firstAlertTs = record.timestamp;
      }

      if (JSON_OUTPUT) {
        console.log(JSON.stringify({
          window: windowCount,
          timestamp: record.timestamp,
          status: state.status,
          fusedScore: +state.fusedScore.toFixed(3),
          alertActive: state.alertActive,
        }));
      } else {
        const statusTag = state.status === STATUS.ALERT ? ' ** ALERT **' :
          state.status === STATUS.CALIBRATING ? ' calibrating' : '';
        console.log(
          `  [${windowCount.toString().padStart(4)}] t=${record.timestamp.toFixed(1)}s` +
          `  score=${state.fusedScore.toFixed(2).padStart(5)}` +
          `  channels=${state.calibratedChannels.length}` +
          `  streak=${state.alertStreak}/${CONSECUTIVE_FRAMES}` +
          statusTag
        );
      }

      lastAnalysisTs = tsMs;
    }
  }

  // Final summary
  if (!JSON_OUTPUT) {
    const state = detector.getState();
    console.log('');
    console.log('='.repeat(60));
    console.log('THROUGH-WALL DETECTION SUMMARY');
    console.log('='.repeat(60));
    console.log(`  Total frames: ${frameCount}`);
    console.log(`  Analysis windows: ${windowCount}`);
    console.log(`  Calibrated channels: ${state.calibratedChannels.join(', ')}`);
    console.log(`  Alert windows: ${totalAlertWindows} / ${windowCount} (${windowCount > 0 ? (totalAlertWindows / windowCount * 100).toFixed(1) : 0}%)`);
    console.log(`  Total alerts: ${state.alertCount}`);
    if (firstAlertTs) {
      console.log(`  First alert at: t=${firstAlertTs.toFixed(1)}s`);
    }
    console.log(`  Threshold: ${ALERT_THRESHOLD}, Consecutive frames: ${CONSECUTIVE_FRAMES}`);
  }
}

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