#!/usr/bin/env node
/**
 * ADR-077: Room Environment Fingerprinting
 *
 * Clusters CSI feature vectors to identify distinct room states (empty,
 * working, sleeping, etc.), tracks transitions, and detects anomalies.
 *
 * Usage:
 *   node scripts/room-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl
 *   node scripts/room-fingerprint.js --port 5006
 *   node scripts/room-fingerprint.js --replay FILE --json
 *
 * 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: '10000' },
    'k':      { type: 'string', default: '5' },
    'new-cluster-threshold': { type: 'string', default: '2.0' },
  },
  strict: true,
});

const PORT             = parseInt(args.port, 10);
const JSON_OUTPUT      = args.json;
const INTERVAL_MS      = parseInt(args.interval, 10);
const K                = parseInt(args.k, 10);
const NEW_CLUSTER_DIST = parseFloat(args['new-cluster-threshold']);

// ---------------------------------------------------------------------------
// ADR-018 packet constants
// ---------------------------------------------------------------------------
const VITALS_MAGIC  = 0xC5110002;
const FEATURE_MAGIC = 0xC5110003;
const FUSED_MAGIC   = 0xC5110004;

// ---------------------------------------------------------------------------
// Online k-means clustering
// ---------------------------------------------------------------------------
class OnlineKMeans {
  constructor(maxK, featureDim, newClusterThreshold) {
    this.maxK = maxK;
    this.dim = featureDim;
    this.threshold = newClusterThreshold;

    this.centroids = [];  // { center: Float64Array, count: number, label: string }
    this.alpha = 0.01;    // EMA update rate
  }

  _distance(a, b) {
    let sum = 0;
    const len = Math.min(a.length, b.length);
    for (let i = 0; i < len; i++) {
      sum += (a[i] - b[i]) ** 2;
    }
    return Math.sqrt(sum);
  }

  assign(features) {
    if (this.centroids.length === 0) {
      // First point creates first cluster
      this.centroids.push({
        center: Float64Array.from(features),
        count: 1,
        label: `State-0`,
      });
      return { clusterId: 0, distance: 0 };
    }

    // Find nearest centroid
    let bestDist = Infinity;
    let bestIdx = 0;
    for (let i = 0; i < this.centroids.length; i++) {
      const d = this._distance(features, this.centroids[i].center);
      if (d < bestDist) {
        bestDist = d;
        bestIdx = i;
      }
    }

    // If too far from any cluster, create new one (up to maxK)
    if (bestDist > this.threshold && this.centroids.length < this.maxK) {
      const newIdx = this.centroids.length;
      this.centroids.push({
        center: Float64Array.from(features),
        count: 1,
        label: `State-${newIdx}`,
      });
      return { clusterId: newIdx, distance: 0 };
    }

    // Update centroid via EMA
    const c = this.centroids[bestIdx];
    c.count++;
    for (let i = 0; i < this.dim; i++) {
      c.center[i] = c.center[i] * (1 - this.alpha) + features[i] * this.alpha;
    }

    return { clusterId: bestIdx, distance: bestDist };
  }

  labelClusters(clusterMotion) {
    // Sort clusters by average motion to assign labels
    const sorted = Object.entries(clusterMotion)
      .sort((a, b) => a[1] - b[1]);

    const labels = ['sleeping/empty', 'resting', 'working', 'active', 'highly active'];
    for (let i = 0; i < sorted.length; i++) {
      const clusterId = parseInt(sorted[i][0], 10);
      if (clusterId < this.centroids.length) {
        this.centroids[clusterId].label = labels[Math.min(i, labels.length - 1)];
      }
    }
  }
}

// ---------------------------------------------------------------------------
// Room state tracker
// ---------------------------------------------------------------------------
class RoomFingerprinter {
  constructor(maxK, featureDim, newClusterThreshold) {
    this.kmeans = new OnlineKMeans(maxK, featureDim, newClusterThreshold);
    this.featureDim = featureDim;

    // State tracking
    this.currentState = null;
    this.stateHistory = [];     // { timestamp, clusterId, label, distance }
    this.transitions = {};      // "from->to" -> count

    // Vitals correlation
    this.clusterMotionSum = {};  // clusterId -> sum
    this.clusterMotionCount = {}; // clusterId -> count

    // Feature buffer (latest per node)
    this.latestFeatures = new Map(); // nodeId -> { timestamp, features }
    this.latestVitals = new Map();   // nodeId -> { timestamp, motion, presence }

    this.startTime = null;
  }

  pushFeature(timestamp, nodeId, features) {
    if (!this.startTime) this.startTime = timestamp;
    this.latestFeatures.set(nodeId, { timestamp, features });
  }

  pushVitals(timestamp, nodeId, motion, presence) {
    this.latestVitals.set(nodeId, { timestamp, motion, presence });
  }

  analyze(timestamp) {
    // Find latest feature vector (prefer most recent node)
    let bestFeature = null;
    let bestTs = 0;
    for (const [, entry] of this.latestFeatures) {
      if (entry.timestamp > bestTs) {
        bestTs = entry.timestamp;
        bestFeature = entry.features;
      }
    }

    if (!bestFeature || bestFeature.length < this.featureDim) return null;

    // Truncate or pad to featureDim
    const features = new Float64Array(this.featureDim);
    for (let i = 0; i < this.featureDim && i < bestFeature.length; i++) {
      features[i] = bestFeature[i];
    }

    // Assign to cluster
    const { clusterId, distance } = this.kmeans.assign(features);

    // Track motion per cluster for labeling
    let avgMotion = 0;
    let motionCount = 0;
    for (const [, v] of this.latestVitals) {
      avgMotion += v.motion;
      motionCount++;
    }
    avgMotion = motionCount > 0 ? avgMotion / motionCount : 0;

    this.clusterMotionSum[clusterId] = (this.clusterMotionSum[clusterId] || 0) + avgMotion;
    this.clusterMotionCount[clusterId] = (this.clusterMotionCount[clusterId] || 0) + 1;

    // Update labels periodically
    const clusterMotion = {};
    for (const id of Object.keys(this.clusterMotionCount)) {
      clusterMotion[id] = this.clusterMotionSum[id] / this.clusterMotionCount[id];
    }
    this.kmeans.labelClusters(clusterMotion);

    const label = this.kmeans.centroids[clusterId]
      ? this.kmeans.centroids[clusterId].label
      : `State-${clusterId}`;

    // Track transitions
    if (this.currentState !== null && this.currentState !== clusterId) {
      const key = `${this.currentState}->${clusterId}`;
      this.transitions[key] = (this.transitions[key] || 0) + 1;
    }
    const prevState = this.currentState;
    this.currentState = clusterId;

    const entry = {
      timestamp,
      clusterId,
      label,
      distance: +distance.toFixed(4),
      motion: +avgMotion.toFixed(3),
      transitioned: prevState !== null && prevState !== clusterId,
      prevState: prevState !== null ? prevState : undefined,
      totalClusters: this.kmeans.centroids.length,
    };

    this.stateHistory.push(entry);
    return entry;
  }

  anomalyScore() {
    // Anomaly = current state is rarely seen at this time-of-day
    if (this.stateHistory.length < 10) return 0;

    const currentCluster = this.currentState;
    const recentCount = this.stateHistory.slice(-20).filter(e => e.clusterId === currentCluster).length;
    return 1 - (recentCount / 20); // low count = high anomaly
  }

  renderTimeline(width) {
    const w = width || 60;
    if (this.stateHistory.length === 0) return 'No data yet.';

    const step = Math.max(1, Math.floor(this.stateHistory.length / w));
    const chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';

    let line = '';
    for (let i = 0; i < this.stateHistory.length; i += step) {
      const cid = this.stateHistory[i].clusterId;
      line += chars[Math.min(cid, chars.length - 1)];
    }

    return `State timeline: ${line}`;
  }

  renderTransitionMatrix() {
    if (Object.keys(this.transitions).length === 0) return 'No transitions yet.';

    const lines = ['Transition matrix:'];
    for (const [key, count] of Object.entries(this.transitions).sort((a, b) => b[1] - a[1])) {
      const [from, to] = key.split('->');
      const fromLabel = this.kmeans.centroids[parseInt(from, 10)]?.label || `State-${from}`;
      const toLabel = this.kmeans.centroids[parseInt(to, 10)]?.label || `State-${to}`;
      lines.push(`  ${fromLabel} -> ${toLabel}: ${count}`);
    }
    return lines.join('\n');
  }
}

// ---------------------------------------------------------------------------
// Packet parsing
// ---------------------------------------------------------------------------
function parseFeatureJsonl(record) {
  if (record.type !== 'feature' || !record.features) return null;
  return {
    timestamp: record.timestamp,
    nodeId: record.node_id,
    features: record.features,
  };
}

function parseVitalsJsonl(record) {
  if (record.type !== 'vitals') return null;
  return {
    timestamp: record.timestamp,
    nodeId: record.node_id,
    motion: record.motion_energy || 0,
    presence: record.presence_score || 0,
  };
}

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

  const nodeId = buf.readUInt8(4);
  const features = [];
  for (let i = 0; i < 8; i++) {
    features.push(buf.readFloatLE(12 + i * 4));
  }
  return { timestamp: Date.now() / 1000, nodeId, features };
}

function parseVitalsUdp(buf) {
  if (buf.length < 32) return null;
  const magic = buf.readUInt32LE(0);
  if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
  return {
    timestamp: Date.now() / 1000,
    nodeId: buf.readUInt8(4),
    motion: buf.readFloatLE(16),
    presence: buf.readFloatLE(20),
  };
}

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

  const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
  const rl = readline.createInterface({
    input: fs.createReadStream(filePath),
    crlfDelay: Infinity,
  });

  let featureCount = 0;
  let vitalsCount = 0;
  let lastAnalysisTs = 0;

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

    const feat = parseFeatureJsonl(record);
    if (feat) {
      fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
      featureCount++;
    }

    const vit = parseVitalsJsonl(record);
    if (vit) {
      fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
      vitalsCount++;
    }

    const ts = feat || vit;
    if (!ts) continue;

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

    if (tsMs - lastAnalysisTs >= INTERVAL_MS) {
      const result = fingerprinter.analyze(ts.timestamp);

      if (result) {
        if (JSON_OUTPUT) {
          console.log(JSON.stringify(result));
        } else {
          const tsStr = new Date(ts.timestamp * 1000).toISOString().slice(11, 19);
          const transition = result.transitioned ? ` << TRANSITION from State-${result.prevState}` : '';
          console.log(`[${tsStr}] Cluster ${result.clusterId} (${result.label}) | dist ${result.distance} | motion ${result.motion} | ${result.totalClusters} clusters${transition}`);
        }
      }

      lastAnalysisTs = tsMs;
    }
  }

  // Summary
  if (!JSON_OUTPUT) {
    console.log('\n' + '='.repeat(60));
    console.log('ROOM FINGERPRINT SUMMARY');
    console.log('='.repeat(60));

    console.log(`\nClusters discovered: ${fingerprinter.kmeans.centroids.length}`);
    for (let i = 0; i < fingerprinter.kmeans.centroids.length; i++) {
      const c = fingerprinter.kmeans.centroids[i];
      const stateCount = fingerprinter.stateHistory.filter(e => e.clusterId === i).length;
      const pct = fingerprinter.stateHistory.length > 0
        ? ((stateCount / fingerprinter.stateHistory.length) * 100).toFixed(1)
        : '0';
      const avgMotion = fingerprinter.clusterMotionCount[i] > 0
        ? (fingerprinter.clusterMotionSum[i] / fingerprinter.clusterMotionCount[i]).toFixed(2)
        : '?';
      console.log(`  Cluster ${i} (${c.label}): ${stateCount} windows (${pct}%) | avg motion ${avgMotion} | ${c.count} assignments`);
    }

    console.log('');
    console.log(fingerprinter.renderTimeline(60));
    console.log('');
    console.log(fingerprinter.renderTransitionMatrix());

    const anomaly = fingerprinter.anomalyScore();
    console.log(`\nCurrent anomaly score: ${anomaly.toFixed(3)}`);
    console.log(`Processed: ${featureCount} feature packets, ${vitalsCount} vitals packets`);
  } else {
    console.log(JSON.stringify({
      type: 'summary',
      clusters: fingerprinter.kmeans.centroids.length,
      windows: fingerprinter.stateHistory.length,
      transitions: Object.keys(fingerprinter.transitions).length,
      anomaly: +fingerprinter.anomalyScore().toFixed(3),
    }));
  }
}

// ---------------------------------------------------------------------------
// Live UDP mode
// ---------------------------------------------------------------------------
function startLive() {
  const fingerprinter = new RoomFingerprinter(K, 8, NEW_CLUSTER_DIST);
  const server = dgram.createSocket('udp4');

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

    if (magic === FEATURE_MAGIC) {
      const feat = parseFeatureUdp(buf);
      if (feat) fingerprinter.pushFeature(feat.timestamp, feat.nodeId, feat.features);
    }
    if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
      const vit = parseVitalsUdp(buf);
      if (vit) fingerprinter.pushVitals(vit.timestamp, vit.nodeId, vit.motion, vit.presence);
    }
  });

  setInterval(() => {
    const result = fingerprinter.analyze(Date.now() / 1000);

    if (JSON_OUTPUT) {
      if (result) console.log(JSON.stringify(result));
    } else {
      process.stdout.write('\x1B[2J\x1B[H');
      console.log('=== ROOM FINGERPRINT (ADR-077) ===\n');

      if (result) {
        console.log(`Current state: Cluster ${result.clusterId} (${result.label})`);
        console.log(`Distance: ${result.distance} | Motion: ${result.motion}`);
        console.log(`Clusters: ${result.totalClusters}`);
        if (result.transitioned) {
          console.log(`** STATE TRANSITION from State-${result.prevState} **`);
        }
      } else {
        console.log('Collecting data...');
      }

      console.log('');
      console.log(fingerprinter.renderTimeline(50));
      console.log('');
      console.log(fingerprinter.renderTransitionMatrix());
      console.log(`\nAnomaly score: ${fingerprinter.anomalyScore().toFixed(3)}`);
    }
  }, INTERVAL_MS);

  server.bind(PORT, () => {
    if (!JSON_OUTPUT) {
      console.log(`Room Fingerprint listening on UDP :${PORT} (k=${K})`);
    }
  });

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

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