// Delta Engine v2 — compares two synthesized sweep results and produces structured changes
// Improvements: count metric thresholds, semantic TG dedup, configurable thresholds, null-safety

import { createHash } from 'crypto';

// ─── Default Thresholds ──────────────────────────────────────────────────────
// Override via config.delta.thresholds in crucix.config.mjs

const DEFAULT_NUMERIC_THRESHOLDS = {
  vix: 5,            // % change to flag
  hy_spread: 5,
  '10y2y': 10,
  wti: 3,
  brent: 3,
  natgas: 5,
  gold: 2,
  silver: 3,
  unemployment: 2,
  fed_funds: 1,
  '10y_yield': 3,
  usd_index: 1,
  mortgage: 2,
};

const DEFAULT_COUNT_THRESHOLDS = {
  urgent_posts: 2,         // need ±2 to matter (was 0 — any change)
  thermal_total: 500,      // ±500 detections (was 0 — +1 was noise)
  air_total: 50,           // ±50 aircraft
  who_alerts: 1,           // any new WHO alert matters
  conflict_events: 5,      // ±5 ACLED events
  conflict_fatalities: 10,  // ±10 fatalities
  sdr_online: 3,           // ±3 receivers
  news_count: 5,           // ±5 news items
  sources_ok: 1,           // any source going down matters
};

// ─── Metric Definitions ──────────────────────────────────────────────────────

const NUMERIC_METRICS = [
  { key: 'vix', extract: d => d.fred?.find(f => f.id === 'VIXCLS')?.value, label: 'VIX' },
  { key: 'hy_spread', extract: d => d.fred?.find(f => f.id === 'BAMLH0A0HYM2')?.value, label: 'HY Spread' },
  { key: '10y2y', extract: d => d.fred?.find(f => f.id === 'T10Y2Y')?.value, label: '10Y-2Y Spread' },
  { key: 'wti', extract: d => d.energy?.wti, label: 'WTI Crude' },
  { key: 'brent', extract: d => d.energy?.brent, label: 'Brent Crude' },
  { key: 'natgas', extract: d => d.energy?.natgas, label: 'Natural Gas' },
  { key: 'gold', extract: d => d.metals?.gold, label: 'Gold' },
  { key: 'silver', extract: d => d.metals?.silver, label: 'Silver' },
  { key: 'unemployment', extract: d => d.bls?.find(b => b.id === 'LNS14000000' || b.id === 'UNRATE')?.value, label: 'Unemployment' },
  { key: 'fed_funds', extract: d => d.fred?.find(f => f.id === 'DFF')?.value, label: 'Fed Funds Rate' },
  { key: '10y_yield', extract: d => d.fred?.find(f => f.id === 'DGS10')?.value, label: '10Y Yield' },
  { key: 'usd_index', extract: d => d.fred?.find(f => f.id === 'DTWEXBGS')?.value, label: 'USD Index' },
  { key: 'mortgage', extract: d => d.fred?.find(f => f.id === 'MORTGAGE30US')?.value, label: '30Y Mortgage' },
];

const COUNT_METRICS = [
  { key: 'urgent_posts', extract: d => d.tg?.urgent?.length || 0, label: 'Urgent OSINT Posts' },
  { key: 'thermal_total', extract: d => d.thermal?.reduce((s, t) => s + t.det, 0) || 0, label: 'Thermal Detections' },
  { key: 'air_total', extract: d => d.air?.reduce((s, a) => s + a.total, 0) || 0, label: 'Air Activity' },
  { key: 'who_alerts', extract: d => d.who?.length || 0, label: 'WHO Alerts' },
  { key: 'conflict_events', extract: d => d.acled?.totalEvents || 0, label: 'Conflict Events' },
  { key: 'conflict_fatalities', extract: d => d.acled?.totalFatalities || 0, label: 'Conflict Fatalities' },
  { key: 'sdr_online', extract: d => d.sdr?.online || 0, label: 'SDR Receivers' },
  { key: 'news_count', extract: d => (d.news?.length ?? d.news?.count) || 0, label: 'News Items' },
  { key: 'sources_ok', extract: d => d.meta?.sourcesOk || 0, label: 'Sources OK' },
];

// Risk-sensitive keys: used for determining overall direction
const RISK_KEYS = ['vix', 'hy_spread', 'urgent_posts', 'conflict_events', 'thermal_total'];

// ─── Semantic Hashing for Telegram Posts ─────────────────────────────────────

/**
 * Produce a normalized semantic hash of a post's content.
 * This is intentionally lossy and is only safe as a fallback when a stable
 * post identity is unavailable.
 */
function contentHash(text) {
  if (!text) return '';
  const normalized = text
    .toLowerCase()
    .replace(/\d{1,2}:\d{2}(:\d{2})?/g, '')       // strip times
    .replace(/\d+/g, 'N')                           // normalize all numbers
    .replace(/[^\w\s]/g, '')                         // strip punctuation
    .replace(/\s+/g, ' ')
    .trim()
    .substring(0, 100);
  return createHash('sha256').update(normalized).digest('hex').substring(0, 12);
}

function stablePostKey(post) {
  if (!post) return '';

  const sourceId = post.postId || post.messageId || '';
  const channelId = post.channel || post.chat || '';
  const date = post.date || '';
  const text = (post.text || '').trim().substring(0, 200);

  if (sourceId) return `id:${sourceId}`;
  if (channelId && date) {
    return createHash('sha256')
      .update(`${channelId}|${date}|${text}`)
      .digest('hex')
      .substring(0, 16);
  }

  return `semantic:${contentHash(post.text)}`;
}

// ─── Core Delta Computation ──────────────────────────────────────────────────

/**
 * @param {object} current - current sweep's synthesized data
 * @param {object|null} previous - previous sweep's synthesized data (null on first run)
 * @param {object} [thresholdOverrides] - optional: { numeric: {...}, count: {...} }
 * @param {Array<object>} [priorRuns] - optional compacted prior runs for broader dedup
 */
export function computeDelta(current, previous, thresholdOverrides = {}, priorRuns = []) {
  if (!previous) return null;
  if (!current) return null;

  const numThresholds = { ...DEFAULT_NUMERIC_THRESHOLDS, ...(thresholdOverrides.numeric || {}) };
  const cntThresholds = { ...DEFAULT_COUNT_THRESHOLDS, ...(thresholdOverrides.count || {}) };

  const signals = { new: [], escalated: [], deescalated: [], unchanged: [] };
  let criticalChanges = 0;

  // ─── Numeric metrics: track % change ─────────────────────────────────

  for (const m of NUMERIC_METRICS) {
    const curr = m.extract(current);
    const prev = m.extract(previous);
    if (curr == null || prev == null) continue;

    const threshold = numThresholds[m.key] ?? 5;
    const pctChange = prev !== 0 ? ((curr - prev) / Math.abs(prev)) * 100 : 0;

    if (Math.abs(pctChange) > threshold) {
      const entry = {
        key: m.key, label: m.label, from: prev, to: curr,
        pctChange: parseFloat(pctChange.toFixed(2)),
        direction: pctChange > 0 ? 'up' : 'down',
        severity: Math.abs(pctChange) > threshold * 3 ? 'critical' : Math.abs(pctChange) > threshold * 2 ? 'high' : 'moderate',
      };
      if (pctChange > 0) signals.escalated.push(entry);
      else signals.deescalated.push(entry);
      if (Math.abs(pctChange) > 10) criticalChanges++;
    } else {
      signals.unchanged.push(m.key);
    }
  }

  // ─── Count metrics: track absolute change (with minimum thresholds) ──

  for (const m of COUNT_METRICS) {
    const curr = m.extract(current);
    const prev = m.extract(previous);
    const diff = curr - prev;
    const threshold = cntThresholds[m.key] ?? 1;

    if (Math.abs(diff) >= threshold) {
      const pctChange = prev > 0 ? ((diff / prev) * 100) : (diff > 0 ? 100 : 0);
      const entry = {
        key: m.key, label: m.label, from: prev, to: curr,
        change: diff, direction: diff > 0 ? 'up' : 'down',
        pctChange: parseFloat(pctChange.toFixed(1)),
        severity: Math.abs(diff) >= threshold * 5 ? 'critical' : Math.abs(diff) >= threshold * 2 ? 'high' : 'moderate',
      };
      if (diff > 0) signals.escalated.push(entry);
      else signals.deescalated.push(entry);
      // Count metrics only critical if the change is extreme
      if (entry.severity === 'critical') criticalChanges++;
    } else {
      signals.unchanged.push(m.key);
    }
  }

  // ─── New urgent Telegram posts (semantic dedup) ──────────────────────

  // Dedup against all recent runs (not just the last one) to catch posts that
  // drop out of one sweep but reappear in a later one. Use stable post identity
  // where possible so updated posts are not collapsed into earlier alerts just
  // because their text is semantically similar.
  const sources = priorRuns.length > 0 ? priorRuns : [previous];
  const prevHashes = new Set(
    sources.flatMap(run => (run?.tg?.urgent || []).map(stablePostKey)).filter(Boolean)
  );

  for (const post of (current.tg?.urgent || [])) {
    const hash = stablePostKey(post);
    if (hash && !prevHashes.has(hash)) {
      signals.new.push({
        key: `tg_urgent:${hash}`,
        text: post.text,
        item: post,
        reason: 'New urgent OSINT post',
      });
      criticalChanges++;
    }
  }

  // ─── Nuclear anomaly state change ────────────────────────────────────

  const currAnom = current.nuke?.some(n => n.anom) || false;
  const prevAnom = previous.nuke?.some(n => n.anom) || false;
  if (currAnom && !prevAnom) {
    signals.new.push({ key: 'nuke_anomaly', reason: 'Nuclear anomaly detected', severity: 'critical' });
    criticalChanges += 5;
  } else if (!currAnom && prevAnom) {
    signals.deescalated.push({ key: 'nuke_anomaly', label: 'Nuclear Anomaly', direction: 'resolved', severity: 'high' });
  }

  // ─── Source health degradation ───────────────────────────────────────

  const currSourcesDown = current.health?.filter(s => s.err).length || 0;
  const prevSourcesDown = previous.health?.filter(s => s.err).length || 0;
  if (currSourcesDown > prevSourcesDown + 2) {
    signals.new.push({
      key: 'source_degradation',
      reason: `${currSourcesDown - prevSourcesDown} additional sources failing (${currSourcesDown} total down)`,
      severity: currSourcesDown > 5 ? 'critical' : 'moderate',
    });
  }

  // ─── Overall direction ───────────────────────────────────────────────

  let direction = 'mixed';
  const riskUp = signals.escalated.filter(s => RISK_KEYS.includes(s.key)).length;
  const riskDown = signals.deescalated.filter(s => RISK_KEYS.includes(s.key)).length;
  if (riskUp > riskDown + 1) direction = 'risk-off';
  else if (riskDown > riskUp + 1) direction = 'risk-on';

  return {
    timestamp: current.meta?.timestamp || new Date().toISOString(),
    previous: previous.meta?.timestamp || null,
    signals,
    summary: {
      totalChanges: signals.new.length + signals.escalated.length + signals.deescalated.length,
      criticalChanges,
      direction,
      signalBreakdown: {
        new: signals.new.length,
        escalated: signals.escalated.length,
        deescalated: signals.deescalated.length,
        unchanged: signals.unchanged.length,
      },
    },
  };
}

// Export thresholds for external config
export { DEFAULT_NUMERIC_THRESHOLDS, DEFAULT_COUNT_THRESHOLDS };
