#!/usr/bin/env node
/**
 * ADR-077: Material/Object Change Detection
 *
 * Monitors CSI subcarrier null patterns to detect when objects (metal, water,
 * wood, glass) are introduced, removed, or moved in the sensing area.
 *
 * Usage:
 *   node scripts/material-detector.js --replay data/recordings/overnight-1775217646.csi.jsonl
 *   node scripts/material-detector.js --port 5006
 *   node scripts/material-detector.js --replay FILE --json
 *   node scripts/material-detector.js --replay FILE --baseline-time 120
 *
 * ADR: docs/adr/ADR-077-novel-rf-sensing-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' },
    replay:         { type: 'string', short: 'r' },
    json:           { type: 'boolean', default: false },
    interval:       { type: 'string', short: 'i', default: '5000' },
    'baseline-time': { type: 'string', default: '60' },
    'null-threshold': { type: 'string', default: '2.0' },
    'change-threshold': { type: 'string', default: '3' },
  },
  strict: true,
});

const PORT             = parseInt(args.port, 10);
const JSON_OUTPUT      = args.json;
const INTERVAL_MS      = parseInt(args.interval, 10);
const BASELINE_SEC     = parseInt(args['baseline-time'], 10);
const NULL_THRESHOLD   = parseFloat(args['null-threshold']);
const CHANGE_THRESHOLD = parseInt(args['change-threshold'], 10); // min subcarriers changed

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

// ---------------------------------------------------------------------------
// Subcarrier null pattern tracker
// ---------------------------------------------------------------------------
class NullPatternTracker {
  constructor(nSubcarriers) {
    this.nSc = nSubcarriers || 64;

    // Baseline (Welford mean per subcarrier)
    this.baselineMean = new Float64Array(256);
    this.baselineCount = new Uint32Array(256);
    this.baselineEstablished = false;
    this.baselineNulls = new Set();

    // Current window state
    this.currentAmps = new Float64Array(256);
    this.currentCount = 0;

    // Events
    this.events = [];
    this.startTime = null;
    this.lastTime = null;
  }

  updateBaseline(amplitudes) {
    const n = amplitudes.length;
    this.nSc = n;
    for (let i = 0; i < n; i++) {
      this.baselineCount[i]++;
      const delta = amplitudes[i] - this.baselineMean[i];
      this.baselineMean[i] += delta / this.baselineCount[i];
    }
  }

  finalizeBaseline() {
    this.baselineNulls = new Set();
    for (let i = 0; i < this.nSc; i++) {
      if (this.baselineMean[i] < NULL_THRESHOLD) {
        this.baselineNulls.add(i);
      }
    }
    this.baselineEstablished = true;
  }

  updateCurrent(amplitudes) {
    const n = amplitudes.length;
    // Exponential moving average for current window
    const alpha = 0.1;
    if (this.currentCount === 0) {
      for (let i = 0; i < n; i++) this.currentAmps[i] = amplitudes[i];
    } else {
      for (let i = 0; i < n; i++) {
        this.currentAmps[i] = this.currentAmps[i] * (1 - alpha) + amplitudes[i] * alpha;
      }
    }
    this.currentCount++;
  }

  detectChanges(timestamp) {
    if (!this.baselineEstablished || this.currentCount < 5) return null;

    const currentNulls = new Set();
    for (let i = 0; i < this.nSc; i++) {
      if (this.currentAmps[i] < NULL_THRESHOLD) {
        currentNulls.add(i);
      }
    }

    // Find differences
    const newNulls = [];      // appeared (something blocking)
    const removedNulls = [];  // disappeared (object removed)
    const shiftedNulls = [];  // nearby shifts

    for (const sc of currentNulls) {
      if (!this.baselineNulls.has(sc)) newNulls.push(sc);
    }
    for (const sc of this.baselineNulls) {
      if (!currentNulls.has(sc)) removedNulls.push(sc);
    }

    // Detect shifts (null moved by 1-3 subcarriers)
    for (const newSc of newNulls) {
      for (const rmSc of removedNulls) {
        if (Math.abs(newSc - rmSc) <= 3) {
          shiftedNulls.push({ from: rmSc, to: newSc });
        }
      }
    }

    // Amplitude changes (non-null subcarriers with significant amplitude shift)
    const ampChanges = [];
    for (let i = 0; i < this.nSc; i++) {
      if (this.baselineMean[i] > NULL_THRESHOLD && this.currentAmps[i] > NULL_THRESHOLD) {
        const ratio = this.currentAmps[i] / this.baselineMean[i];
        if (ratio < 0.5 || ratio > 2.0) {
          ampChanges.push({ sc: i, baseline: this.baselineMean[i], current: this.currentAmps[i], ratio });
        }
      }
    }

    // Material classification
    let material = 'unknown';
    if (newNulls.length > 0) {
      // Null pattern indicates metal
      if (newNulls.length <= 5) material = 'metal (small object)';
      else if (newNulls.length <= 15) material = 'metal (medium)';
      else material = 'metal (large)';
    } else if (ampChanges.length > this.nSc * 0.3) {
      // Broad amplitude change = water or human
      const avgRatio = ampChanges.reduce((s, c) => s + c.ratio, 0) / ampChanges.length;
      material = avgRatio < 1 ? 'water/human (absorption)' : 'reflective surface';
    } else if (ampChanges.length > 0 && ampChanges.length <= this.nSc * 0.1) {
      material = 'wood/plastic (minimal)';
    }

    const totalChanges = newNulls.length + removedNulls.length + ampChanges.length;

    // Only report if significant changes
    if (totalChanges < CHANGE_THRESHOLD) {
      return {
        timestamp,
        changeDetected: false,
        currentNullCount: currentNulls.size,
        baselineNullCount: this.baselineNulls.size,
      };
    }

    // Determine event type
    let eventType;
    if (shiftedNulls.length > 0) eventType = 'moved';
    else if (newNulls.length > removedNulls.length) eventType = 'added';
    else if (removedNulls.length > newNulls.length) eventType = 'removed';
    else eventType = 'changed';

    const event = {
      timestamp,
      changeDetected: true,
      eventType,
      material,
      newNulls: newNulls.length,
      removedNulls: removedNulls.length,
      shiftedNulls: shiftedNulls.length,
      ampChanges: ampChanges.length,
      newNullRange: newNulls.length > 0 ? [Math.min(...newNulls), Math.max(...newNulls)] : null,
      removedNullRange: removedNulls.length > 0 ? [Math.min(...removedNulls), Math.max(...removedNulls)] : null,
      currentNullCount: currentNulls.size,
      baselineNullCount: this.baselineNulls.size,
      nullDelta: currentNulls.size - this.baselineNulls.size,
    };

    this.events.push(event);
    return event;
  }

  renderNullMap() {
    const chars = [];
    for (let i = 0; i < this.nSc; i++) {
      if (this.currentAmps[i] < NULL_THRESHOLD) {
        if (this.baselineNulls.has(i)) chars.push('_'); // baseline null
        else chars.push('X'); // new null
      } else if (this.baselineNulls.has(i)) {
        chars.push('O'); // removed null
      } else {
        chars.push('\u2581'); // normal
      }
    }
    return chars.join('');
  }
}

// ---------------------------------------------------------------------------
// Multi-node manager
// ---------------------------------------------------------------------------
class MaterialDetector {
  constructor() {
    this.trackers = new Map(); // nodeId -> NullPatternTracker
    this.startTime = null;
    this.allEvents = [];
  }

  ingestCSI(nodeId, timestamp, amplitudes) {
    if (!this.startTime) this.startTime = timestamp;

    if (!this.trackers.has(nodeId)) {
      this.trackers.set(nodeId, new NullPatternTracker(amplitudes.length));
    }
    const tracker = this.trackers.get(nodeId);
    tracker.lastTime = timestamp;
    if (!tracker.startTime) tracker.startTime = timestamp;

    // Baseline phase
    const elapsed = timestamp - tracker.startTime;
    if (elapsed < BASELINE_SEC) {
      tracker.updateBaseline(amplitudes);
      return null;
    }

    // Finalize baseline on transition
    if (!tracker.baselineEstablished) {
      tracker.finalizeBaseline();
    }

    tracker.updateCurrent(amplitudes);
    return null; // actual detection happens on analyze() call
  }

  analyze(timestamp) {
    const results = {};
    for (const [nodeId, tracker] of this.trackers) {
      const result = tracker.detectChanges(timestamp);
      if (result) {
        result.nodeId = nodeId;
        results[nodeId] = result;
        if (result.changeDetected) {
          this.allEvents.push(result);
        }
      }
    }
    return results;
  }
}

// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
function parseCsiJsonl(record) {
  if (record.type !== 'raw_csi' || !record.iq_hex) return null;
  const nSc = record.subcarriers || 64;
  const bytes = Buffer.from(record.iq_hex, 'hex');
  const amplitudes = new Float64Array(nSc);

  for (let sc = 0; sc < nSc; sc++) {
    const offset = 2 + sc * 2;
    if (offset + 1 >= bytes.length) break;
    let I = bytes[offset]; if (I > 127) I -= 256;
    let Q = bytes[offset + 1]; if (Q > 127) Q -= 256;
    amplitudes[sc] = Math.sqrt(I * I + Q * Q);
  }

  return { timestamp: record.timestamp, nodeId: record.node_id, amplitudes };
}

function parseCsiUdp(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 nSc = buf.readUInt16LE(6);
  const amplitudes = new Float64Array(nSc);

  for (let sc = 0; sc < nSc; 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 { timestamp: Date.now() / 1000, nodeId, amplitudes };
}

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

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

  let frameCount = 0;
  let lastAnalysisTs = 0;
  let baselineReported = new Set();

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

    const csi = parseCsiJsonl(record);
    if (!csi) continue;

    detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
    frameCount++;

    // Report baseline completion
    for (const [nodeId, tracker] of detector.trackers) {
      if (tracker.baselineEstablished && !baselineReported.has(nodeId)) {
        baselineReported.add(nodeId);
        if (!JSON_OUTPUT) {
          console.log(`Node ${nodeId}: baseline established (${tracker.baselineNulls.size} nulls, ${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
        }
      }
    }

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

    if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
      const results = detector.analyze(csi.timestamp);

      for (const [nodeId, result] of Object.entries(results)) {
        if (JSON_OUTPUT) {
          console.log(JSON.stringify(result));
        } else if (result.changeDetected) {
          const ts = new Date(csi.timestamp * 1000).toISOString().slice(11, 19);
          console.log(`[${ts}] Node ${nodeId}: ${result.eventType.toUpperCase()} | ${result.material} | nulls ${result.baselineNullCount} -> ${result.currentNullCount} (delta ${result.nullDelta > 0 ? '+' : ''}${result.nullDelta})`);
          if (result.newNullRange) console.log(`  New nulls: sc ${result.newNullRange[0]}-${result.newNullRange[1]} (${result.newNulls} subcarriers)`);
          if (result.removedNullRange) console.log(`  Removed nulls: sc ${result.removedNullRange[0]}-${result.removedNullRange[1]} (${result.removedNulls} subcarriers)`);
          if (result.ampChanges > 0) console.log(`  Amplitude changes: ${result.ampChanges} subcarriers`);
        }
      }

      lastAnalysisTs = tsMs;
    }
  }

  // Summary
  if (!JSON_OUTPUT) {
    console.log('\n' + '='.repeat(60));
    console.log('MATERIAL/OBJECT CHANGE DETECTION SUMMARY');
    console.log('='.repeat(60));

    for (const [nodeId, tracker] of detector.trackers) {
      console.log(`\nNode ${nodeId}:`);
      console.log(`  Baseline nulls: ${tracker.baselineNulls.size} / ${tracker.nSc} (${((tracker.baselineNulls.size / tracker.nSc) * 100).toFixed(0)}%)`);
      console.log(`  Current map:  ${tracker.renderNullMap()}`);
      console.log(`  Legend: _ = baseline null, X = new null, O = removed null, \u2581 = normal`);
    }

    console.log(`\nTotal change events: ${detector.allEvents.length}`);
    if (detector.allEvents.length > 0) {
      const types = {};
      const materials = {};
      for (const e of detector.allEvents) {
        types[e.eventType] = (types[e.eventType] || 0) + 1;
        materials[e.material] = (materials[e.material] || 0) + 1;
      }
      console.log('Event types:');
      for (const [t, c] of Object.entries(types)) console.log(`  ${t}: ${c}`);
      console.log('Materials:');
      for (const [m, c] of Object.entries(materials)) console.log(`  ${m}: ${c}`);
    }

    console.log(`\nProcessed ${frameCount} CSI frames`);
  } else {
    console.log(JSON.stringify({
      type: 'summary',
      events: detector.allEvents.length,
      frames: frameCount,
    }));
  }
}

// ---------------------------------------------------------------------------
// Live UDP mode
// ---------------------------------------------------------------------------
function startLive() {
  const detector = new MaterialDetector();
  const server = dgram.createSocket('udp4');

  server.on('message', (buf) => {
    const csi = parseCsiUdp(buf);
    if (csi) detector.ingestCSI(csi.nodeId, csi.timestamp, csi.amplitudes);
  });

  setInterval(() => {
    const results = detector.analyze(Date.now() / 1000);

    if (JSON_OUTPUT) {
      for (const result of Object.values(results)) {
        console.log(JSON.stringify(result));
      }
    } else {
      process.stdout.write('\x1B[2J\x1B[H');
      console.log('=== MATERIAL/OBJECT DETECTOR (ADR-077) ===\n');

      for (const [nodeId, tracker] of detector.trackers) {
        if (!tracker.baselineEstablished) {
          const elapsed = tracker.lastTime ? tracker.lastTime - tracker.startTime : 0;
          console.log(`Node ${nodeId}: establishing baseline... ${elapsed.toFixed(0)}/${BASELINE_SEC}s`);
        } else {
          console.log(`Node ${nodeId}: ${tracker.renderNullMap()}`);
          console.log(`  Baseline: ${tracker.baselineNulls.size} nulls | Current: ${[...Array(tracker.nSc)].filter((_, i) => tracker.currentAmps[i] < NULL_THRESHOLD).length} nulls`);
        }
      }

      if (detector.allEvents.length > 0) {
        console.log('\nRecent events:');
        for (const e of detector.allEvents.slice(-5)) {
          const ts = new Date(e.timestamp * 1000).toISOString().slice(11, 19);
          console.log(`  [${ts}] ${e.eventType} | ${e.material} | delta ${e.nullDelta}`);
        }
      }

      console.log(`\nTotal events: ${detector.allEvents.length}`);
    }
  }, INTERVAL_MS);

  server.bind(PORT, () => {
    if (!JSON_OUTPUT) {
      console.log(`Material Detector listening on UDP :${PORT} (baseline: ${BASELINE_SEC}s)`);
    }
  });

  process.on('SIGINT', () => { server.close(); process.exit(0); });
}

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