#!/usr/bin/env node

import { loadEnvFile, runSeed, getRedisCredentials, loadSharedConfig } from './_seed-utils.mjs';
import { resolveIso2, normalizeCountryToken } from './_country-resolver.mjs';

loadEnvFile(import.meta.url);

const CANONICAL_KEY = 'correlation:cards-bootstrap:v1';
const CACHE_TTL = 1200; // 20min — outlives maxStaleMin:15 with buffer (cron runs every 5min)

const INPUT_KEYS = [
  'military:flights:v1',
  'military:flights:stale:v1',
  'unrest:events:v1',
  'infra:outages:v1',
  'seismology:earthquakes:v1',
  'market:stocks-bootstrap:v1',
  'market:commodities-bootstrap:v1',
  'market:crypto:v1',
  'news:insights:v1',
];

async function fetchInputData() {
  const { url, token } = getRedisCredentials();
  const pipeline = INPUT_KEYS.map(k => ['GET', k]);
  const resp = await fetch(`${url}/pipeline`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(pipeline),
    signal: AbortSignal.timeout(10_000),
  });
  if (!resp.ok) throw new Error(`Redis pipeline: HTTP ${resp.status}`);
  const results = await resp.json();
  const data = {};
  for (let i = 0; i < INPUT_KEYS.length; i++) {
    const raw = results[i]?.result;
    if (raw) {
      try { data[INPUT_KEYS[i]] = JSON.parse(raw); } catch { /* skip */ }
    }
  }
  return data;
}

// ── Haversine ───────────────────────────────────────────────
function haversineKm(lat1, lon1, lat2, lon2) {
  const R = 6371;
  const dLat = ((lat2 - lat1) * Math.PI) / 180;
  const dLon = ((lon2 - lon1) * Math.PI) / 180;
  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos((lat1 * Math.PI) / 180) *
    Math.cos((lat2 * Math.PI) / 180) *
    Math.sin(dLon / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

const COUNTRY_NAME_TO_ISO2 = loadSharedConfig('country-names.json');
const ISO3_TO_ISO2 = loadSharedConfig('iso3-to-iso2.json');

const COUNTRY_CENTROIDS = {
  'AF':[33.9,67.7],'AL':[41.2,20.2],'DZ':[28.0,1.7],'AO':[-11.2,17.9],'AR':[-38.4,-63.6],
  'AM':[40.1,45.0],'AU':[-25.3,133.8],'AT':[47.5,14.6],'AZ':[40.1,47.6],'BH':[26.0,50.6],
  'BD':[23.7,90.4],'BY':[53.7,28.0],'BE':[50.5,4.5],'BO':[-16.3,-63.6],'BA':[43.9,17.7],
  'BR':[-14.2,-51.9],'BG':[42.7,25.5],'BF':[12.2,-1.6],'KH':[12.6,105.0],'CM':[7.4,12.4],
  'CA':[56.1,-106.3],'CF':[6.6,20.9],'TD':[15.5,18.7],'CL':[-35.7,-71.5],'CN':[35.9,104.2],
  'CO':[4.6,-74.3],'CD':[-4.0,21.8],'CG':[-0.2,15.8],'HR':[45.1,15.2],'CU':[21.5,-78.0],
  'CZ':[49.8,15.5],'DK':[56.3,9.5],'DJ':[11.6,43.1],'DO':[18.7,-70.2],'EC':[-1.8,-78.2],
  'EG':[26.8,30.8],'SV':[13.8,-88.9],'ER':[15.2,39.8],'EE':[58.6,25.0],'ET':[9.1,40.5],
  'FI':[61.9,25.7],'FR':[46.2,2.2],'GE':[42.3,43.4],'DE':[51.2,10.5],'GH':[7.9,-1.0],
  'GR':[39.1,21.8],'GT':[15.8,-90.2],'GN':[9.9,-9.7],'HT':[19.0,-72.3],'HN':[15.2,-86.2],
  'HU':[47.2,19.5],'IN':[20.6,79.0],'ID':[-0.8,113.9],'IR':[32.4,53.7],'IQ':[33.2,43.7],
  'IE':[53.1,-7.7],'IL':[31.0,34.9],'IT':[41.9,12.6],'JM':[18.1,-77.3],'JP':[36.2,138.3],
  'JO':[30.6,36.2],'KZ':[48.0,68.0],'KE':[-0.0,37.9],'KW':[29.3,47.5],'KG':[41.2,74.8],
  'LV':[56.9,24.1],'LB':[33.9,35.9],'LY':[26.3,17.2],'LT':[55.2,23.9],'MG':[-18.8,46.9],
  'MW':[-13.3,34.3],'MY':[4.2,101.9],'ML':[17.6,-4.0],'MR':[21.0,-10.9],'MX':[23.6,-102.6],
  'MD':[47.4,28.4],'MN':[46.9,103.8],'ME':[42.7,19.4],'MA':[31.8,-7.1],'MZ':[-18.7,35.5],
  'MM':[21.9,95.9],'NA':[-22.6,17.1],'NP':[28.4,84.1],'NL':[52.1,5.3],'NZ':[-40.9,174.9],
  'NI':[12.9,-85.2],'NE':[17.6,8.1],'NG':[9.1,8.7],'KP':[40.3,127.5],'MK':[41.5,21.7],
  'NO':[60.5,8.5],'OM':[21.5,55.9],'PK':[30.4,69.3],'PS':[31.9,35.2],'PA':[9.0,-79.5],
  'PG':[-6.3,143.9],'PY':[-23.4,-58.4],'PE':[-9.2,-75.0],'PH':[12.9,121.8],'PL':[51.9,19.1],
  'PT':[39.4,-8.2],'QA':[25.4,51.2],'RO':[45.9,25.0],'RU':[61.5,105.3],'RW':[-1.9,29.9],
  'SA':[23.9,45.1],'SN':[14.5,-14.5],'RS':[44.0,21.0],'SL':[8.5,-11.8],'SG':[1.4,103.8],
  'SK':[48.7,19.7],'SI':[46.2,14.6],'SO':[5.2,46.2],'ZA':[-30.6,22.9],'KR':[35.9,127.8],
  'SS':[6.9,31.3],'ES':[40.5,-3.7],'LK':[7.9,80.8],'SD':[12.9,30.2],'SE':[60.1,18.6],
  'CH':[46.8,8.2],'SY':[35.0,38.5],'TW':[23.7,121.0],'TJ':[38.9,71.3],'TZ':[-6.4,34.9],
  'TH':[15.9,100.9],'TG':[8.6,1.2],'TN':[34.0,9.5],'TR':[39.0,35.2],'TM':[39.0,59.6],
  'UG':[1.4,32.3],'UA':[48.4,31.2],'AE':[23.4,53.8],'GB':[55.4,-3.4],'US':[37.1,-95.7],
  'UY':[-32.5,-55.8],'UZ':[41.4,64.6],'VE':[6.4,-66.6],'VN':[14.1,108.3],'YE':[15.6,48.5],
  'ZM':[-13.1,27.8],'ZW':[-19.0,29.2],
  'CI':[7.5,-5.5],'CR':[10.0,-84.0],'CV':[16.0,-24.0],'CY':[35.1,33.4],'GA':[-0.8,11.6],
  'IS':[64.9,-19.0],'LA':[19.9,102.5],'SZ':[-26.5,31.5],'TL':[-8.9,125.7],'TT':[10.4,-61.2],
  'XK':[42.6,20.9],
};

function nearestCountryByCoords(lat, lon) {
  if (lat == null || lon == null || (lat === 0 && lon === 0)) return undefined;
  let bestCode, bestDist = Infinity;
  for (const [code, [clat, clon]] of Object.entries(COUNTRY_CENTROIDS)) {
    const d = haversineKm(lat, lon, clat, clon);
    if (d < bestDist) { bestDist = d; bestCode = code; }
  }
  return bestDist < 800 ? bestCode : undefined;
}

function normalizeToCode(country, lat, lon) {
  if (country) {
    const resolved = resolveIso2({ iso2: country, iso3: country, name: country });
    if (resolved) return resolved;
  }
  return nearestCountryByCoords(lat, lon);
}

const COUNTRY_NAME_ENTRIES = Object.entries(COUNTRY_NAME_TO_ISO2)
  .filter(([name]) => name.length >= 2)
  .sort((a, b) => b[0].length - a[0].length)
  .map(([name, code]) => ({ name, code, regex: new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i') }));

export function matchCountryNamesInText(text) {
  const matched = [];
  let remaining = text.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLowerCase()
    .replace(/['.(),/-]/g, ' ').replace(/\s+/g, ' ');
  for (const { code, regex } of COUNTRY_NAME_ENTRIES) {
    if (regex.test(remaining)) {
      matched.push(code);
      remaining = remaining.replace(regex, '');
    }
  }
  return matched;
}

// ── Adapter: Military ───────────────────────────────────────
const STRIKE_TYPES = new Set(['fighter', 'bomber', 'attack']);
const SUPPORT_TYPES = new Set(['tanker', 'awacs', 'surveillance', 'electronic_warfare']);

function collectMilitarySignals(flights) {
  const signals = [];
  const now = Date.now();
  const windowMs = 24 * 60 * 60 * 1000;
  for (const f of flights) {
    const ts = typeof f.lastSeen === 'number' ? f.lastSeen : (f.lastSeen ? new Date(f.lastSeen).getTime() : now);
    if (now - ts > windowMs) continue;
    const isStrike = STRIKE_TYPES.has(f.aircraftType);
    const isSupport = SUPPORT_TYPES.has(f.aircraftType);
    const severity = isStrike ? 80 : isSupport ? 60 : 55;
    signals.push({
      type: 'military_flight',
      source: 'signal-aggregator',
      severity,
      lat: f.lat,
      lon: f.lon,
      country: f.operatorCountry,
      timestamp: ts,
      label: `${f.operator || ''} ${f.aircraftType || ''} ${f.callsign || ''}`.trim(),
      aircraftType: f.aircraftType,
    });
  }
  return signals;
}

function generateMilitaryTitle(cluster) {
  const types = new Set(cluster.map(s => s.type));
  const countries = [...new Set(cluster.map(s => s.country).filter(Boolean))];
  const countryLabel = countries.slice(0, 2).join('/') || 'Unknown region';
  const flightTypes = new Set(
    cluster.filter(s => s.type === 'military_flight').map(s => s.aircraftType).filter(Boolean),
  );
  const hasStrikePackage = [...STRIKE_TYPES].some(t => flightTypes.has(t)) &&
                           [...SUPPORT_TYPES].some(t => flightTypes.has(t));
  if (hasStrikePackage) return `Strike packaging detected \u2014 ${countryLabel}`;
  if (types.has('military_flight')) return `Military flight cluster \u2014 ${countryLabel}`;
  return `Military activity convergence \u2014 ${countryLabel}`;
}

// ── Adapter: Escalation ─────────────────────────────────────
const ESCALATION_KEYWORDS = /\b((?:military|armed|air)\s*(?:strike|attack|offensive)|invasion|bombing|missile|airstrike|shelling|drone\s+strike|war(?:fare)?|ceasefire|martial\s+law|armed\s+clash(?:es)?|gunfire|coup(?:\s+attempt)?|insurgent|rebel|militia|terror(?:ist|ism)|hostage|siege|blockade|mobiliz(?:ation|e)|escalat(?:ion|ing|e)|retaliat|deploy(?:ment|ed)|incursion|annex(?:ation|ed)|occupation|humanitarian\s+crisis|refugee|evacuat|nuclear|chemical\s+weapon|biological\s+weapon)\b/i;

function collectEscalationSignals(protests, outages, newsClusters) {
  const signals = [];
  const now = Date.now();
  const windowMs = 48 * 60 * 60 * 1000;

  for (const p of protests) {
    const ts = typeof p.occurredAt === 'number' ? p.occurredAt : (p.occurredAt ? new Date(p.occurredAt).getTime() : now);
    if (now - ts > windowMs) continue;
    const pLat = p.location?.latitude ?? p.lat;
    const pLon = p.location?.longitude ?? p.lon;
    const code = normalizeToCode(p.country, pLat, pLon);
    if (!code) continue;
    const severityMap = { SEVERITY_LEVEL_HIGH: 85, SEVERITY_LEVEL_MEDIUM: 55, SEVERITY_LEVEL_LOW: 30, high: 85, medium: 55, low: 30 };
    signals.push({
      type: 'conflict_event',
      source: 'signal-aggregator',
      severity: severityMap[p.severity] ?? 40,
      lat: pLat,
      lon: pLon,
      country: code,
      timestamp: ts,
      label: `${p.eventType || 'event'}: ${p.title || ''}`,
    });
  }

  for (const o of outages) {
    const ts = typeof o.detectedAt === 'number' ? o.detectedAt : (o.detectedAt ? new Date(o.detectedAt).getTime() : now);
    if (now - ts > windowMs) continue;
    const oLat = o.location?.latitude ?? o.lat;
    const oLon = o.location?.longitude ?? o.lon;
    if (oLat != null && oLon != null && oLat === 0 && oLon === 0) continue;
    const code = normalizeToCode(o.country, oLat, oLon);
    if (!code) continue;
    const severityMap = { OUTAGE_SEVERITY_TOTAL: 90, OUTAGE_SEVERITY_MAJOR: 70, OUTAGE_SEVERITY_PARTIAL: 40, total: 90, major: 70, partial: 40 };
    signals.push({
      type: 'escalation_outage',
      source: 'signal-aggregator',
      severity: severityMap[o.severity] ?? 30,
      lat: oLat,
      lon: oLon,
      country: code,
      timestamp: ts,
      label: `${o.severity || ''} outage: ${o.title || ''}`,
    });
  }

  for (const c of newsClusters) {
    if (!c.threat || c.threat.level === 'info' || c.threat.level === 'low') continue;
    const ts = c.lastUpdated ?? now;
    if (now - ts > windowMs) continue;
    if (!ESCALATION_KEYWORDS.test(c.primaryTitle)) continue;
    const severity = c.threat.level === 'critical' ? 85 : c.threat.level === 'high' ? 65 : 45;
    const matched = matchCountryNamesInText(c.primaryTitle);
    const code = normalizeToCode(matched[0], c.lat, c.lon);
    if (!code) continue;
    signals.push({
      type: 'news_severity',
      source: 'analysis-core',
      severity,
      lat: c.lat,
      lon: c.lon,
      country: code,
      timestamp: ts,
      label: c.primaryTitle,
    });
  }

  const conflictCountries = new Set(
    signals.filter(s => s.type === 'conflict_event').map(s => s.country).filter(Boolean),
  );
  return signals.filter(s => s.type !== 'escalation_outage' || conflictCountries.has(s.country));
}

function generateEscalationTitle(cluster) {
  const types = new Set(cluster.map(s => s.type));
  const countries = [...new Set(cluster.map(s => s.country).filter(Boolean))];
  const countryLabel = countries[0] || 'Unknown';
  const parts = [];
  if (types.has('conflict_event')) parts.push('conflict');
  if (types.has('escalation_outage')) parts.push('comms disruption');
  if (types.has('news_severity')) parts.push('news escalation');
  return parts.length > 0
    ? `${parts.join(' + ')} \u2014 ${countryLabel}`
    : `Escalation signals \u2014 ${countryLabel}`;
}

// ── Adapter: Economic ───────────────────────────────────────
const SANCTIONS_KEYWORDS = /\b(sanction|tariff|embargo|trade\s+war|ban|restrict|block|seize|freeze\s+assets|export\s+control|blacklist|decouple|decoupl|subsid|dumping|countervail|quota|levy|excise|retaliat|currency\s+manipulat|capital\s+controls|swift|cbdc|petrodollar|de-?dollar|opec|cartel|price\s+cap|oil|crude|commodity|shortage|stockpile|strategic\s+reserve|supply\s+chain|rare\s+earth|chip\s+ban|semiconductor|economic\s+warfare|financial\s+weapon)\b/i;
const COMMODITY_SYMBOLS = new Set(['CL=F', 'GC=F', 'NG=F', 'SI=F', 'HG=F', 'ZW=F', 'BTC-USD', 'BZ=F', 'ETH-USD', 'KC=F', 'SB=F', 'CT=F', 'CC=F']);
const SIGNIFICANT_CHANGE_PCT = 1.5;

function collectEconomicSignals(markets, newsClusters) {
  const signals = [];
  const now = Date.now();
  const windowMs = 24 * 60 * 60 * 1000;

  for (const m of markets) {
    if (m.change == null || m.price == null) continue;
    const absPct = Math.abs(m.change);
    if (absPct < SIGNIFICANT_CHANGE_PCT) continue;
    const isCommodity = COMMODITY_SYMBOLS.has(m.symbol);
    const type = isCommodity ? 'commodity_spike' : 'market_move';
    signals.push({
      type,
      source: 'markets',
      severity: Math.min(100, absPct * 10),
      timestamp: now,
      label: `${m.display ?? m.symbol} ${m.change > 0 ? '+' : ''}${m.change.toFixed(1)}%`,
      symbol: m.symbol,
      display: m.display,
      change: m.change,
    });
  }

  for (const c of newsClusters) {
    const ts = c.lastUpdated ?? now;
    if (now - ts > windowMs) continue;
    if (!SANCTIONS_KEYWORDS.test(c.primaryTitle)) continue;
    const severity = c.threat?.level === 'critical' ? 85 : c.threat?.level === 'high' ? 70 : 50;
    signals.push({
      type: 'sanctions_news',
      source: 'analysis-core',
      severity,
      timestamp: ts,
      label: c.primaryTitle,
    });
  }

  return signals;
}

const KNOWN_ENTITIES = /\b(Iran|Russia|China|North Korea|Venezuela|Cuba|Syria|Myanmar|Belarus|Turkey|Saudi|OPEC|EU|USA?|United States|India)\b(?![A-Za-z])/i;
const GENERIC_ENTITY_KEYS = new Set([
  'sanctions', 'trade', 'tariff', 'commodity', 'currency', 'energy',
  'embargo', 'semiconductor', 'crypto', 'inflation',
]);

function generateEconomicTitle(cluster, entityKey) {
  const types = new Set(cluster.map(s => s.type));

  if (types.has('commodity_spike')) {
    const spikes = cluster.filter(s => s.type === 'commodity_spike');
    const names = spikes.map(s => s.display ?? s.symbol ?? s.label.split(' ')[0]).slice(0, 2);
    const change = spikes[0]?.change;
    const pctSuffix = change != null ? ` (${change > 0 ? '+' : ''}${change.toFixed(1)}%)` : '';
    const base = `${names.join('/')} spike${pctSuffix}`;
    if (types.has('sanctions_news')) return `${base} + sanctions`;
    return base;
  }

  if (types.has('sanctions_news')) {
    const labels = cluster.filter(s => s.type === 'sanctions_news').map(s => s.label);
    let qualifier = '';
    for (const label of labels) {
      const match = KNOWN_ENTITIES.exec(label);
      if (match) { qualifier = match[1]; break; }
    }
    if (!qualifier && entityKey && !GENERIC_ENTITY_KEYS.has(entityKey)) {
      qualifier = entityKey.charAt(0).toUpperCase() + entityKey.slice(1);
    }
    const sanctionsBase = qualifier ? `${qualifier} sanctions activity` : 'Sanctions activity';
    if (types.has('market_move')) {
      const movers = cluster.filter(s => s.type === 'market_move');
      const moverNames = movers.map(s => s.display ?? s.symbol ?? s.label.split(' ')[0]).slice(0, 2);
      return `${sanctionsBase} + ${moverNames.join('/')} disruption`;
    }
    return sanctionsBase;
  }

  if (types.has('market_move')) {
    const movers = cluster.filter(s => s.type === 'market_move');
    const names = movers.map(s => s.display ?? s.symbol ?? s.label.split(' ')[0]).slice(0, 2);
    return `Market disruption: ${names.join('/')}`;
  }

  const fallback = entityKey && !GENERIC_ENTITY_KEYS.has(entityKey)
    ? entityKey.charAt(0).toUpperCase() + entityKey.slice(1) : '';
  return fallback ? `Economic convergence: ${fallback}` : 'Economic convergence detected';
}

// ── Adapter: Disaster ───────────────────────────────────────
function collectDisasterSignals(earthquakes, outages, protests) {
  const signals = [];
  const now = Date.now();
  const windowMs = 96 * 60 * 60 * 1000;

  for (const q of earthquakes) {
    const ts = q.occurredAt ?? now;
    if (now - ts > windowMs) continue;
    if (q.location?.latitude == null || q.location?.longitude == null) continue;
    const severity = Math.min(100, Math.max(10, (q.magnitude - 1.5) * 17));
    signals.push({
      type: 'earthquake',
      source: 'usgs',
      severity,
      lat: q.location.latitude,
      lon: q.location.longitude,
      timestamp: ts,
      label: `M${q.magnitude.toFixed(1)} \u2014 ${q.place}`,
      magnitude: q.magnitude,
    });
  }

  const conflictCountries = new Set(
    (protests ?? [])
      .filter(p => {
        const ts = typeof p.occurredAt === 'number' ? p.occurredAt : (p.occurredAt ? new Date(p.occurredAt).getTime() : now);
        return (now - ts) <= windowMs;
      })
      .map(p => p.country)
      .filter(Boolean),
  );

  for (const o of outages) {
    const ts = typeof o.detectedAt === 'number' ? o.detectedAt : (o.detectedAt ? new Date(o.detectedAt).getTime() : now);
    if (now - ts > windowMs) continue;
    if (o.country && conflictCountries.has(o.country)) continue;
    const oLat = o.location?.latitude ?? o.lat;
    const oLon = o.location?.longitude ?? o.lon;
    if (oLat == null || oLon == null || (oLat === 0 && oLon === 0)) continue;
    const severityMap = { OUTAGE_SEVERITY_TOTAL: 90, OUTAGE_SEVERITY_MAJOR: 70, OUTAGE_SEVERITY_PARTIAL: 40, total: 90, major: 70, partial: 40 };
    signals.push({
      type: 'infra_outage',
      source: 'signal-aggregator',
      severity: severityMap[o.severity] ?? 30,
      lat: oLat,
      lon: oLon,
      country: o.country,
      timestamp: ts,
      label: `Infra outage: ${o.title || ''}`,
    });
  }

  return signals;
}

function generateDisasterTitle(cluster) {
  const types = new Set(cluster.map(s => s.type));
  const parts = [];
  if (types.has('earthquake')) {
    const maxMag = Math.max(...cluster.filter(s => s.type === 'earthquake').map(s => s.magnitude ?? 0));
    parts.push(`M${maxMag.toFixed(1)} seismic`);
  }
  if (types.has('infra_outage')) parts.push('infra disruption');
  const quakePlace = cluster.find(s => s.type === 'earthquake')?.label?.split('\u2014')[1]?.trim();
  return parts.length > 0
    ? `Disaster cascade: ${parts.join(' + ')}${quakePlace ? ` \u2014 ${quakePlace}` : ''}`
    : 'Disaster convergence detected';
}

// ── Clustering ──────────────────────────────────────────────
function clusterByProximity(signals, radiusKm) {
  if (signals.length === 0) return [];
  const DEG_PER_KM_LAT = 1 / 111;
  const cellSizeLat = radiusKm * DEG_PER_KM_LAT;
  const parent = signals.map((_, i) => i);
  const find = (i) => {
    while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
    return i;
  };
  const union = (a, b) => {
    const ra = find(a), rb = find(b);
    if (ra !== rb) parent[ra] = rb;
  };
  const grid = new Map();
  const validIndices = [];
  for (let i = 0; i < signals.length; i++) {
    const s = signals[i];
    if (s.lat == null || s.lon == null) continue;
    validIndices.push(i);
    const cellRow = Math.floor(s.lat / cellSizeLat);
    const cosLat = Math.cos(s.lat * Math.PI / 180);
    const cellSizeLon = cosLat > 0.01 ? cellSizeLat / cosLat : cellSizeLat;
    const cellCol = Math.floor(s.lon / cellSizeLon);
    const key = `${cellRow}:${cellCol}`;
    const list = grid.get(key);
    if (list) list.push(i); else grid.set(key, [i]);
  }
  for (const [key, indices] of grid) {
    const sep = key.indexOf(':');
    const row = Number(key.slice(0, sep));
    const col = Number(key.slice(sep + 1));
    for (let dr = -1; dr <= 1; dr++) {
      for (let dc = -1; dc <= 1; dc++) {
        const neighbors = grid.get(`${row + dr}:${col + dc}`);
        if (!neighbors) continue;
        for (const i of indices) {
          const si = signals[i];
          for (const j of neighbors) {
            if (i >= j) continue;
            const sj = signals[j];
            if (haversineKm(si.lat, si.lon, sj.lat, sj.lon) <= radiusKm) union(i, j);
          }
        }
      }
    }
  }
  const clusterMap = new Map();
  for (const i of validIndices) {
    const root = find(i);
    const list = clusterMap.get(root);
    if (list) list.push(signals[i]); else clusterMap.set(root, [signals[i]]);
  }
  const clusters = [];
  for (const sigs of clusterMap.values()) {
    if (sigs.length >= 2) clusters.push({ signals: sigs });
  }
  return clusters;
}

function clusterByCountry(signals) {
  const byCountry = new Map();
  for (const s of signals) {
    if (!s.country) continue;
    const list = byCountry.get(s.country) ?? [];
    list.push(s);
    byCountry.set(s.country, list);
  }
  const clusters = [];
  for (const [country, sigs] of byCountry) {
    if (sigs.length < 2) continue;
    clusters.push({ signals: sigs, country });
  }
  return clusters;
}

function clusterByEntity(signals) {
  const COMPOUND_PATTERNS = [
    'supply chain', 'rare earth', 'central bank', 'interest rate',
    'trade war', 'oil price', 'gas price', 'federal reserve',
  ];
  const SINGLE_KEYS = new Set([
    'oil', 'gas', 'sanctions', 'trade', 'tariff', 'commodity', 'currency',
    'energy', 'wheat', 'crude', 'gold', 'silver', 'copper', 'bitcoin',
    'crypto', 'inflation', 'embargo', 'opec', 'semiconductor', 'dollar',
    'yuan', 'euro',
  ]);
  const tokenMap = new Map();
  for (const s of signals) {
    const lower = s.label.toLowerCase();
    let matchedKey = COMPOUND_PATTERNS.find(p => lower.includes(p));
    if (!matchedKey) {
      const words = lower.split(/\W+/);
      matchedKey = words.find(w => SINGLE_KEYS.has(w));
    }
    if (!matchedKey) continue;
    const list = tokenMap.get(matchedKey) ?? [];
    list.push(s);
    tokenMap.set(matchedKey, list);
  }
  const clusters = [];
  for (const [key, sigs] of tokenMap) {
    if (sigs.length < 2) continue;
    clusters.push({ signals: sigs, entityKey: key });
  }
  return clusters;
}

// ── Scoring ─────────────────────────────────────────────────
function scoreClusters(clusters, weights, threshold) {
  return clusters
    .map(cluster => {
      const perType = new Map();
      for (const s of cluster.signals) {
        const current = perType.get(s.type) ?? 0;
        perType.set(s.type, Math.max(current, s.severity));
      }
      let weightedSum = 0;
      for (const [type, severity] of perType) {
        weightedSum += severity * (weights[type] ?? 0);
      }
      const diversityBonus = Math.min(30, Math.max(0, (perType.size - 2)) * 12);
      const score = Math.min(100, weightedSum + diversityBonus);

      let centroidLat, centroidLon;
      const geoSignals = cluster.signals.filter(s => s.lat != null && s.lon != null);
      if (geoSignals.length > 0) {
        centroidLat = geoSignals.reduce((sum, s) => sum + s.lat, 0) / geoSignals.length;
        const toRad = Math.PI / 180;
        let sinSum = 0, cosSum = 0;
        for (const s of geoSignals) {
          sinSum += Math.sin(s.lon * toRad);
          cosSum += Math.cos(s.lon * toRad);
        }
        centroidLon = Math.atan2(sinSum, cosSum) * (180 / Math.PI);
      }

      const countries = [...new Set(cluster.signals.map(s => s.country).filter(Boolean))];
      const key = cluster.country ?? cluster.entityKey ?? `${centroidLat?.toFixed(1)},${centroidLon?.toFixed(1)}`;

      return { cluster, score, countries, centroidLat, centroidLon, key };
    })
    .filter(c => c.score >= threshold);
}

// ── Card Generation ─────────────────────────────────────────
function toCard(scored, domain, titleFn) {
  const title = titleFn(scored.cluster.signals, scored.cluster.entityKey);
  const location = scored.centroidLat != null && scored.centroidLon != null
    ? { lat: scored.centroidLat, lon: scored.centroidLon, label: scored.key }
    : undefined;

  const signals = scored.cluster.signals.map(s => ({
    type: s.type,
    source: s.source,
    severity: s.severity,
    lat: s.lat,
    lon: s.lon,
    country: s.country,
    timestamp: s.timestamp,
    label: s.label,
  }));

  return {
    id: `${domain}:${scored.key}`,
    domain,
    title,
    score: Math.round(scored.score),
    signals,
    location,
    countries: scored.countries,
    trend: 'stable',
    timestamp: Date.now(),
  };
}

// ── Domain configs ──────────────────────────────────────────
const DOMAINS = {
  military: {
    weights: { military_flight: 0.40, ais_gap: 0.30, military_vessel: 0.30 },
    clusterMode: 'geographic',
    spatialRadius: 500,
    threshold: 20,
    titleFn: generateMilitaryTitle,
  },
  escalation: {
    weights: { conflict_event: 0.45, escalation_outage: 0.25, news_severity: 0.30 },
    clusterMode: 'country',
    threshold: 20,
    titleFn: generateEscalationTitle,
  },
  economic: {
    weights: { market_move: 0.35, sanctions_news: 0.30, commodity_spike: 0.35 },
    clusterMode: 'entity',
    threshold: 20,
    titleFn: generateEconomicTitle,
  },
  disaster: {
    weights: { earthquake: 0.55, infra_outage: 0.45 },
    clusterMode: 'geographic',
    spatialRadius: 500,
    threshold: 20,
    titleFn: generateDisasterTitle,
  },
};

// ── Main ────────────────────────────────────────────────────
async function computeCorrelation() {
  const data = await fetchInputData();

  const hasAnyData = INPUT_KEYS.some(k => data[k] != null);
  if (!hasAnyData) throw new Error('No input data available in Redis');

  const flights = data['military:flights:v1']?.flights
    ?? data['military:flights:stale:v1']?.flights
    ?? data['military:flights:v1'] ?? data['military:flights:stale:v1'] ?? [];
  const rawFlights = Array.isArray(flights) ? flights : [];

  const protestData = data['unrest:events:v1'];
  const protests = protestData?.events ?? (Array.isArray(protestData) ? protestData : []);

  const outageData = data['infra:outages:v1'];
  const outages = outageData?.outages ?? (Array.isArray(outageData) ? outageData : []);

  const quakeData = data['seismology:earthquakes:v1'];
  const earthquakes = quakeData?.earthquakes ?? (Array.isArray(quakeData) ? quakeData : []);

  const stockQuotes = data['market:stocks-bootstrap:v1']?.quotes ?? [];
  const commodityQuotes = data['market:commodities-bootstrap:v1']?.quotes ?? [];
  const cryptoQuotes = data['market:crypto:v1']?.quotes ?? [];
  const allMarkets = [...stockQuotes, ...commodityQuotes, ...cryptoQuotes];

  const insights = data['news:insights:v1'];
  const newsClusters = (insights?.topStories ?? []).map(s => ({
    primaryTitle: s.primaryTitle,
    threat: { level: s.threatLevel ?? 'moderate' },
    lastUpdated: s.pubDate ? new Date(s.pubDate).getTime() : (insights?.generatedAt ? new Date(insights.generatedAt).getTime() : Date.now()),
    lat: s.lat,
    lon: s.lon,
  }));

  const result = { military: [], escalation: [], economic: [], disaster: [], computedAt: Date.now() };

  // Military
  const milSignals = collectMilitarySignals(rawFlights);
  const milClusters = clusterByProximity(milSignals, 500);
  const milScored = scoreClusters(milClusters, DOMAINS.military.weights, DOMAINS.military.threshold);
  result.military = milScored.map(s => toCard(s, 'military', generateMilitaryTitle)).sort((a, b) => b.score - a.score);

  // Escalation
  const escSignals = collectEscalationSignals(protests, outages, newsClusters);
  const escClusters = clusterByCountry(escSignals);
  const escScored = scoreClusters(escClusters, DOMAINS.escalation.weights, DOMAINS.escalation.threshold);
  result.escalation = escScored.map(s => toCard(s, 'escalation', generateEscalationTitle)).sort((a, b) => b.score - a.score);

  // Economic
  const ecoSignals = collectEconomicSignals(allMarkets, newsClusters);
  const ecoClusters = clusterByEntity(ecoSignals);
  const ecoScored = scoreClusters(ecoClusters, DOMAINS.economic.weights, DOMAINS.economic.threshold);
  result.economic = ecoScored.map(s => toCard(s, 'economic', generateEconomicTitle)).sort((a, b) => b.score - a.score);

  // Disaster
  const disSignals = collectDisasterSignals(earthquakes, outages, protests);
  const disClusters = clusterByProximity(disSignals, 500);
  const disScored = scoreClusters(disClusters, DOMAINS.disaster.weights, DOMAINS.disaster.threshold);
  result.disaster = disScored.map(s => toCard(s, 'disaster', generateDisasterTitle)).sort((a, b) => b.score - a.score);

  return result;
}

export function declareRecords(data) {
  return (data?.military?.length ?? 0) + (data?.escalation?.length ?? 0) + (data?.economic?.length ?? 0) + (data?.disaster?.length ?? 0);
}

if (process.argv[1]?.endsWith('seed-correlation.mjs')) {
  runSeed('correlation', 'cards', CANONICAL_KEY, computeCorrelation, {
    ttlSeconds: CACHE_TTL,
    sourceVersion: 'correlation-engine-v1',
    recordCount: (data) => (data.military?.length ?? 0) + (data.escalation?.length ?? 0) + (data.economic?.length ?? 0) + (data.disaster?.length ?? 0),
    extraKeys: [
      { key: 'correlation:military:v1', ttl: CACHE_TTL },
      { key: 'correlation:escalation:v1', ttl: CACHE_TTL },
      { key: 'correlation:economic:v1', ttl: CACHE_TTL },
      { key: 'correlation:disaster:v1', ttl: CACHE_TTL },
    ].map(ek => ({
      key: ek.key,
      ttl: ek.ttl,
      transform: (data) => data[ek.key.split(':')[1]],
    })),
  
    declareRecords,
    schemaVersion: 1,
    maxStaleMin: 15,
  }).catch((err) => {
    const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : ''; console.error('FATAL:', (err.message || err) + _cause);
    process.exit(1);
  });
}
