import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import {
  Sparkles, RefreshCw, ChevronDown, TrendingUp, TrendingDown,
  Minus, BarChart2, Clock, Calendar, AlertCircle, Link2,
} from 'lucide-react'
import axios from 'axios'
import clsx from 'clsx'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import InstrumentChart, { TheoPoint } from '../components/InstrumentChart'
import SaxoLinkPicker from '../components/SaxoLinkPicker'
import { useSetInstrumentSaxoLink, useInstrumentsWatchlist, useQuickAddInstrument } from '../hooks/useApi'
import { fmtPrice } from '../lib/format'

const api = axios.create({ baseURL: '/api' })

const WAVELET_BAND_COLORS = ['#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6', '#84cc16']

// Stable empty array — prevents InstrumentChart useEffect from re-running on every render
const NO_CHART_EVENTS: never[] = []

// Tiny inline shape indicator for a band's recent RMS trajectory — same energy level can
// hide an accelerating vs. a decelerating band; the sparkline shows which.
function Sparkline({ values, color, width = 56, height = 18 }: { values: number[]; color: string; width?: number; height?: number }) {
  if (values.length < 2) return null
  const min = Math.min(...values), max = Math.max(...values)
  const range = max - min || 1
  const points = values
    .map((v, i) => `${((i / (values.length - 1)) * width).toFixed(1)},${(height - ((v - min) / range) * height).toFixed(1)}`)
    .join(' ')
  return (
    <svg width={width} height={height} className="inline-block align-middle opacity-80">
      <polyline points={points} fill="none" stroke={color} strokeWidth={1.2} />
    </svg>
  )
}

// ── Wavelet regime engine ──────────────────────────────────────────────────────
// Wavelets answer "how is variance spread across timescales"; volatility answers "how
// much variance is there in total" — two prices can carry the same slow-band energy while
// one trends smoothly (100→105 in a straight line) and the other only gets there via wild
// swings (100→95→105→98→110→103). Same Trend Ratio, wildly different volatility. Combining
// both, plus their own trends, into a 6-regime classifier per the spec discussed.

type RegimeBucket = { label: string; color: string }

function bucketTrendRatio(r: number | null): RegimeBucket {
  if (r == null) return { label: '—', color: 'text-slate-500' }
  if (r < 1) return { label: 'Bruit dominant', color: 'text-red-400' }
  if (r < 3) return { label: 'Mixte', color: 'text-amber-400' }
  if (r < 10) return { label: 'Tendance', color: 'text-emerald-400' }
  return { label: 'Cycle dominant', color: 'text-blue-400' }
}
function bucketCoherence(c: number | null): RegimeBucket {
  if (c == null) return { label: '—', color: 'text-slate-500' }
  if (c > 0.8) return { label: 'Direction très propre', color: 'text-emerald-400' }
  if (c >= 0.4) return { label: 'Direction moyenne', color: 'text-amber-400' }
  return { label: 'Oscillation', color: 'text-red-400' }
}
function bucketDynamic(slopePct: number | null): RegimeBucket {
  if (slopePct == null) return { label: '—', color: 'text-slate-500' }
  if (slopePct > 0.5) return { label: 'Se renforce', color: 'text-emerald-400' }
  if (slopePct < -0.5) return { label: "S'affaiblit", color: 'text-red-400' }
  return { label: 'Stable', color: 'text-slate-400' }
}
function bucketVolPercentile(p: number | null): RegimeBucket {
  if (p == null) return { label: '—', color: 'text-slate-500' }
  if (p < 20) return { label: 'Très calme', color: 'text-blue-400' }
  if (p < 60) return { label: 'Normal', color: 'text-emerald-400' }
  if (p < 80) return { label: 'Agité', color: 'text-amber-400' }
  return { label: 'Extrême', color: 'text-red-400' }
}

function classifyMarketRegime(input: {
  trendRatio: number | null; coherenceSlow: number | null; volPercentile: number | null
  volDelta: number | null; trendRatioSlope: number | null; slowSlopePct: number | null
}): { name: string; reading: string; color: string } {
  const { trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct } = input
  const trHigh = trendRatio != null && trendRatio > 3
  const trLow = trendRatio != null && trendRatio < 1
  const cohHigh = coherenceSlow != null && coherenceSlow > 0.8
  const cohLow = coherenceSlow != null && coherenceSlow < 0.4
  const volLow = volPercentile != null && volPercentile < 40
  const volHigh = volPercentile != null && volPercentile > 60
  const volUp = volDelta != null && volDelta > 3
  const volDown = volDelta != null && volDelta < -3
  const trUp = trendRatioSlope != null && trendRatioSlope > 0.2
  const trDown = trendRatioSlope != null && trendRatioSlope < -0.2
  const slowUp = slowSlopePct != null && slowSlopePct > 0.5
  const slowDown = slowSlopePct != null && slowSlopePct < -0.5

  if (trHigh && cohHigh && volLow && volDown)
    return { name: 'Drift', reading: 'Marché extrêmement organisé — tendance lente propre, sous faible volatilité qui continue de baisser.', color: 'text-blue-400' }
  if (trHigh && cohHigh && volHigh && volUp)
    return { name: 'Tendance explosive', reading: 'Même direction que le Drift, mais en accélération — momentum fort sous volatilité montante.', color: 'text-emerald-400' }
  if (trLow && volLow)
    return { name: 'Compression', reading: 'Accumulation — toutes les bandes sont faibles, marché en attente de sortie de range.', color: 'text-slate-400' }
  if (trLow && volHigh && cohLow)
    return { name: 'Chaos', reading: 'News / panique — bruit dominant sous forte volatilité, aucune direction nette.', color: 'text-red-400' }
  if (trDown && slowDown && volUp)
    return { name: 'Transition', reading: "La structure se casse — la tendance lente s'affaiblit pendant que la volatilité monte.", color: 'text-amber-400' }
  if (trUp && slowUp && volDown)
    return { name: 'Construction de tendance', reading: 'Le marché devient de plus en plus propre — tendance lente qui se renforce sous volatilité qui retombe.', color: 'text-emerald-400' }
  return { name: 'Indéterminé', reading: "Combinaison de signaux ambiguë — aucun des 6 régimes types ne se dégage nettement à cette date.", color: 'text-slate-500' }
}

// ── Types ─────────────────────────────────────────────────────────────────────

interface CausalTemplate {
  id: number; name: string; category: string; sub_type: string
  instruments: string[]; description: string
  graph_json: {
    nodes: { id: string; type: string; label: string; instrument?: string }[]
    edges: { from: string; to: string; lag_days?: number; lag_min?: number; sign?: string }[]
  }
  calibration_json: {
    absorption_days?: number; lag_days?: number; half_life_days?: number; decay_type?: string
  }
}

interface InstrumentConfig {
  id: string; name: string; yf_ticker: string; category: string; currency: string
  description: string
  regime_labels: string[]
  chart: { ma_periods: number[]; show_volume: boolean }
  correlation_instruments: string[]
  saxo_quote_symbol?: string | null
}

interface PriceCandle { time: string; open: number; high: number; low: number; close: number; volume: number }
interface LinePoint    { time: string; value: number }

interface SnapshotEvent {
  id?: number; template_id?: number | null
  analyzed_instruments?: string | null   // comma-sep: "EURUSD,SP500" — set when causal analysis exists
  date: string; end_date: string | null; title: string; level: string
  category: string; sub_type?: string; description: string; impact_score: number
  expected_value?: string | null; actual_value?: string | null
  surprise_pct?: number | null; unit?: string | null; absorption_pct?: number | null
  prediction_json?: string | null   // JSON: {node_id: pips} from causal_event_analyses
  actual_json?: string | null       // JSON: {instrument: pips} from causal_event_analyses
  activation_score?: number | null  // stored directional accuracy from causal_event_analyses
}

interface RegimeSignals {
  ma50_above_ma200: boolean | null; ma50_slope_pct: number; ma200_slope_pct: number
  momentum_20d_pct: number; dist_ma200_pct: number; vol_ratio_pct: number
}

interface TrendMetrics {
  ma50_slope_5d: number; ma200_slope_20d: number; rsi14_current: number
  atr14_current: number; atr_vs_3m_avg_pct: number; momentum_1m_pct: number
  momentum_3m_pct: number; dist_ma50_pct: number | null; dist_ma200_pct: number | null
  current_price: number; high_52w: number; low_52w: number
}

interface MacroRegime {
  dominant: string
  label: string
  color: string
  emoji: string
  scores: Record<string, number>
  ranked: string[]
  asset_bias: Record<string, any>
}

interface GaugeValue {
  id: string; label: string; value: number | null; change_pct: number | null
  unit: string; bloc: string; note?: string
}

interface MacroGaugeSnap {
  snapshot_date: string
  dominant: string
  regime_scores: Record<string, number>
  gauges: Record<string, GaugeValue>
}

interface Snapshot {
  instrument: InstrumentConfig
  price_data: PriceCandle[]
  indicators: Record<string, LinePoint[]>
  regime: { current: string; confidence: number; scores: Record<string, number>; signals: RegimeSignals }
  macro_regime: MacroRegime
  trend: TrendMetrics
  events: SnapshotEvent[]
  current_price: number; change_pct: number; change_abs: number; period: string
  source?: 'saxo' | 'yfinance'
  source_error?: string | null
}

// ── Helpers ───────────────────────────────────────────────────────────────────

const CATEGORY_LABELS: Record<string, string> = {
  equity_index: 'Indices US', equity_intl: 'Intl Equity', metal: 'Métaux',
  energy: 'Énergie', bond: 'Obligataire', credit: 'Crédit',
  fx: 'Forex', volatility: 'Volatilité', stock: 'Actions', crypto: 'Crypto',
}

// Instrument Analysis's catalog keys FX pairs with yfinance's "=X" suffix (EURUSD=X),
// while the Cockpit watchlist stores the plain ticker (EURUSD) — try both forms before
// concluding a watchlist instrument has no catalog entry yet (same logic as Dashboard.tsx's
// resolveCatalogId, duplicated here since the two pages don't share a components module).
function resolveWatchlistCatalogId(ticker: string, catalog: { id: string }[]): string | undefined {
  const upper = ticker.toUpperCase()
  const ids = new Set(catalog.map(i => i.id.toUpperCase()))
  if (ids.has(upper)) return upper
  if (ids.has(`${upper}=X`)) return `${upper}=X`
  return undefined
}

function regimeColor(label: string): string {
  const l = label.toLowerCase()
  if (/bull|expan|recov|rally|strength|risk.on|inflow|demand|falling|weakness/i.test(l)) return 'emerald'
  if (/bear|crash|crunch|shock|squeeze|fear|risk.off|selloff|crisis|deficit/i.test(l)) return 'red'
  if (/volatil|spike|stress/i.test(l)) return 'orange'
  if (/range|neutral|consolid|compress/i.test(l)) return 'slate'
  return 'blue'
}

function pctColor(v: number | null, inverse = false): string {
  if (v === null || v === undefined) return 'text-slate-400'
  const pos = inverse ? v < 0 : v > 0
  const neg = inverse ? v > 0 : v < 0
  return pos ? 'text-emerald-400' : neg ? 'text-red-400' : 'text-slate-400'
}

function Arrow({ v }: { v: number }) {
  if (v > 0.3) return <TrendingUp className="w-3.5 h-3.5 text-emerald-400" />
  if (v < -0.3) return <TrendingDown className="w-3.5 h-3.5 text-red-400" />
  return <Minus className="w-3.5 h-3.5 text-slate-500" />
}

function fmt(v: number | null, digits = 2): string {
  if (v === null || v === undefined) return '—'
  return (v >= 0 ? '+' : '') + v.toFixed(digits)
}

function fmtDateFR(s: string | null): string {
  if (!s) return '—'
  const [y, m, d] = s.split('-')
  return `${d}/${m}/${y}`
}

function pctN(a: number | undefined, b: number | undefined): number {
  if (a === undefined || b === undefined || b === 0) return 0
  return ((a - b) / b) * 100
}

function isActiveAt(ev: SnapshotEvent, selectedDate: string | null): boolean {
  if (!selectedDate) return false
  if (ev.date > selectedDate) return false
  if (ev.end_date) return ev.end_date >= selectedDate
  const diffDays = (new Date(selectedDate).getTime() - new Date(ev.date).getTime()) / 86400000
  return diffDays <= 30
}

// ── Causal graph config ───────────────────────────────────────────────────────

// Maps snapshot event category → causal template category
// market_events.category → causal_graph_templates.category
// event_calendar & fundamental cover both US and EU; geopolitical/report/sentiment map 1-to-1
const EVENT_TO_CAUSAL_CAT: Record<string, string[]> = {
  event_calendar: ['macro_us', 'macro_eu'],
  fundamental:    ['macro_us', 'macro_eu'],
  geopolitical:   ['geopolitical'],
  report:         ['report'],
  sentiment:      ['sentiment'],
  commodity:      ['commodity'],
  technical:      [],
}

// Maps instrument dashboard category → causal lab instrument keys
const CAT_TO_CAUSAL_INST: Record<string, string[]> = {
  equity_index: ['SP500'],
  equity_intl:  ['SP500'],
  metal:        ['XAUUSD'],
  energy:       ['BRENT'],
  fx:           ['EURUSD'],
  bond:         ['US10Y', 'EU10Y'],
  credit:       ['US10Y'],
  volatility:   ['SP500'],
  stock:        ['SP500'],
  crypto:       [],
}

// Keyed by market_event.category — same palette as the ★ stars on the chart
const EV_CAT_TW: Record<string, string> = {
  event_calendar: 'text-amber-400 border-amber-700/40 bg-amber-900/20',
  geopolitical:   'text-red-400 border-red-700/40 bg-red-900/20',
  fundamental:    'text-emerald-400 border-emerald-700/40 bg-emerald-900/20',
  report:         'text-blue-400 border-blue-700/40 bg-blue-900/20',
  sentiment:      'text-violet-400 border-violet-700/40 bg-violet-900/20',
  technical:      'text-cyan-400 border-cyan-700/40 bg-cyan-900/20',
}

// ── Macro regime colour mapping ───────────────────────────────────────────────

const MACRO_COLOR_MAP: Record<string, string> = {
  goldilocks:       'emerald',
  desinflation:     'cyan',
  soft_landing:     'blue',
  reflation:        'amber',
  stagflation:      'orange',
  inflation_shock:  'red',
  recession:        'red',
  crise_liquidite:  'red',
  incertain:        'slate',
}

function macroRegimeColor(dominant: string): string {
  return MACRO_COLOR_MAP[dominant] ?? 'slate'
}

// ── RegimeCard ────────────────────────────────────────────────────────────────

function RegimeCard({
  regime, macroRegime, signalsAt, dateLabel,
}: {
  regime: Snapshot['regime']
  macroRegime: MacroRegime | null
  signalsAt: RegimeSignals | null
  dateLabel: string
}) {
  const signals = signalsAt ?? regime.signals
  const col = regimeColor(regime.current)

  const borderMap: Record<string, string> = {
    emerald: 'border-emerald-700/40 bg-emerald-950/30',
    red:     'border-red-700/40 bg-red-950/30',
    orange:  'border-orange-700/40 bg-orange-950/30',
    slate:   'border-slate-700/40 bg-slate-900/30',
    blue:    'border-blue-700/40 bg-blue-950/30',
    cyan:    'border-cyan-700/40 bg-cyan-950/30',
    amber:   'border-amber-700/40 bg-amber-950/30',
  }

  const ma50Color = signals.ma50_above_ma200 ? 'text-emerald-400' : 'text-red-400'
  const volColor  = (signals.vol_ratio_pct ?? 0) > 130 ? 'text-orange-400' : (signals.vol_ratio_pct ?? 0) < 70 ? 'text-cyan-400' : 'text-slate-300'

  const metrics: { label: string; value: string; sub?: string; color: string }[] = [
    {
      label: 'MA50 / MA200',
      value: signals.ma50_above_ma200 === true ? 'Au-dessus' : signals.ma50_above_ma200 === false ? 'En-dessous' : '—',
      sub: signals.ma50_above_ma200 === true ? '↑ Golden cross' : signals.ma50_above_ma200 === false ? '↓ Death cross' : '',
      color: ma50Color,
    },
    { label: 'Slope MA50 (10j)',  value: fmt(signals.ma50_slope_pct) + '%',  color: pctColor(signals.ma50_slope_pct) },
    { label: 'Slope MA200 (10j)', value: fmt(signals.ma200_slope_pct) + '%', color: pctColor(signals.ma200_slope_pct) },
    { label: 'Momentum 20j',      value: fmt(signals.momentum_20d_pct) + '%', color: pctColor(signals.momentum_20d_pct) },
    {
      label: 'Distance MA200',
      value: fmt(signals.dist_ma200_pct) + '%',
      sub: signals.dist_ma200_pct > 10 ? 'Surextension' : signals.dist_ma200_pct < -10 ? 'Survendu' : 'Neutre',
      color: pctColor(signals.dist_ma200_pct),
    },
    {
      label: 'Volatilité ATR',
      value: (signals.vol_ratio_pct ?? 0).toFixed(0) + '%',
      sub: (signals.vol_ratio_pct ?? 0) > 130 ? 'Élevée' : (signals.vol_ratio_pct ?? 0) < 70 ? 'Comprimée' : 'Normale',
      color: volColor,
    },
  ]

  const macroCol = macroRegime ? macroRegimeColor(macroRegime.dominant) : 'slate'
  const top3Macro = macroRegime?.ranked?.slice(0, 3) ?? []

  return (
    <div className={clsx('rounded-xl border p-4 space-y-3', borderMap[col])}>
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <BarChart2 className={clsx('w-4 h-4', `text-${col}-400`)} />
          <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
        </div>
        <span className="text-xs text-slate-600">{dateLabel}</span>
      </div>

      {macroRegime && macroRegime.dominant !== 'incertain' && (
        <div className="rounded-lg border border-slate-700/30 bg-dark-700/40 px-3 py-2 space-y-1.5">
          <div className="flex items-center justify-between">
            <span className="text-xs text-slate-500 uppercase tracking-wide">Cycle macro global</span>
            <span className="text-xs text-slate-600">Fed cycle</span>
          </div>
          <div className="flex items-center gap-2">
            <span className="text-base">{macroRegime.emoji || '🌍'}</span>
            <span className={clsx('text-sm font-bold', `text-${macroCol}-300`)}>{macroRegime.label || macroRegime.dominant}</span>
          </div>
          {top3Macro.length > 0 && (
            <div className="flex flex-wrap gap-1 pt-0.5">
              {top3Macro.map((key, i) => {
                const score = macroRegime.scores?.[key]
                const c = macroRegimeColor(key)
                return (
                  <span key={key} className={clsx(
                    'text-xs px-1.5 py-0.5 rounded border',
                    i === 0 ? `text-${c}-300 bg-${c}-900/30 border-${c}-700/40` : 'text-slate-500 bg-dark-800/40 border-slate-700/30'
                  )}>
                    {key} {score !== undefined ? Math.round(score) + '%' : ''}
                  </span>
                )
              })}
            </div>
          )}
        </div>
      )}

      <div className={clsx('rounded-lg px-3 py-2', `bg-${col}-900/20`)}>
        <div className="text-xs text-slate-500 mb-0.5">Régime technique</div>
        <div className={clsx('text-sm font-bold leading-tight', `text-${col}-300`)}>{regime.current}</div>
        <div className="flex items-center gap-2 mt-1">
          <div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
            <div className={clsx('h-full rounded-full', `bg-${col}-500`)}
              style={{ width: `${Math.round(regime.confidence * 100)}%` }} />
          </div>
          <span className="text-xs text-slate-500">{Math.round(regime.confidence * 100)}%</span>
        </div>
      </div>

      <div className="grid grid-cols-2 gap-2">
        {metrics.map(m => (
          <div key={m.label} className="rounded-lg bg-dark-700/50 px-2.5 py-2">
            <div className="text-xs text-slate-600 mb-0.5 truncate">{m.label}</div>
            <div className={clsx('text-sm font-bold', m.color)}>{m.value}</div>
            {m.sub && <div className="text-xs text-slate-600 mt-0.5">{m.sub}</div>}
          </div>
        ))}
      </div>
    </div>
  )
}

// ── TrendCard ─────────────────────────────────────────────────────────────────

function TrendCard({ trend, dateLabel }: { trend: TrendMetrics; dateLabel: string }) {
  const rsi = trend.rsi14_current ?? 50
  const rsiColor = rsi > 70 ? 'text-orange-400' : rsi < 30 ? 'text-cyan-400' : 'text-slate-300'
  const rsiZone  = rsi > 70 ? 'Suracheté' : rsi < 30 ? 'Survendu' : 'Neutre'

  const items = [
    { group: 'Tendance', rows: [
      { label: 'Slope MA50 (5j)',   value: fmt(trend.ma50_slope_5d) + '%',   arrow: trend.ma50_slope_5d   },
      { label: 'Slope MA200 (20j)', value: fmt(trend.ma200_slope_20d) + '%', arrow: trend.ma200_slope_20d },
      { label: 'Distance MA50',     value: trend.dist_ma50_pct  != null ? fmt(trend.dist_ma50_pct)  + '%' : '—', arrow: trend.dist_ma50_pct  ?? 0 },
      { label: 'Distance MA200',    value: trend.dist_ma200_pct != null ? fmt(trend.dist_ma200_pct) + '%' : '—', arrow: trend.dist_ma200_pct ?? 0, bold: true },
    ]},
    { group: 'Momentum', rows: [
      { label: 'Momentum 1M', value: fmt(trend.momentum_1m_pct) + '%', arrow: trend.momentum_1m_pct },
      { label: 'Momentum 3M', value: fmt(trend.momentum_3m_pct) + '%', arrow: trend.momentum_3m_pct, bold: true },
    ]},
  ]

  const pct52w = trend.high_52w && trend.low_52w && (trend.high_52w - trend.low_52w) > 0
    ? ((trend.current_price - trend.low_52w) / (trend.high_52w - trend.low_52w)) * 100 : null

  return (
    <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <TrendingUp className="w-4 h-4 text-blue-400" />
          <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Indicateurs de Tendance</span>
        </div>
        <span className="text-xs text-slate-600">{dateLabel}</span>
      </div>

      <div className="flex items-center justify-between bg-dark-700/40 rounded-lg px-3 py-1.5">
        <span className="text-xs text-slate-500">Prix</span>
        <span className="text-sm font-bold text-white">
          {fmtPrice(trend.current_price)}
        </span>
      </div>

      {items.map(group => (
        <div key={group.group}>
          <div className="text-xs text-slate-600 uppercase tracking-wide mb-1.5">{group.group}</div>
          {group.rows.map(row => (
            <div key={row.label} className="flex items-center justify-between text-xs py-0.5">
              <span className="text-slate-500">{row.label}</span>
              <span className={clsx('flex items-center gap-1', (row as any).bold ? 'font-semibold' : '', pctColor(row.arrow ?? 0))}>
                <Arrow v={row.arrow ?? 0} />{row.value}
              </span>
            </div>
          ))}
        </div>
      ))}

      <div className="border-t border-slate-700/30 pt-3">
        <div className="flex items-center justify-between text-xs mb-1.5">
          <span className="text-slate-500">RSI(14)</span>
          <span className={clsx('font-semibold', rsiColor)}>{rsi.toFixed(0)} — {rsiZone}</span>
        </div>
        <div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
          <div className="absolute left-0 top-0 h-full w-[30%] bg-cyan-900/50" />
          <div className="absolute right-0 top-0 h-full w-[30%] bg-orange-900/50" />
          <div className="absolute top-0 h-full w-2 bg-white rounded-full transition-all"
            style={{ left: `calc(${Math.min(98, rsi)}% - 4px)` }} />
        </div>
        <div className="flex justify-between text-xs text-slate-600 mt-0.5">
          <span>0 Survendu</span><span>50</span><span>Suracheté 100</span>
        </div>
      </div>

      {pct52w !== null && (
        <div>
          <div className="flex items-center justify-between text-xs mb-1">
            <span className="text-slate-500">Range 52 semaines</span>
            <span className="text-slate-400">{Math.round(pct52w)}e percentile</span>
          </div>
          <div className="relative h-2 bg-slate-800 rounded-full overflow-hidden">
            <div className="absolute top-0 h-full bg-blue-500/60 rounded-full" style={{ width: `${pct52w}%` }} />
          </div>
          <div className="flex justify-between text-xs text-slate-600 mt-0.5">
            <span>{fmtPrice(trend.low_52w)}</span><span>{fmtPrice(trend.high_52w)}</span>
          </div>
        </div>
      )}

      <div className="flex items-center justify-between text-xs">
        <span className="text-slate-500">ATR(14) vs moy. 3M</span>
        <span className={clsx((trend.atr_vs_3m_avg_pct ?? 100) > 130 ? 'text-orange-400' : (trend.atr_vs_3m_avg_pct ?? 100) < 70 ? 'text-cyan-400' : 'text-slate-400')}>
          {(trend.atr_vs_3m_avg_pct ?? 100).toFixed(0)}%
        </span>
      </div>
    </div>
  )
}

// ── EventsCard ────────────────────────────────────────────────────────────────

function EventsCard({ events, selectedDate }: { events: SnapshotEvent[]; selectedDate: string | null }) {
  const navigate = useNavigate()
  const LEVEL_C: Record<string, string> = {
    long:   'text-violet-400 bg-violet-900/30 border-violet-700/30',
    medium: 'text-blue-400 bg-blue-900/30 border-blue-700/30',
    short:  'text-emerald-400 bg-emerald-900/30 border-emerald-700/30',
  }

  function isActiveAt(ev: SnapshotEvent): boolean {
    if (!selectedDate) return false
    const evStart = ev.date
    const evEnd   = ev.end_date
    if (evStart > selectedDate) return false
    if (evEnd) return evEnd >= selectedDate
    // point event: active if within 30 days after
    const diffDays = (new Date(selectedDate).getTime() - new Date(evStart).getTime()) / 86400000
    return diffDays <= 30
  }

  function surpriseColor(v: number | null | undefined): string {
    if (v === null || v === undefined) return 'text-slate-400'
    if (v > 5) return 'text-emerald-400'
    if (v < -5) return 'text-red-400'
    return 'text-slate-400'
  }

  return (
    <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
      <div className="flex items-center gap-2">
        <Calendar className="w-4 h-4 text-amber-400" />
        <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Événements Macro</span>
      </div>
      {events.length === 0 ? (
        <div className="text-xs text-slate-600 italic py-2">Aucun événement lié trouvé</div>
      ) : (
        <div className="space-y-2 max-h-[380px] overflow-y-auto pr-1">
          {events.map((ev, i) => {
            const active = isActiveAt(ev)
            return (
              <div key={i}
                className={clsx(
                  'rounded-lg border p-2 cursor-pointer transition-colors',
                  active
                    ? 'border-amber-600/50 bg-amber-900/20 hover:bg-amber-900/30'
                    : 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
                )}
                onClick={() => navigate(`/timeline?date=${ev.date}`)}
              >
                <div className="flex items-start justify-between gap-2">
                  <div className="flex items-center gap-1.5 min-w-0">
                    {active && <span className="shrink-0 w-1.5 h-1.5 rounded-full bg-amber-400 mt-0.5" />}
                    <span className="text-xs font-medium text-slate-300 leading-tight truncate">{ev.title}</span>
                  </div>
                  <div className="flex items-center gap-1 shrink-0">
                    {ev.sub_type && (
                      <span className="text-xs px-1 py-0 rounded bg-slate-700/50 text-slate-400 border border-slate-600/30"
                        style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
                        {ev.sub_type}
                      </span>
                    )}
                    <span className={clsx('text-xs px-1.5 py-0.5 rounded border', LEVEL_C[ev.level] ?? 'text-slate-400 bg-slate-800 border-slate-700/30')}>
                      {ev.level === 'long' ? 'LT' : ev.level === 'medium' ? 'MT' : 'CT'}
                    </span>
                  </div>
                </div>

                {/* Eco calendar fields */}
                {(ev.expected_value || ev.actual_value) && (
                  <div className="flex items-center gap-3 mt-1.5 text-xs">
                    {ev.expected_value && (
                      <span className="text-slate-500">Att. <span className="text-slate-300">{ev.expected_value}</span></span>
                    )}
                    {ev.actual_value && (
                      <span className="text-slate-500">Réel <span className="font-semibold text-white">{ev.actual_value}</span></span>
                    )}
                    {ev.surprise_pct !== null && ev.surprise_pct !== undefined && (
                      <span className={clsx('font-semibold', surpriseColor(ev.surprise_pct))}>
                        {ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(0)}%
                        {ev.surprise_pct > 5 ? ' ↑' : ev.surprise_pct < -5 ? ' ↓' : ''}
                      </span>
                    )}
                    {ev.unit && <span className="text-slate-600">{ev.unit}</span>}
                  </div>
                )}

                {ev.absorption_pct !== null && ev.absorption_pct !== undefined && (
                  <div className="mt-1.5">
                    <div className="flex items-center justify-between text-xs text-slate-600 mb-0.5">
                      <span>Absorption marché</span><span>{ev.absorption_pct}%</span>
                    </div>
                    <div className="h-1 bg-slate-800 rounded-full overflow-hidden">
                      <div className="h-full bg-amber-600/60 rounded-full" style={{ width: `${ev.absorption_pct}%` }} />
                    </div>
                  </div>
                )}

                <div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
                  <Clock className="w-3 h-3" />
                  {fmtDateFR(ev.date)}{ev.end_date ? ` → ${fmtDateFR(ev.end_date)}` : ''}
                  {ev.impact_score > 0.7 && <AlertCircle className="w-3 h-3 text-orange-400" />}
                </div>
                {ev.description && <p className="text-xs text-slate-600 mt-1 line-clamp-1">{ev.description}</p>}
              </div>
            )
          })}
        </div>
      )}
    </div>
  )
}

// ── NarrativeCard ─────────────────────────────────────────────────────────────

function NarrativeCard({ narrative, loading, onLoad, instrument }: {
  narrative: string; loading: boolean; onLoad: () => void; instrument: InstrumentConfig
}) {
  return (
    <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
      <div className="flex items-center justify-between mb-3">
        <div className="flex items-center gap-2">
          <Sparkles className="w-4 h-4 text-violet-400" />
          <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Narration IA — {instrument.name}</span>
        </div>
        <button onClick={onLoad} disabled={loading}
          className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-violet-800/30 hover:bg-violet-800/50 text-violet-300 border border-violet-700/40 rounded-lg transition-colors disabled:opacity-40"
        >
          {loading ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
          {loading ? 'Génération...' : narrative ? 'Actualiser' : 'Générer'}
        </button>
      </div>
      {narrative ? (
        <p className="text-sm text-slate-300 leading-relaxed">{narrative}</p>
      ) : loading ? (
        <div className="space-y-2">
          {[95, 88, 70].map(w => <div key={w} className="h-3 bg-slate-800 rounded animate-pulse" style={{ width: `${w}%` }} />)}
        </div>
      ) : (
        <div className="text-sm text-slate-600 italic">
          Cliquez "Générer" pour une analyse IA pour {instrument.name}.
        </div>
      )}
    </div>
  )
}

// ── Macro gauge helpers ───────────────────────────────────────────────────────

const SCENARIO_META: Record<string, { label: string; emoji: string; color: string }> = {
  goldilocks:       { label: 'Goldilocks',             emoji: '🟢', color: '#10b981' },
  desinflation:     { label: 'Désinflation',           emoji: '🔵', color: '#3b82f6' },
  soft_landing:     { label: 'Soft Landing',           emoji: '🔷', color: '#06b6d4' },
  reflation:        { label: 'Reflation',              emoji: '🟠', color: '#f97316' },
  stagflation:      { label: 'Stagflation',            emoji: '🟡', color: '#f59e0b' },
  inflation_shock:  { label: 'Choc Inflationniste',    emoji: '🔥', color: '#dc2626' },
  recession:        { label: 'Récession',              emoji: '🔴', color: '#ef4444' },
  crise_liquidite:  { label: 'Crise de liquidité',     emoji: '🟣', color: '#7c3aed' },
  incertain:        { label: 'Incertain',              emoji: '⬜', color: '#64748b' },
}

function snapToMacroRegime(snap: MacroGaugeSnap): MacroRegime {
  const scores = snap.regime_scores ?? {}
  const meta   = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain
  const ranked = Object.entries(scores)
    .sort(([, a], [, b]) => b - a)
    .map(([k]) => k)
  return {
    dominant:   snap.dominant,
    label:      meta.label,
    color:      meta.color,
    emoji:      meta.emoji,
    scores,
    ranked,
    asset_bias: {},
  }
}

const BLOC_LABELS: Record<string, string> = {
  liquidite:  'Liquidité / Taux',
  credit:     'Crédit / Vol',
  energie:    'Énergie',
  metaux:     'Métaux',
  croissance: 'Croissance US',
  secteurs:   'Secteurs',
  volatilite: 'Volatilité surf.',
  global:     'Global / EM',
  forex_ro:   'Forex Risk-Off',
  derive:     'Dérivés',
}

function MacroGaugePanel({ snap, dateLabel }: { snap: MacroGaugeSnap; dateLabel: string }) {
  const byBloc: Record<string, GaugeValue[]> = {}
  for (const g of Object.values(snap.gauges)) {
    if (g.value === null && g.change_pct === null) continue
    const b = g.bloc ?? 'derive'
    if (!byBloc[b]) byBloc[b] = []
    byBloc[b].push(g)
  }
  const meta = SCENARIO_META[snap.dominant] ?? SCENARIO_META.incertain

  return (
    <div className="bg-dark-800/60 rounded-xl border border-slate-700/30 p-4 space-y-3">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-2">
          <span className="text-base">{meta.emoji}</span>
          <span className="text-sm font-semibold text-white">{meta.label}</span>
          <span className="text-xs text-slate-500">— contexte macro au {dateLabel}</span>
        </div>
        <span className="text-xs text-slate-600">{snap.snapshot_date}</span>
      </div>

      {/* Regime scores mini bar */}
      <div className="flex gap-1 h-1.5">
        {Object.entries(snap.regime_scores)
          .sort(([,a],[,b]) => b - a)
          .slice(0, 6)
          .map(([k, v]) => (
            <div key={k} title={`${SCENARIO_META[k]?.label ?? k}: ${(v*100).toFixed(0)}%`}
              style={{ width: `${v*100}%`, background: SCENARIO_META[k]?.color ?? '#64748b' }}
              className="rounded-full transition-all"
            />
          ))}
      </div>

      {/* Gauges by bloc */}
      <div className="grid grid-cols-2 gap-x-6 gap-y-3">
        {Object.entries(byBloc).map(([bloc, gauges]) => (
          <div key={bloc}>
            <div className="text-xs text-slate-600 uppercase tracking-wide mb-1">{BLOC_LABELS[bloc] ?? bloc}</div>
            <div className="space-y-0.5">
              {gauges.map(g => (
                <div key={g.id} className="flex items-center justify-between text-xs">
                  <span className="text-slate-400 truncate max-w-[120px]" title={g.label}>{g.label}</span>
                  <div className="flex items-center gap-2">
                    {g.value !== null && (
                      <span className="text-white font-mono">
                        {g.unit === '%' ? g.value.toFixed(2) + '%'
                          : g.unit === 'pts' ? g.value.toFixed(1)
                          : g.unit === 'ratio' ? g.value.toFixed(3)
                          : g.value.toFixed(2)}
                      </span>
                    )}
                    {g.change_pct !== null && g.change_pct !== undefined && (
                      <span className={clsx('font-mono', g.change_pct >= 0 ? 'text-emerald-400' : 'text-red-400')}>
                        {g.change_pct >= 0 ? '+' : ''}{g.change_pct.toFixed(1)}%
                      </span>
                    )}
                  </div>
                </div>
              ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

// ── Shared: trading-day-index x-coordinate ────────────────────────────────────
// Uses the same time scale as LightweightCharts (trading days, weekends skipped).
// For a date D, snaps to the nearest available trading day index.
function makeTdToX(priceData: PriceCandle[], width: number): (d: string) => number {
  const dates = priceData.map(c => c.time)
  const N = dates.length
  if (N < 2) return () => 0
  function snap(d: string): number {
    if (d <= dates[0]) return 0
    if (d >= dates[N - 1]) return N - 1
    let lo = 0, hi = N - 1
    while (lo < hi) {
      const mid = (lo + hi) >> 1
      if (dates[mid] < d) lo = mid + 1
      else hi = mid
    }
    return lo
  }
  return (d: string) => (snap(d) / (N - 1)) * width
}

// ── Comprehension scoring ─────────────────────────────────────────────────────

function templateAbsorptionDays(tmpl: CausalTemplate): number {
  const fromCalib = tmpl.calibration_json?.absorption_days
  if (fromCalib && fromCalib > 0) return fromCalib
  const maxLag = Math.max(0, ...tmpl.graph_json.edges.map(e => e.lag_days || 0))
  return maxLag > 0 ? maxLag : 30
}

/** Score 0-100 measuring how well the graph predicted actual pips for an instrument. */
function comprehensionScore(ev: SnapshotEvent, tmpl: CausalTemplate, instrument: string): number | null {
  // Prefer the stored activation_score from causal_event_analyses (consistent with MarketEvents panel)
  if (ev.activation_score != null) return Math.round(ev.activation_score * 100)

  if (!ev.prediction_json || !ev.actual_json) return null
  try {
    const preds:   Record<string, number> = JSON.parse(ev.prediction_json)
    const actuals: Record<string, number> = JSON.parse(ev.actual_json)
    const actual = actuals[instrument] ?? actuals[instrument.toUpperCase()] ?? actuals[instrument.toLowerCase()]
    if (actual == null) return null

    const outputNode = tmpl.graph_json.nodes.find(n =>
      (n.type === 'market_asset' || n.type === 'output') && n.instrument?.toUpperCase() === instrument.toUpperCase()
    )
    const predicted = outputNode ? (preds[outputNode.id] ?? null) : null
    if (predicted == null) return null
    if (predicted === 0 && actual === 0) return 100
    if (predicted === 0) return 40

    const sameDir = predicted * actual > 0
    if (!sameDir) return 0
    const ratio = Math.min(Math.abs(actual), Math.abs(predicted)) / Math.max(Math.abs(actual), Math.abs(predicted))
    return Math.round(50 + ratio * 50)
  } catch { return null }
}

// ── CausalFrise ───────────────────────────────────────────────────────────────

const FRISE_LANE_H   = 24   // px par lane
const FRISE_CHIP_H   = 18   // hauteur d'un chip
const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2
const FRISE_AXIS_H   = 20   // axe temporel en bas
const FRISE_MIN_W    = 44   // largeur minimale d'un chip (en px)
const FRISE_POPUP_W  = 220  // largeur du popup

function CausalFrise({
  events, templates, priceData, selectedDate, causalInsts,
  chartDateToX, chartCanvasLeft, chartReady,
}: {
  events:          SnapshotEvent[]
  templates:       CausalTemplate[]
  priceData:       PriceCandle[]
  selectedDate:    string | null
  causalInsts:     string[]
  chartDateToX?:   (d: string) => number | null
  chartCanvasLeft?: number
  chartReady?:     number
}) {
  const navigate     = useNavigate()
  const containerRef = useRef<HTMLDivElement>(null)
  const [contWidth, setContWidth]   = useState(400)
  const [contLeft,  setContLeft]    = useState(0)
  const [activeChip, setActiveChip] = useState<{
    tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number
  } | null>(null)
  const [popupScore, setPopupScore] = useState<number | null | 'loading'>('loading')

  // Fetch analysis live when popup opens — compute same precision as MarketEvents expanded view
  useEffect(() => {
    const evId = activeChip?.ev.id
    if (!evId) { setPopupScore(null); return }
    setPopupScore('loading')
    api.get(`/causal-lab/analyses?market_event_id=${evId}&limit=1`)
      .then(r => {
        const analysis = r.data?.[0]
        console.log('[CausalFrise] analysis for event', evId, analysis)
        if (!analysis) { setPopupScore(null); return }

        // Compute magnitude-weighted precision (same formula as MarketEvents expanded panel)
        const preds: Record<string, number> = analysis.prediction_json || {}
        const actuals: Record<string, number> = analysis.actual_json || {}
        const graph = analysis.graph_json || {}
        const outputNodes: { id: string; instrument?: string }[] = (graph.nodes || [])
          .filter((n: { type?: string }) => n.type === 'market_asset' || n.type === 'output')

        console.log('[CausalFrise] preds:', preds, 'actuals:', actuals, 'outputNodes:', outputNodes)

        const nodeScores = outputNodes.map((n: { id: string; instrument?: string }) => {
          const pred = preds[n.id]
          const act = actuals[n.instrument || n.id] ?? actuals[n.id]
          if (pred == null || act == null) return null
          if (pred === 0 && act === 0) return 100
          if (pred === 0) return 40
          const sameDir = pred * act > 0
          if (!sameDir) return 0
          const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred))
          return Math.round(50 + ratio * 50)
        }).filter((s): s is number => s != null)

        console.log('[CausalFrise] nodeScores:', nodeScores)

        if (nodeScores.length > 0) {
          setPopupScore(Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length))
        } else {
          // Fallback: use stored activation_score if no prediction data
          const stored = analysis.activation_score
          setPopupScore(stored != null ? Math.round(stored * 100) : null)
        }
      })
      .catch(err => { console.error('[CausalFrise] fetch error:', err); setPopupScore(null) })
  }, [activeChip?.ev.id])

  useEffect(() => {
    const el = containerRef.current; if (!el) return
    const update = () => {
      const rect = el.getBoundingClientRect()
      setContWidth(rect.width)
      setContLeft(rect.left)
    }
    update()
    const obs = new ResizeObserver(update)
    obs.observe(el)
    return () => obs.disconnect()
  }, [chartReady])  // re-measure when chart is ready (layout may have changed)

  // Close popup on outside click (tiny delay avoids self-close)
  useEffect(() => {
    if (!activeChip) return
    let tid: ReturnType<typeof setTimeout>
    const close = () => setActiveChip(null)
    tid = setTimeout(() => document.addEventListener('mousedown', close), 60)
    return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) }
  }, [activeChip])

  const linked = events.filter(ev => {
    if (!ev.analyzed_instruments || ev.template_id == null) return false
    const insts = ev.analyzed_instruments.split(',')
    return causalInsts.some(ci => insts.includes(ci))
  })

  if (!priceData.length || !linked.length) {
    return (
      <div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
        Aucun graphe causal lié pour cet instrument
      </div>
    )
  }

  const minDate = priceData[0].time
  const maxDate = priceData[priceData.length - 1].time
  const usable  = Math.max(contWidth, 1)

  // If chart coordinate bridge is available, use it for pixel-perfect alignment.
  // chart.timeToCoordinate() gives x relative to chart canvas left edge.
  // We subtract (chartCanvasLeft - contLeft) to convert to frise container coords.
  // canvasOffset > 0 means chart canvas starts to the right of the frise container.
  const canvasOffset = (chartCanvasLeft ?? 0) - contLeft
  const tdToX: (d: string) => number = chartDateToX
    ? (d: string) => {
        const x = chartDateToX(d)
        if (x !== null) return x - canvasOffset
        // Fallback for out-of-range dates: clamp to edges
        return d <= minDate ? -canvasOffset : usable - canvasOffset
      }
    : makeTdToX(priceData, usable)

  // Build chips (one per event × template)
  type Chip = {
    ev: SnapshotEvent; tmpl: CausalTemplate
    x1: number; x2: number; w: number; active: boolean; absorptionDays: number
  }
  const chips: Chip[] = linked
    .filter(ev => ev.date <= maxDate)
    .map(ev => {
      const tmpl = templates.find(t => t.id === ev.template_id)
      if (!tmpl) return null
      const absorptionDays = templateAbsorptionDays(tmpl)
      const endDate = ev.end_date ?? (() => {
        const d = new Date(ev.date); d.setDate(d.getDate() + absorptionDays)
        return d.toISOString().slice(0, 10)
      })()
      const x1  = tdToX(ev.date)
      const raw = tdToX(endDate) - x1
      const w   = Math.max(raw, FRISE_MIN_W)
      return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate), absorptionDays }
    })
    .filter((c): c is Chip => c !== null)
    .sort((a, b) => a.x1 - b.x1)

  // Greedy lane assignment — no overlap
  const laneEnds: number[] = []
  const placed = chips.map(chip => {
    const GAP = 3
    let lane = laneEnds.findIndex(end => end + GAP <= chip.x1)
    if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
    laneEnds[lane] = chip.x2
    return { ...chip, lane }
  })

  const numLanes   = Math.max(laneEnds.length, 1)
  const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4

  // Month tick marks — skip some if too crowded
  const ticks: { label: string; x: number }[] = []
  {
    const start = new Date(minDate)
    let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1)
    while (cur.toISOString().slice(0, 10) <= maxDate) {
      ticks.push({
        label: cur.toLocaleDateString('fr-FR', {
          month: 'short',
          ...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
        }),
        x: tdToX(cur.toISOString().slice(0, 10)),
      })
      cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
    }
  }
  // Keep 1 label every ~70px to avoid crowding
  const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70)))
  const visTicks = ticks.filter((_, i) => i % tickStep === 0)

  const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
    ? tdToX(selectedDate) : null

  return (
    <div
      ref={containerRef}
      className="relative w-full select-none"
      style={{ height: containerH }}
      onClick={() => setActiveChip(null)}
    >
      {/* Month grid lines */}
      {visTicks.map((t, i) => (
        <div key={i} className="absolute top-0 pointer-events-none"
          style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
      ))}

      {/* Crosshair */}
      {crossX !== null && (
        <div className="absolute top-0 bottom-0 pointer-events-none"
          style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
      )}

      {/* Chips */}
      {placed.map(({ ev, tmpl, x1, w, lane, active, absorptionDays }) => {
        const catTw    = EV_CAT_TW[ev.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40'
        // Height scales with absorption: 13px (short) → 20px (30+ days), centred in lane
        const chipH    = Math.max(13, Math.min(20, Math.round(13 + Math.min(absorptionDays, 30) / 30 * 7)))
        const chipY    = lane * FRISE_LANE_H + Math.floor((FRISE_LANE_H - chipH) / 2)
        const isOpen   = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id
        const charsFit = Math.floor((w - 18) / 5.5)
        const label    = charsFit < 3 ? '' : ev.title.length > charsFit
          ? ev.title.slice(0, charsFit - 1) + '…' : ev.title

        return (
          <div
            key={`${tmpl.id}-${ev.id ?? ev.date}`}
            className={clsx(
              'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
              'text-[9px] font-medium leading-none',
              active
                ? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
                : clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
                    'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
            )}
            style={{ left: x1, top: chipY, width: w, height: chipH }}
            onMouseDown={e => e.stopPropagation()}
            onClick={e => {
              e.stopPropagation()
              setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY })
            }}
            title={`${ev.title} [${tmpl.name}]\n${fmtDateFR(ev.date)} → ${ev.end_date ? fmtDateFR(ev.end_date) : `+${absorptionDays}j`}`}
          >
            <span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
            {label && <span className="truncate">{label}</span>}
          </div>
        )
      })}

      {/* Popup */}
      {activeChip && (() => {
        const { tmpl, ev, chipX, chipY } = activeChip
        const popH   = 108
        const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4
        const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4))
        return (
          <div
            className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
            style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
            onMouseDown={e => e.stopPropagation()}
            onClick={e => e.stopPropagation()}
          >
            <button
              onClick={() => setActiveChip(null)}
              className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
            >×</button>

            <div className="flex items-start gap-2 mb-1.5 pr-4">
              <span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
              <span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
                EV_CAT_TW[ev.category] ?? 'text-slate-500 border-slate-700/30'
              )}>{ev.category}</span>
            </div>

            <div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>

            <div className="flex items-center gap-3 mb-2 text-[9px] text-slate-600">
              <span className="flex items-center gap-1">
                <Clock className="w-2.5 h-2.5" />
                {fmtDateFR(ev.date)} → {ev.end_date ? fmtDateFR(ev.end_date) : `+${templateAbsorptionDays(tmpl)}j`}
              </span>
              {ev.impact_score != null && (
                <span className="text-amber-600">★ {ev.impact_score.toFixed(1)}</span>
              )}
              {ev.surprise_pct != null && (
                <span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
                  {ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
                </span>
              )}
            </div>

            {/* Précision — fetch live depuis causal_event_analyses au moment du clic */}
            {popupScore === 'loading' ? (
              <div className="mb-2 text-[9px] text-slate-600 italic">Chargement score…</div>
            ) : popupScore != null ? (() => {
              const barCls = popupScore >= 70 ? 'bg-emerald-500' : popupScore >= 40 ? 'bg-amber-500' : 'bg-red-500'
              const txtCls = popupScore >= 70 ? 'text-emerald-400' : popupScore >= 40 ? 'text-amber-400' : 'text-red-400'
              return (
                <div className="mb-2">
                  <div className="flex items-center justify-between text-[9px] text-slate-600 mb-0.5">
                    <span>Précision prédiction</span>
                    <span className={txtCls}>{popupScore}%</span>
                  </div>
                  <div className="h-1 bg-slate-800 rounded-full overflow-hidden">
                    <div className={clsx('h-full rounded-full', barCls)} style={{ width: `${popupScore}%` }} />
                  </div>
                </div>
              )
            })() : null}

            <div className="flex gap-1.5">
              <button
                onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
                className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
              >Événement →</button>
              <button
                onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
                className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
              >CausalLab →</button>
            </div>
          </div>
        )
      })()}

      {/* X-axis */}
      <div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
        {visTicks.map((t, i) => (
          <span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
            {t.label}
          </span>
        ))}
      </div>
    </div>
  )
}

// ── ExplanationScore ──────────────────────────────────────────────────────────

function computeMagnitudeScore(analysis: { prediction_json: Record<string, number>; actual_json: Record<string, number>; graph_json: { nodes?: { id: string; type?: string; instrument?: string }[] } }): number | null {
  const preds = analysis.prediction_json || {}
  const actuals = analysis.actual_json || {}
  const outputNodes = (analysis.graph_json?.nodes || []).filter(n => n.type === 'market_asset' || n.type === 'output')
  const nodeScores = outputNodes.map(n => {
    const pred = preds[n.id]
    const act = actuals[n.instrument || n.id] ?? actuals[n.id]
    if (pred == null || act == null) return null
    if (pred === 0 && act === 0) return 100
    if (pred === 0) return 40
    if (pred * act <= 0) return 0
    const ratio = Math.min(Math.abs(act), Math.abs(pred)) / Math.max(Math.abs(act), Math.abs(pred))
    return Math.round(50 + ratio * 50)
  }).filter((s): s is number => s != null)
  return nodeScores.length > 0 ? Math.round(nodeScores.reduce((a, b) => a + b, 0) / nodeScores.length) : null
}

function ExplanationScore({
  events, templates, causalInsts, onRefreshDone, onDebugResult, debugInfo,
}: {
  events: SnapshotEvent[]
  templates: CausalTemplate[]
  causalInsts: string[]
  onRefreshDone?: () => void
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  onDebugResult?: (data: any) => void
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  debugInfo?: any
}) {
  const [refreshing, setRefreshing] = useState(false)
  const [refreshDone, setRefreshDone] = useState(false)
  const [liveScores, setLiveScores] = useState<Record<number, number | null>>({})

  // Fetch all linked event analyses dynamically — same formula as MarketEvents expanded panel
  const linkedIds = events.filter(ev => ev.template_id && ev.id).map(ev => ev.id as number)
  const linkedKey = linkedIds.join(',')
  useEffect(() => {
    if (!linkedIds.length) return
    Promise.all(linkedIds.map(id =>
      api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`)
        .then(r => ({ id, score: computeMagnitudeScore(r.data?.[0]) }))
        .catch(() => ({ id, score: null }))
    )).then(results => {
      setLiveScores(Object.fromEntries(results.map(r => [r.id, r.score])))
    })
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [linkedKey])

  const scored: number[] = []
  const totalLinked: number[] = []

  for (const ev of events) {
    if (!ev.template_id) continue
    const tmpl = templates.find(t => t.id === ev.template_id)
    if (!tmpl) continue
    if (!causalInsts.some(ci => tmpl.instruments.includes(ci))) continue
    totalLinked.push(ev.id ?? 0)
    const s = ev.id != null ? liveScores[ev.id] : null
    if (s != null) scored.push(s)
  }

  const globalScore = scored.length > 0
    ? Math.round(scored.reduce((a, b) => a + b, 0) / scored.length)
    : null

  const verdict = globalScore == null ? 'En attente'
    : globalScore >= 70 ? 'Bien expliqué' : globalScore >= 40 ? 'Partiellement' : 'Peu lisible'
  const badgeCls = globalScore == null
    ? 'text-slate-500 bg-slate-800/60 border-slate-700/30'
    : globalScore >= 70
    ? 'text-emerald-400 bg-emerald-900/30 border-emerald-700/40'
    : globalScore >= 40
    ? 'text-amber-400 bg-amber-900/30 border-amber-700/40'
    : 'text-red-400 bg-red-900/20 border-red-700/30'
  const barCls = globalScore == null ? 'bg-slate-600'
    : globalScore >= 70 ? 'bg-emerald-500' : globalScore >= 40 ? 'bg-amber-500' : 'bg-red-500'

  const handleRefresh = async () => {
    onDebugResult?.(null)
    setRefreshing(true)
    try {
      const res = await api.post('/causal-lab/auto-analyze/refresh')
      const data = res.data
      onDebugResult?.(data)
      setRefreshDone(true)
      onRefreshDone?.()
      setTimeout(() => setRefreshDone(false), 8000)
    } catch (err: any) {
      onDebugResult?.({ error: String(err) })
    } finally {
      setRefreshing(false)
    }
  }

  const needsRefresh = totalLinked.length > 0 && scored.length === 0 && !refreshDone

  return (
    <div className="space-y-2">
      <div className="flex items-center gap-3 px-4 py-2.5 rounded-xl border border-slate-700/30 bg-dark-800/50">
        <span className="text-xs text-slate-500 whitespace-nowrap">Note globale</span>
        <div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
          <div className={clsx('h-full rounded-full transition-all', barCls)} style={{ width: `${globalScore ?? 0}%` }} />
        </div>
        <span className={clsx('text-xs font-semibold px-2 py-1 rounded-lg border whitespace-nowrap', badgeCls)}>
          {globalScore != null ? `${globalScore}% — ` : ''}{verdict}
        </span>
        <span className="text-[10px] text-slate-600 whitespace-nowrap">
          {scored.length}/{totalLinked.length} graphes
        </span>
        {(needsRefresh || scored.length === 0) && !refreshDone && totalLinked.length > 0 && (
          <button
            onClick={handleRefresh}
            disabled={refreshing}
            className="text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 border border-violet-800/40 rounded px-2 py-1 whitespace-nowrap disabled:opacity-50 transition-colors"
          >
            {refreshing ? '…' : 'Recalculer'}
          </button>
        )}
      </div>

      {/* Refresh result panel */}
      {debugInfo && (
        <div className="rounded-lg border border-slate-700/30 bg-dark-900/60 p-3 text-[10px] font-mono text-slate-600">
          {debugInfo.error
            ? <span className="text-red-500">Erreur: {debugInfo.error}</span>
            : <span>Refresh: {debugInfo.refreshed} ok / {debugInfo.failed} err / {debugInfo.total} total</span>
          }
        </div>
      )}
    </div>
  )
}

// ── Main page ─────────────────────────────────────────────────────────────────

const PERIODS = [
  { key: '5d',  label: '5D'  },
  { key: '1mo', label: '1M'  },
  { key: '3mo', label: '3M'  },
  { key: '6mo', label: '6M'  },
  { key: '1y',  label: '1Y'  },
  { key: '2y',  label: '2Y'  },
  { key: '5y',  label: '5Y'  },
]

// ── Types factor-state ────────────────────────────────────────────────────────
interface FactorContrib {
  event_name: string; event_date: string; template_name: string
  pips_full: number; days_elapsed: number; absorption_days: number
  decay_pct: number; pips_current: number
}
interface FactorCategory {
  label: string; pips: number; contributions: FactorContrib[]
}
interface FactorState {
  instrument: string; at_date: string; net_pips: number
  direction: 'bullish' | 'bearish' | 'neutral'
  categories: FactorCategory[]; n_events: number
}

// ── PressureCockpit ───────────────────────────────────────────────────────────
function PressureCockpit({ instrumentId, refreshKey }: { instrumentId: string; refreshKey: number }) {
  const [state, setState]     = useState<FactorState | null>(null)
  const [loading, setLoading] = useState(false)
  const [expanded, setExpanded] = useState<string | null>(null)

  useEffect(() => {
    if (!instrumentId) return
    setLoading(true)
    api.get(`/instruments/${instrumentId}/factor-state`)
      .then(r => setState(r.data))
      .catch(() => setState(null))
      .finally(() => setLoading(false))
  }, [instrumentId, refreshKey])

  if (loading) return (
    <div className="h-16 flex items-center justify-center text-xs text-slate-600 italic">
      Calcul pression en cours…
    </div>
  )
  if (!state) return null

  const { net_pips, direction, categories } = state
  const maxAbs = Math.max(...categories.map(c => Math.abs(c.pips)), 1)
  const netCls  = direction === 'bullish' ? 'text-emerald-400' : direction === 'bearish' ? 'text-red-400' : 'text-slate-400'
  const netLabel = direction === 'bullish' ? '▲ HAUSSIER' : direction === 'bearish' ? '▼ BAISSIER' : '◼ NEUTRE'

  return (
    <div className="space-y-2">
      {/* NET */}
      <div className="flex items-center gap-3 px-1">
        <span className="text-xs text-slate-500 uppercase tracking-wide">Pression nette</span>
        <span className={clsx('font-mono font-bold text-base', netCls)}>
          {net_pips >= 0 ? '+' : ''}{net_pips} pips
        </span>
        <span className={clsx('text-[10px] font-semibold px-1.5 py-0.5 rounded border', netCls,
          direction === 'bullish' ? 'border-emerald-700/40 bg-emerald-900/20'
          : direction === 'bearish' ? 'border-red-700/40 bg-red-900/20'
          : 'border-slate-700/40 bg-slate-800/20')}>
          {netLabel}
        </span>
        <span className="ml-auto text-[10px] text-slate-600">{state.n_events} event{state.n_events > 1 ? 's' : ''} actifs</span>
      </div>

      {/* Barres par catégorie */}
      {categories.length === 0 ? (
        <p className="text-xs text-slate-600 italic px-1">Aucun event actif avec prédiction pour cet instrument.</p>
      ) : (
        <div className="space-y-1.5">
          {categories.map(cat => {
            const barW   = Math.round(Math.abs(cat.pips) / maxAbs * 100)
            const isPos  = cat.pips >= 0
            const isOpen = expanded === cat.label
            return (
              <div key={cat.label}>
                <button
                  className="w-full flex items-center gap-2 text-xs hover:bg-slate-800/30 rounded px-1 py-0.5 transition-colors"
                  onClick={() => setExpanded(isOpen ? null : cat.label)}
                >
                  <span className="w-32 text-left text-slate-400 shrink-0 truncate">{cat.label}</span>
                  {/* Barre */}
                  <div className="flex-1 h-3 bg-slate-800/60 rounded-full overflow-hidden">
                    <div
                      className={clsx('h-full rounded-full transition-all', isPos ? 'bg-emerald-600/70' : 'bg-red-600/70')}
                      style={{ width: `${barW}%` }}
                    />
                  </div>
                  <span className={clsx('font-mono font-semibold w-16 text-right shrink-0', isPos ? 'text-emerald-400' : 'text-red-400')}>
                    {isPos ? '+' : ''}{cat.pips}
                  </span>
                  <span className="text-slate-600 text-[10px] shrink-0">{isOpen ? '▲' : '▼'}</span>
                </button>

                {/* Détail events */}
                {isOpen && (
                  <div className="ml-2 mt-1 space-y-1 border-l border-slate-700/40 pl-3">
                    {cat.contributions.map((c, i) => (
                      <div key={i} className="flex items-center gap-2 text-[10px] text-slate-500">
                        <span className="flex-1 truncate">{c.event_name}</span>
                        <span className="text-slate-600 shrink-0">{c.event_date} · {c.decay_pct}% actif ({c.days_elapsed}j)</span>
                        <span className={clsx('font-mono shrink-0 w-12 text-right', c.pips_current >= 0 ? 'text-emerald-500' : 'text-red-500')}>
                          {c.pips_current >= 0 ? '+' : ''}{c.pips_current}
                        </span>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            )
          })}
        </div>
      )}
    </div>
  )
}

export default function InstrumentDashboard({ instrumentIdProp, isVisible }: { instrumentIdProp?: string; isVisible?: boolean } = {}) {
  const { id: paramId = localStorage.getItem('last_instrument') || 'EURUSD=X' } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [period, setPeriod]           = useState('1y')
  const [chartStyle, setChartStyle]   = useState<'candles' | 'line'>('candles')
  const [instruments, setInstruments] = useState<InstrumentConfig[]>([])
  const setSaxoLink = useSetInstrumentSaxoLink()
  const { data: watchlistItems } = useInstrumentsWatchlist()
  const quickAdd = useQuickAddInstrument()
  const [addingTicker, setAddingTicker] = useState<string | null>(null)
  const [snapshot, setSnapshot]       = useState<Snapshot | null>(null)
  const [narrative, setNarrative]     = useState('')
  const [loading, setLoading]         = useState(false)
  const [refreshDebug, setRefreshDebug] = useState<any>(null)
  const [loadingNarr, setLoadingNarr] = useState(false)
  const [selectorOpen, setSelectorOpen] = useState(false)
  const [selectedDate, setSelectedDate] = useState<string | null>(null)
  const [searchParams] = useSearchParams()
  // ?tab=wavelets lets other pages (e.g. Dashboard's Wavelets Signal card) deep-link
  // straight into the Wavelets panel instead of always landing on Counters.
  const [tabUnder, setTabUnder]         = useState<'counters' | 'analyse' | 'wavelets' | 'regime'>(() => {
    const t = searchParams.get('tab')
    return t === 'counters' || t === 'analyse' || t === 'regime' ? t : 'wavelets'
  })
  const [waveletLevels, setWaveletLevels]   = useState(4)
  const [waveletFamily, setWaveletFamily]   = useState<'gmw' | 'morlet' | 'bump'>('gmw')
  const [waveletMethod, setWaveletMethod]   = useState<'cwt' | 'ssq'>('cwt')
  const [waveletCausal, setWaveletCausal]   = useState(false)
  const [waveletLookback, setWaveletLookback] = useState(260)
  const [waveletData, setWaveletData]       = useState<any | null>(null)
  // 'cached' = last per-cycle scan result (services.wavelet_signals, same source as the
  // Dashboard's Wavelets Signal card) — loaded automatically. 'live' = a fresh on-demand
  // run from the "Start analyse" button, with whatever params are set above; not persisted
  // back into the cache, so it can diverge from what the Signal card shows until the next cycle.
  const [waveletSource, setWaveletSource]   = useState<'cached' | 'live' | null>(null)
  const [waveletCachedAt, setWaveletCachedAt] = useState<string | null>(null)
  const [loadingWavelet, setLoadingWavelet] = useState(false)
  const [waveletReliability, setWaveletReliability] = useState<any | null>(null)
  const [loadingReliability, setLoadingReliability] = useState(false)
  const [hiddenBands, setHiddenBands]       = useState<Set<string>>(new Set())
  const [curveRegime, setCurveRegime]       = useState<any | null>(null)
  const [loadingCurveRegime, setLoadingCurveRegime] = useState(false)
  const [hoveredWaveletIdx, setHoveredWaveletIdx] = useState<number | null>(null)
  const [templates, setTemplates]       = useState<CausalTemplate[]>([])
  const [causalScores, setCausalScores] = useState<Record<number, number | null>>({}) // eventId → activation_score*100
  const [macroAtDate, setMacroAtDate]   = useState<MacroGaugeSnap | null>(null)
  const [theoryCurve, setTheoryCurve]   = useState<TheoPoint[] | null>(null)
  const [loadingTheory, setLoadingTheory] = useState(false)
  const [showTheory, setShowTheory]     = useState(false)
  // Chart coordinate bridge — lets CausalFrise align with the chart's x-axis
  const chartDateToXRef = useRef<((d: string) => number | null) | null>(null)
  const chartCanvasLeftRef = useRef<number>(0)
  const [chartReady, setChartReady] = useState(0)   // bump to trigger frise re-render

  // instrumentIdProp is set when mounted as a keep-alive tab (so URL params don't bleed across instances)
  const instrumentId = (instrumentIdProp ?? paramId).toUpperCase()

  useEffect(() => {
    api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
    api.get('/causal-lab/templates').then(r => setTemplates(r.data)).catch(() => {})
  }, [])

  // Silent refresh when the tab becomes visible again (e.g. after analysis updated from MarketEvents)
  const mountedRef = useRef(false)
  useEffect(() => {
    if (!mountedRef.current) { mountedRef.current = true; return }
    if (isVisible) fetchSnapshotSilent()
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isVisible])

  const fetchSnapshot = useCallback(() => {
    setLoading(true)
    api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
      .then(r => {
        setSnapshot(r.data)
        const pd: PriceCandle[] = r.data.price_data
        if (pd?.length) setSelectedDate(pd[pd.length - 1].time)
      })
      .catch(e => { console.error(`[InstrumentDashboard] snapshot fetch failed for ${instrumentId}:`, e); setSnapshot(null) })
      .finally(() => setLoading(false))
  }, [instrumentId, period])

  // Silent re-fetch (no loading spinner) — used after Recalculer so ExplanationScore keeps its state
  const fetchSnapshotSilent = useCallback(() => {
    api.get(`/instruments/${instrumentId}/snapshot?period=${period}`)
      .then(r => { setSnapshot(r.data) })
      .catch(() => {})
  }, [instrumentId, period])

  // Fetch causal scores directly from the analyses API (not via snapshot) so the popup
  // always shows the DB-stored activation_score, independent of snapshot staleness
  const refreshCausalScores = useCallback((events: SnapshotEvent[]) => {
    const ids = events.map(e => e.id).filter(Boolean) as number[]
    if (!ids.length) return
    Promise.all(ids.map(id =>
      api.get(`/causal-lab/analyses?market_event_id=${id}&limit=1`)
        .then(r => ({ id, score: r.data[0]?.activation_score ?? null }))
        .catch(() => ({ id, score: null }))
    )).then(results => {
      setCausalScores(Object.fromEntries(results.map(r => [r.id, r.score != null ? Math.round(r.score * 100) : null])))
    })
  }, [])

  useEffect(() => {
    setSnapshot(null)
    setNarrative('')
    setSelectedDate(null)
    setMacroAtDate(null)
    setTheoryCurve(null)
    setShowTheory(false)
    chartDateToXRef.current = null
    setChartReady(0)
    fetchSnapshot()
  }, [instrumentId, period])

  // Refresh causal scores whenever the snapshot events change
  useEffect(() => {
    if (snapshot?.events?.length) refreshCausalScores(snapshot.events)
  }, [snapshot?.events, refreshCausalScores])

  const loadNarrative = useCallback(() => {
    setLoadingNarr(true)
    api.post(`/instruments/${instrumentId}/narrative`)
      .then(r => setNarrative(r.data.narrative))
      .catch(() => {})
      .finally(() => setLoadingNarr(false))
  }, [instrumentId])

  const handleDateHover = useCallback((date: string | null) => {
    if (date) setSelectedDate(date)
  }, [])

  const handleChartReady = useCallback((fn: ((d: string) => number | null) | null, left: number) => {
    chartDateToXRef.current = fn
    chartCanvasLeftRef.current = left
    setChartReady(k => k + 1)
  }, [])

  const toggleTheory = useCallback(() => {
    if (showTheory) {
      setShowTheory(false)
      setTheoryCurve(null)
      return
    }
    if (theoryCurve) { setShowTheory(true); return }
    setLoadingTheory(true)
    api.get(`/instruments/${instrumentId}/theoretical-curve?period=${period}`)
      .then(r => { setTheoryCurve(r.data); setShowTheory(true) })
      .catch(() => {})
      .finally(() => setLoadingTheory(false))
  }, [showTheory, theoryCurve, instrumentId, period])

  const { priceMap, indMap, sortedDates, dateIndex } = useMemo(() => {
    if (!snapshot) return { priceMap: {} as Record<string, PriceCandle>, indMap: {} as Record<string, Record<string, number>>, sortedDates: [] as string[], dateIndex: {} as Record<string, number> }
    const priceMap:   Record<string, PriceCandle>            = {}
    const indMap:     Record<string, Record<string, number>> = {}
    const sortedDates: string[] = []
    const dateIndex:  Record<string, number>                  = {}
    for (const c of snapshot.price_data) { priceMap[c.time] = c; sortedDates.push(c.time) }
    sortedDates.forEach((d, i) => { dateIndex[d] = i })
    for (const [key, pts] of Object.entries(snapshot.indicators)) {
      for (const pt of pts) { if (!indMap[pt.time]) indMap[pt.time] = {}; indMap[pt.time][key] = pt.value }
    }
    return { priceMap, indMap, sortedDates, dateIndex }
  }, [snapshot])

  // Fetch macro gauge context when crosshair date changes (after sortedDates is available)
  useEffect(() => {
    if (!selectedDate || !sortedDates.length) return
    const isLast = selectedDate === sortedDates[sortedDates.length - 1]
    if (isLast) { setMacroAtDate(null); return }
    api.get(`/market/macro-gauges/at?date=${selectedDate}`)
      .then(r => { if (r.data?.snapshot_date) setMacroAtDate(r.data) })
      .catch(() => {})
  }, [selectedDate, sortedDates])

  const effectiveDate = useMemo(() => {
    if (selectedDate && dateIndex[selectedDate] !== undefined) return selectedDate
    return sortedDates[sortedDates.length - 1] ?? null
  }, [selectedDate, sortedDates, dateIndex])

  const dateTrend = useMemo((): TrendMetrics | null => {
    if (!effectiveDate || !snapshot) return null
    const candle = priceMap[effectiveDate]; if (!candle) return null
    const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
    const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14 ?? 0
    const d5  = idx >= 5  ? sortedDates[idx - 5]  : null
    const d20 = idx >= 20 ? sortedDates[idx - 20] : null
    const d21 = idx >= 21 ? sortedDates[idx - 21] : null
    const d63 = idx >= 63 ? sortedDates[idx - 63] : null
    let atrSum = 0, atrCnt = 0
    for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
    let high52 = candle.high, low52 = candle.low
    for (let i = Math.max(0, idx - 252); i <= idx; i++) { const c = priceMap[sortedDates[i]]; if (c) { if (c.high > high52) high52 = c.high; if (c.low < low52) low52 = c.low } }
    return {
      ma50_slope_5d:    pctN(ma50, d5  ? indMap[d5]?.ma50  : undefined),
      ma200_slope_20d:  pctN(ma200, d20 ? indMap[d20]?.ma200 : undefined),
      rsi14_current:    ind.rsi14 ?? 50,
      atr14_current:    atr14,
      atr_vs_3m_avg_pct: atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 100,
      momentum_1m_pct:  d21 && priceMap[d21] ? pctN(price, priceMap[d21].close) : 0,
      momentum_3m_pct:  d63 && priceMap[d63] ? pctN(price, priceMap[d63].close) : 0,
      dist_ma50_pct:    ma50  ? pctN(price, ma50)  : null,
      dist_ma200_pct:   ma200 ? pctN(price, ma200) : null,
      current_price: price, high_52w: high52, low_52w: low52,
    }
  }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])

  const dateSignals = useMemo((): RegimeSignals | null => {
    if (!effectiveDate || !snapshot) return null
    const candle = priceMap[effectiveDate]; if (!candle) return null
    const idx = dateIndex[effectiveDate], price = candle.close, ind = indMap[effectiveDate] ?? {}
    const ma50 = ind.ma50, ma200 = ind.ma200, atr14 = ind.atr14
    const d10 = idx >= 10 ? sortedDates[idx - 10] : null
    const d20 = idx >= 20 ? sortedDates[idx - 20] : null
    let atrSum = 0, atrCnt = 0
    for (let i = Math.max(0, idx - 65); i <= idx; i++) { const v = indMap[sortedDates[i]]?.atr14; if (v) { atrSum += v; atrCnt++ } }
    return {
      ma50_above_ma200: ma50 !== undefined && ma200 !== undefined ? ma50 > ma200 : null,
      ma50_slope_pct:   pctN(ma50,  d10 ? indMap[d10]?.ma50  : undefined),
      ma200_slope_pct:  pctN(ma200, d10 ? indMap[d10]?.ma200 : undefined),
      momentum_20d_pct: d20 && priceMap[d20] ? pctN(price, priceMap[d20].close) : 0,
      dist_ma200_pct:   ma200 ? pctN(price, ma200) : 0,
      vol_ratio_pct:    atrCnt > 0 && atr14 ? (atr14 / (atrSum / atrCnt)) * 100 : 0,
    }
  }, [effectiveDate, priceMap, indMap, sortedDates, dateIndex, snapshot])

  // The picker only offers Cockpit Watchlist instruments (Config) — no more browsing the
  // underlying instruments.json catalog directly. Order matches the Watchlist's own
  // sort_order (services.database.get_instruments_watchlist).
  const watchlistOptions = (watchlistItems as any[]) ?? []

  const selected     = instruments.find(i => i.id === instrumentId)
  const isLastDate   = effectiveDate === sortedDates[sortedDates.length - 1]
  const dateLabel    = effectiveDate ? fmtDateFR(effectiveDate) : '—'
  const displayPrice = dateTrend?.current_price ?? snapshot?.current_price

  // On-demand LIVE recompute — the "Start analyse" button only. Does not touch
  // wavelet_decomposition_cache, so it can show a different result (custom params, more
  // recent tick) than the cached view without disturbing what the Dashboard's Wavelets
  // Signal card / other instruments' cached tabs show until the next scheduled cycle.
  const runWaveletAnalysis = async () => {
    if (!selected) return
    setLoadingWavelet(true)
    try {
      const path = waveletCausal ? '/wavelet/rolling' : '/wavelet/analyze'
      const params: any = { symbol: selected.yf_ticker, period, levels: waveletLevels, wavelet: waveletFamily, method: waveletMethod }
      if (selected.saxo_quote_symbol) params.saxo_symbol = selected.saxo_quote_symbol
      if (waveletCausal) params.lookback = waveletLookback
      const { data } = await api.get(path, { params })
      setWaveletData(data)
      setWaveletSource('live')
      setWaveletCachedAt(null)
      setHiddenBands(new Set())
    } catch (e) {
      console.error('Wavelet analysis failed', e)
      setWaveletData(null)
      setWaveletSource(null)
    } finally {
      setLoadingWavelet(false)
    }
  }

  // Load the cached decomposition once per instrument, the first time the Wavelet tab
  // becomes active for it — NOT a live call. Same data the per-cycle Watchlist scan writes
  // (services.wavelet_signals.scan_watchlist_wavelet_signals), so this tab always agrees
  // with the Dashboard's Wavelets Signal card and never needs a click just to show the
  // current state. 404 (no cycle has scanned this ticker yet, or it's not Watchlist-linked)
  // just leaves the empty state up — "Start analyse" still runs a live one on demand.
  // Keyed by instrumentId in a ref so tweaking levels/family/method for a live run
  // afterward doesn't silently re-trigger this.
  const waveletAutoFetchKey = useRef<string | null>(null)
  useEffect(() => {
    if (tabUnder !== 'wavelets' || !selected) return
    if (waveletAutoFetchKey.current === instrumentId) return
    waveletAutoFetchKey.current = instrumentId
    setWaveletData(null)
    setWaveletSource(null)
    setWaveletCachedAt(null)
    setLoadingWavelet(true)
    api.get(`/instruments/${encodeURIComponent(instrumentId)}/wavelet-cache`)
      .then(({ data }) => {
        setWaveletData(data)
        setWaveletSource('cached')
        setWaveletCachedAt(data.computed_at ?? null)
        setHiddenBands(new Set())
      })
      .catch(() => { /* no cache yet — expected, empty state stays up */ })
      .finally(() => setLoadingWavelet(false))
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tabUnder, instrumentId, selected])

  // Curve Regime tab — auto-loads once per instrument, same pattern as the Wavelet tab
  // above (reuses the period selector's current snapshot, no separate live trigger).
  const curveRegimeAutoFetchKey = useRef<string | null>(null)
  useEffect(() => {
    if (tabUnder !== 'regime' || !selected) return
    if (curveRegimeAutoFetchKey.current === `${instrumentId}|${period}`) return
    curveRegimeAutoFetchKey.current = `${instrumentId}|${period}`
    setCurveRegime(null)
    setLoadingCurveRegime(true)
    api.get(`/instruments/${encodeURIComponent(instrumentId)}/curve-regime`, { params: { period } })
      .then(({ data }) => setCurveRegime(data))
      .catch((e) => console.error(`[CurveRegime] fetch failed for ${instrumentId}:`, e))
      .finally(() => setLoadingCurveRegime(false))
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [tabUnder, instrumentId, selected, period])

  const runWaveletReliability = async () => {
    if (!selected) return
    setLoadingReliability(true)
    try {
      const { data } = await api.get('/wavelet/reliability', {
        params: {
          symbol: selected.yf_ticker, period, levels: waveletLevels,
          wavelet: waveletFamily, method: waveletMethod, lookback: waveletLookback,
          ...(selected.saxo_quote_symbol ? { saxo_symbol: selected.saxo_quote_symbol } : {}),
        },
      })
      setWaveletReliability(data)
    } catch (e) {
      console.error('Wavelet reliability failed', e)
      setWaveletReliability(null)
    } finally {
      setLoadingReliability(false)
    }
  }

  // Re-fetch the instrument catalog (source of `selected.saxo_quote_symbol`) and the
  // current snapshot after linking/unlinking a Saxo symbol — this component manages its
  // own data via useState/axios rather than React Query, so there's no cache to invalidate.
  const handleSaxoLinkSave = (saxoSymbol: string | null) => {
    if (!instrumentId) return
    setSaxoLink.mutate({ instrumentId, saxoSymbol }, {
      onSuccess: () => {
        api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
        fetchSnapshot()
      },
    })
  }

  // Picking a Watchlist instrument that has no catalog entry yet: quick-add it (copies
  // its saxo_quote_symbol over automatically, see services.instrument_service.
  // quick_add_instrument_from_watchlist) then navigate straight there. Already-resolvable
  // tickers (e.g. EURUSD -> EURUSD=X) skip the round-trip and navigate directly.
  const handleSelectWatchlist = (ticker: string) => {
    const existing = resolveWatchlistCatalogId(ticker, instruments)
    if (existing) {
      navigate(`/instruments/${encodeURIComponent(existing)}`)
      setSelectorOpen(false)
      return
    }
    setAddingTicker(ticker)
    quickAdd.mutate(ticker, {
      onSuccess: ({ id }) => {
        api.get('/instruments').then(r => setInstruments(r.data)).catch(() => {})
        navigate(`/instruments/${encodeURIComponent(id)}`)
        setSelectorOpen(false)
        setAddingTicker(null)
      },
      onError: (e) => {
        console.error('Quick-add from watchlist failed', e)
        setAddingTicker(null)
      },
    })
  }

  const waveletChartData = useMemo(() => {
    if (!waveletData) return []
    const { dates, original, bands, mean } = waveletData
    // mean is a single scalar for a whole-range decomposition (/wavelet/analyze), but a
    // per-day series for the causal/rolling one (/wavelet/rolling and the cached decomposition
    // this tab loads by default) — each trailing window is de-meaned independently before
    // decomposing it, and that mean drifts as the window slides forward, so there's no single
    // constant that reconstructs every day correctly. Without picking the right one per day,
    // the reconstruction (and the shared price axis it's plotted on) comes out silently
    // missing the entire price level, not just slightly off.
    return dates.map((d: string, i: number) => {
      const row: any = { date: d.slice(0, 10), original: original[i] }
      let recon = Array.isArray(mean) ? (mean[i] ?? 0) : (mean ?? 0)
      for (const b of bands) { row[b.label] = b.series[i]; recon += b.series[i] }
      row.reconstruction = recon
      return row
    })
  }, [waveletData])

  const waveletStats = useMemo(() => {
    if (!waveletData || waveletChartData.length < 2) return null
    const orig: number[] = waveletData.original
    const recon = waveletChartData.map((r: any) => r.reconstruction)
    const n = orig.length
    const meanO = orig.reduce((a: number, b: number) => a + b, 0) / n
    const meanR = recon.reduce((a: number, b: number) => a + b, 0) / n
    let cov = 0, varO = 0, varR = 0, sumAbs = 0, maxAbs = 0
    for (let i = 0; i < n; i++) {
      const dO = orig[i] - meanO, dR = recon[i] - meanR
      cov += dO * dR; varO += dO * dO; varR += dR * dR
      const err = Math.abs(orig[i] - recon[i])
      sumAbs += err; maxAbs = Math.max(maxAbs, err)
    }
    const correlation = varO > 0 && varR > 0 ? cov / Math.sqrt(varO * varR) : null
    return { correlation, meanAbsError: sumAbs / n, maxAbsError: maxAbs }
  }, [waveletData, waveletChartData])

  // Rolling energy per band E_i(t) = mean(w_i(t-k)^2, k=0..L-1) — RMS power of each band
  // over a trailing window, its share of total energy across bands, and its 20-day change.
  // Trend Ratio = (sum of all faster bands) / (slowest band) — a single number meant to be
  // far more stable than watching a band's raw coefficient cross zero: high → a genuine
  // slow trend dominates (breakouts tend to hold); low → high-frequency noise dominates
  // (range, false breakouts).
  //
  // Two additions on top of raw energy:
  // - dE/dt (WAVELET_SLOPE_LAG-day slope of the rolling energy): energy alone doesn't say
  //   whether a band is building or fading — two bands can carry the same RMS while one is
  //   accelerating and the other decaying, and only the slope tells them apart.
  // - Coherence C_i(t) = |rolling mean(w_i)| / RMS(w_i): energy also doesn't capture whether
  //   a band is moving persistently in one direction or oscillating symmetrically around
  //   zero — a clean sinusoid and ragged noise can carry identical energy. Bounded [0,1] by
  //   construction (Cauchy-Schwarz: |mean| ≤ RMS always) — 1 = every value in the window
  //   pushed the same direction (a real directional cycle), 0 = perfectly symmetric,
  //   cancels out (pure oscillation, no net drift).
  const WAVELET_ROLLING_WINDOW = 20
  const WAVELET_SLOPE_LAG = 5

  const waveletEnergy = useMemo(() => {
    const bands: { label: string; series: number[]; reconstruction_failed?: boolean }[] = waveletData?.bands ?? []
    const n = bands[0]?.series?.length ?? 0
    if (!bands.length || n === 0) return null

    const L = WAVELET_ROLLING_WINDOW
    const energyByBand: number[][] = []
    const meanByBand: number[][] = []
    const coherenceByBand: number[][] = []
    for (const b of bands) {
      const s = b.series
      const e = new Array(n).fill(0)
      const m = new Array(n).fill(0)
      const c = new Array(n).fill(0)
      let sumSq = 0
      let sum = 0
      for (let t = 0; t < n; t++) {
        sumSq += s[t] * s[t]
        sum += s[t]
        if (t >= L) { sumSq -= s[t - L] * s[t - L]; sum -= s[t - L] }
        const count = Math.min(t + 1, L)
        e[t] = sumSq / count
        m[t] = sum / count
        const rms = Math.sqrt(e[t])
        c[t] = rms > 1e-12 ? Math.min(1, Math.abs(m[t]) / rms) : 0
      }
      energyByBand.push(e)
      meanByBand.push(m)
      coherenceByBand.push(c)
    }

    // dE/dt: short-lag slope of the (already-smoothed) rolling energy — a continuous
    // "is this band's power building or fading right now" series, distinct from the
    // 20-day snapshot delta already shown in the table.
    const slopeByBand: (number | null)[][] = energyByBand.map(e =>
      e.map((_, t) => (t >= WAVELET_SLOPE_LAG ? (e[t] - e[t - WAVELET_SLOPE_LAG]) / WAVELET_SLOPE_LAG : null))
    )

    // The "slowest" band = the one with the largest upper period bound parsed from its own
    // label (e.g. "25-256j" → 256). Falls back to the last band in the array (the usual
    // fast→slow ordering convention) if any label doesn't parse.
    const periodHighs = bands.map(b => {
      const m = b.label.match(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)/)
      return m ? parseFloat(m[2]) : null
    })
    const slowIdx = periodHighs.every(v => v != null)
      ? periodHighs.indexOf(Math.max(...(periodHighs as number[])))
      : bands.length - 1

    const relByDate: number[][] = []
    const trendRatioByDate: (number | null)[] = []
    for (let t = 0; t < n; t++) {
      const values = energyByBand.map(e => e[t])
      const total = values.reduce((a, b) => a + b, 0)
      relByDate.push(values.map(v => (total > 0 ? (v / total) * 100 : 0)))
      // slow/fast (not the literal fast/slow from the spec — see note where this is used):
      // a HIGH ratio should mean "the slow trend dominates", matching the worked example
      // (70% slow / 30% fast called "trend-dominated") — fast/slow gives 0.43 there, which
      // reads as noise-dominated on the very same 3=trend/0.3=noise scale, contradicting it.
      const slowE = values[slowIdx]
      const fastSum = values.reduce((a, v, i) => (i === slowIdx ? a : a + v), 0)
      trendRatioByDate.push(fastSum > 1e-12 ? slowE / fastSum : null)
    }

    return { bands, energyByBand, meanByBand, coherenceByBand, slopeByBand, relByDate, trendRatioByDate, slowIdx, n, L }
  }, [waveletData])

  const waveletEnergyIdx = waveletEnergy
    ? Math.min(hoveredWaveletIdx ?? waveletEnergy.n - 1, waveletEnergy.n - 1)
    : 0

  // Volatility percentile (vs. this instrument's own history — 8% means something
  // different on EURUSD than on the NASDAQ, the percentile rank makes it universal) and its
  // own trend, joined to the wavelet's date axis so the regime panel updates on hover too.
  const WAVELET_VOL_DELTA_LAG = 10

  const waveletRegime = useMemo(() => {
    if (!waveletEnergy) return null
    const volPoints: LinePoint[] = snapshot?.indicators?.volatility ?? []
    if (!volPoints.length) return null

    const volByDate = new Map(volPoints.map(p => [p.time, p.value]))
    const sortedVols = volPoints.map(p => p.value).sort((a, b) => a - b)
    const percentileOf = (v: number) => {
      let lo = 0, hi = sortedVols.length
      while (lo < hi) { const mid = (lo + hi) >> 1; if (sortedVols[mid] <= v) lo = mid + 1; else hi = mid }
      return (lo / sortedVols.length) * 100
    }

    const n = waveletEnergy.n
    const volAt: (number | null)[] = new Array(n).fill(null)
    const volPercentileAt: (number | null)[] = new Array(n).fill(null)
    for (let t = 0; t < n; t++) {
      const date = waveletChartData[t]?.date
      const v = date != null ? volByDate.get(date) : undefined
      if (v != null) { volAt[t] = v; volPercentileAt[t] = percentileOf(v) }
    }

    const trendRatioSlopeAt = (t: number): number | null => {
      const lag = WAVELET_SLOPE_LAG
      const cur = waveletEnergy.trendRatioByDate[t]
      const prev = t >= lag ? waveletEnergy.trendRatioByDate[t - lag] : null
      return cur != null && prev != null ? cur - prev : null
    }
    const volDeltaAt = (t: number): number | null => {
      const lag = WAVELET_VOL_DELTA_LAG
      const cur = volPercentileAt[t]
      const prev = t >= lag ? volPercentileAt[t - lag] : null
      return cur != null && prev != null ? cur - prev : null
    }

    return { volAt, volPercentileAt, trendRatioSlopeAt, volDeltaAt }
  }, [waveletEnergy, waveletChartData, snapshot])

  const waveletRegimeView = useMemo(() => {
    if (!waveletEnergy) return null
    const idx = waveletEnergyIdx
    const slowIdx = waveletEnergy.slowIdx
    const trendRatio = waveletEnergy.trendRatioByDate[idx]
    const coherenceSlow = waveletEnergy.coherenceByBand[slowIdx][idx]
    const meanSlow = waveletEnergy.meanByBand[slowIdx][idx]
    const direction = meanSlow > 1e-9 ? 'Haussière' : meanSlow < -1e-9 ? 'Baissière' : 'Neutre'
    const energieSlowPct = waveletEnergy.relByDate[idx][slowIdx]
    const eSlow = waveletEnergy.energyByBand[slowIdx][idx]
    const slopeSlow = waveletEnergy.slopeByBand[slowIdx][idx]
    const slowSlopePct = slopeSlow != null && eSlow > 1e-12 ? (slopeSlow / eSlow) * 100 : null
    const volPercentile = waveletRegime?.volPercentileAt[idx] ?? null
    const volRaw = waveletRegime?.volAt[idx] ?? null
    const volDelta = waveletRegime ? waveletRegime.volDeltaAt(idx) : null
    const trendRatioSlope = waveletRegime ? waveletRegime.trendRatioSlopeAt(idx) : null

    const regime = classifyMarketRegime({ trendRatio, coherenceSlow, volPercentile, volDelta, trendRatioSlope, slowSlopePct })

    return {
      trendRatio, coherenceSlow, direction, energieSlowPct, slowSlopePct,
      volPercentile, volRaw, volDelta, trendRatioSlope, regime,
    }
  }, [waveletEnergy, waveletEnergyIdx, waveletRegime])

  return (
    <div className="p-6 max-w-screen-xl mx-auto space-y-4">

      {/* ── Header ── */}
      <div className="flex flex-wrap items-center gap-3">
        <div className="relative">
          <button onClick={() => setSelectorOpen(s => !s)}
            className="flex items-center gap-2 px-4 py-2 bg-dark-800 border border-slate-700/40 rounded-xl text-white hover:bg-dark-700 transition-colors"
          >
            <BarChart2 className="w-4 h-4 text-blue-400" />
            <span className="font-semibold">{selected?.name ?? instrumentId}</span>
            <span className="text-xs text-slate-500">{selected?.id}</span>
            <ChevronDown className="w-3.5 h-3.5 text-slate-500" />
          </button>
          {selectorOpen && (
            <div className="absolute top-full left-0 mt-1 z-50 bg-dark-800 border border-slate-700/40 rounded-xl shadow-2xl p-3 w-[480px] max-h-[60vh] overflow-y-auto">
              <div className="text-xs text-slate-500 uppercase tracking-wide mb-1.5 px-1">Watchlist (Config)</div>
              {watchlistOptions.length === 0 ? (
                <div className="text-xs text-slate-500 px-1 py-2">Aucun instrument dans la Watchlist du Cockpit.</div>
              ) : (
                <div className="grid grid-cols-3 gap-1">
                  {watchlistOptions.map((w: any) => (
                    <button key={w.ticker}
                      onClick={() => handleSelectWatchlist(w.ticker)}
                      disabled={addingTicker === w.ticker}
                      className={clsx('text-left px-2.5 py-1.5 rounded-lg text-xs transition-colors disabled:opacity-50',
                        w.ticker === instrumentId || `${w.ticker}=X` === instrumentId
                          ? 'bg-blue-800/40 text-blue-300 border border-blue-700/40'
                          : 'text-slate-400 hover:bg-dark-700 hover:text-white'
                      )}
                      title={w.saxo_quote_symbol ? `Lien Saxo auto: ${w.saxo_quote_symbol}` : 'Pas encore lié à Saxo (Config → Instruments Watchlist)'}
                    >
                      <div className="font-semibold flex items-center gap-1">
                        {w.ticker}
                        {w.saxo_quote_symbol && <Link2 className="w-2.5 h-2.5 text-emerald-500 shrink-0" />}
                      </div>
                      <div className="text-slate-500 truncate">{addingTicker === w.ticker ? 'Ajout…' : (w.name || w.ticker)}</div>
                    </button>
                  ))}
                </div>
              )}
            </div>
          )}
        </div>

        {selected && (
          <span className="text-xs px-2 py-1 bg-dark-700 border border-slate-700/30 rounded-lg text-slate-400">
            {CATEGORY_LABELS[selected.category] ?? selected.category}
          </span>
        )}

        {selected && (
          <SaxoLinkPicker ticker={selected.id} kind="quote" saxoSymbol={selected.saxo_quote_symbol ?? null}
            isPending={setSaxoLink.isPending} onSave={handleSaxoLinkSave} />
        )}

        {displayPrice !== undefined && (
          <div className="flex items-center gap-3">
            <span className="text-xl font-bold text-white">
              {fmtPrice(displayPrice)}
            </span>
            {isLastDate && snapshot && (
              <span className={clsx('text-sm font-semibold', (snapshot.change_pct ?? 0) >= 0 ? 'text-emerald-400' : 'text-red-400')}>
                {(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{fmtPrice(snapshot.change_abs)} ({(snapshot.change_pct ?? 0) >= 0 ? '+' : ''}{snapshot.change_pct?.toFixed(2)}%)
              </span>
            )}
            {snapshot?.source === 'saxo' && (
              <span className="flex items-center gap-1 text-[10px] text-emerald-500" title="Prix source Saxo">
                <Link2 className="w-3 h-3" /> ● Saxo
              </span>
            )}
          </div>
        )}

        <div className="ml-auto flex items-center gap-2">
          {/* Chart style toggle */}
          <div className="flex items-center bg-dark-800 border border-slate-700/40 rounded-xl p-1">
            <button
              onClick={() => setChartStyle('candles')}
              title="Chandeliers"
              className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'candles' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
            ><BarChart2 className="w-3.5 h-3.5" /></button>
            <button
              onClick={() => setChartStyle('line')}
              title="Courbe lisse"
              className={clsx('p-1.5 rounded-lg transition-colors', chartStyle === 'line' ? 'bg-blue-700/50 text-blue-300' : 'text-slate-500 hover:text-white hover:bg-dark-700')}
            ><TrendingUp className="w-3.5 h-3.5" /></button>
          </div>
          {/* Period selector */}
          <div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
            {PERIODS.map(p => (
              <button key={p.key} onClick={() => setPeriod(p.key)}
                className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
                  period === p.key ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
                )}
              >
                {p.label}
              </button>
            ))}
          </div>
        </div>

        {selected && <p className="w-full text-xs text-slate-500">{selected.description}</p>}
      </div>

      {/* ── Loading ── */}
      {loading && (
        <div className="space-y-4">
          <div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse" style={{ height: 420 }} />
          <div className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-20" />
          <div className="grid grid-cols-3 gap-4">
            {[1, 2, 3].map(i => <div key={i} className="bg-dark-800/60 rounded-xl border border-slate-700/40 animate-pulse h-72" />)}
          </div>
        </div>
      )}

      {/* ── Content ── */}
      {!loading && snapshot && (
        <>
          {snapshot.price_data.length === 0 && (
            <div className="text-center py-16 text-slate-500 bg-dark-800/40 rounded-xl border border-slate-700/40">
              <BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
              <p>Aucune donnée de prix pour {instrumentId}</p>
              {snapshot.source_error && (
                <p className="text-xs text-slate-600 mt-2 max-w-lg mx-auto">{snapshot.source_error}</p>
              )}
            </div>
          )}
          <InstrumentChart
            priceData={snapshot.price_data}
            indicators={snapshot.indicators}
            events={NO_CHART_EVENTS}
            height={420}
            chartType={chartStyle}
            onDateHover={handleDateHover}
            theoryCurve={showTheory && theoryCurve ? theoryCurve : undefined}
            onChartReady={handleChartReady}
          />

          {/* Date badge */}
          <div className="flex items-center gap-2">
            <div className={clsx(
              'flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border',
              isLastDate
                ? 'bg-slate-800 border-slate-700/40 text-slate-400'
                : 'bg-blue-900/40 border-blue-700/50 text-blue-300'
            )}>
              <Calendar className="w-3 h-3" />
              Snapshot au {dateLabel}
              {!isLastDate && <span className="text-blue-500 ml-1">← survol du graphe</span>}
            </div>
          </div>

          {/* ── Tabs sous la courbe ── */}
          <div className="border-b border-slate-700/40 flex gap-1">
            {([
              { key: 'wavelets', label: 'Wavelet' },
              { key: 'regime',   label: 'Curve Regime' },
              { key: 'counters', label: 'Compteurs' },
              { key: 'analyse',  label: 'Analyse de la courbe' },
            ] as const).map(t => (
              <button
                key={t.key}
                onClick={() => setTabUnder(t.key)}
                className={clsx(
                  'px-4 py-2 text-xs font-medium border-b-2 -mb-px transition-colors',
                  tabUnder === t.key
                    ? 'text-blue-300 border-blue-500'
                    : 'text-slate-500 border-transparent hover:text-slate-300 hover:border-slate-600'
                )}
              >
                {t.label}
              </button>
            ))}
          </div>

          {tabUnder === 'counters' && (
            <>
              <div className="grid grid-cols-3 gap-4">
                <RegimeCard
                  regime={snapshot.regime}
                  macroRegime={macroAtDate ? snapToMacroRegime(macroAtDate) : (snapshot.macro_regime ?? null)}
                  signalsAt={dateSignals}
                  dateLabel={dateLabel}
                />
                <TrendCard
                  trend={dateTrend ?? snapshot.trend}
                  dateLabel={dateLabel}
                />
                <EventsCard
                  events={snapshot.events}
                  selectedDate={effectiveDate}
                />
              </div>
              {macroAtDate && <MacroGaugePanel snap={macroAtDate} dateLabel={dateLabel} />}
            </>
          )}

          {tabUnder === 'analyse' && (() => {
            const causalInsts = CAT_TO_CAUSAL_INST[selected?.category ?? ''] ?? []
            const theoPt = effectiveDate && theoryCurve
              ? theoryCurve.find(p => p.date === effectiveDate) ?? null
              : null
            return (
            <div className="space-y-3">
              {/* Pression nette actuelle */}
              <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
                <div className="flex items-center gap-2 mb-3">
                  <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Pression Nette</span>
                </div>
                <PressureCockpit instrumentId={instrumentId} refreshKey={chartReady} />
              </div>

              {/* Frise des graphes causaux (inclut l'event) */}
              <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
                <div className="flex items-center gap-2 mb-3">
                  <BarChart2 className="w-4 h-4 text-violet-400" />
                  <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des graphes</span>
                  <button
                    onClick={toggleTheory}
                    disabled={loadingTheory}
                    className={`ml-auto px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
                      showTheory
                        ? 'bg-violet-700/60 border-violet-600/60 text-violet-200'
                        : 'bg-dark-900/40 border-slate-600/40 text-slate-400 hover:border-violet-600/40 hover:text-violet-300'
                    }`}
                  >
                    {loadingTheory ? '↻ Chargement…' : showTheory ? '⟁ Théorie ON' : '⟁ Courbe théorique'}
                  </button>
                </div>
                <CausalFrise
                  events={snapshot.events}
                  templates={templates}
                  priceData={snapshot.price_data}
                  selectedDate={effectiveDate}
                  causalInsts={causalInsts}
                  chartDateToX={chartDateToXRef.current ?? undefined}
                  chartCanvasLeft={chartCanvasLeftRef.current}
                  chartReady={chartReady}
                />
              </div>

              {/* Décomposition théorique au curseur */}
              {showTheory && theoPt && (
                <div className="rounded-xl border border-violet-800/40 bg-violet-950/20 p-4">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-violet-400 text-xs">⟁</span>
                    <span className="text-xs font-semibold text-violet-300 uppercase tracking-wide">
                      Contributions théoriques — {dateLabel}
                    </span>
                    <span className={`ml-auto text-sm font-mono font-bold ${theoPt.cumulative_pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
                      {theoPt.cumulative_pips >= 0 ? '+' : ''}{theoPt.cumulative_pips} pips
                    </span>
                  </div>
                  {theoPt.contributions.length === 0 ? (
                    <p className="text-xs text-slate-500 italic">Aucun événement actif à cette date.</p>
                  ) : (
                    <div className="space-y-2">
                      {theoPt.contributions.map((c, i) => (
                        <div key={i} className="flex items-center gap-2 text-xs">
                          <div className="flex-1 min-w-0">
                            <span className="text-slate-300 font-medium truncate block">{c.event_name}</span>
                            <span className="text-slate-500">{c.template_name} · depuis {c.event_date} · {Math.round(c.decay_factor * 100)}% actif</span>
                          </div>
                          <span className={`font-mono font-semibold shrink-0 ${c.pips >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
                            {c.pips >= 0 ? '+' : ''}{c.pips} pip
                          </span>
                        </div>
                      ))}
                    </div>
                  )}
                </div>
              )}

              {showTheory && !theoPt && theoryCurve && (
                <div className="rounded-xl border border-violet-800/30 bg-violet-950/10 p-3 text-xs text-slate-500 text-center">
                  ⟁ Aucune contribution théorique pour cette date
                </div>
              )}

              {/* Note globale */}
              <ExplanationScore
                events={snapshot.events}
                templates={templates}
                causalInsts={causalInsts}
                onRefreshDone={fetchSnapshotSilent}
                onDebugResult={setRefreshDebug}
                debugInfo={refreshDebug}
              />
            </div>
            )
          })()}

          {tabUnder === 'wavelets' && (
            <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
              <div className="flex items-center gap-2 mb-3 flex-wrap">
                <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Décomposition Wavelet</span>
                {waveletSource === 'cached' && (
                  <span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-900/30 border border-emerald-700/30 text-emerald-400"
                    title="Dernier calcul du cycle automatique — même source que la card Wavelets Signal du Cockpit">
                    En cache{waveletCachedAt ? ` · ${waveletCachedAt.slice(0, 16).replace('T', ' ')}` : ''}
                  </span>
                )}
                {waveletSource === 'live' && (
                  <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-900/30 border border-blue-700/30 text-blue-400"
                    title="Calcul à la demande avec les paramètres ci-contre — pas persisté, n'affecte pas la card Wavelets Signal">
                    Calcul live
                  </span>
                )}
                <div className="ml-auto flex items-center gap-2 flex-wrap text-xs">
                  <label className="flex items-center gap-1 text-slate-400">
                    Niveaux
                    <input type="number" min={2} max={6} value={waveletLevels}
                      onChange={e => setWaveletLevels(Math.max(2, Math.min(6, Number(e.target.value) || 4)))}
                      className="w-12 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
                  </label>
                  <select value={waveletFamily} onChange={e => setWaveletFamily(e.target.value as any)}
                    className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
                    <option value="gmw">gmw</option>
                    <option value="morlet">morlet</option>
                    <option value="bump">bump</option>
                  </select>
                  <select value={waveletMethod} onChange={e => setWaveletMethod(e.target.value as any)}
                    className="bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-slate-300">
                    <option value="cwt">CWT</option>
                    <option value="ssq">SSQ</option>
                  </select>
                  <label className="flex items-center gap-1 text-slate-400">
                    <input type="checkbox" checked={waveletCausal} onChange={e => setWaveletCausal(e.target.checked)} />
                    Mode causal
                  </label>
                  {waveletCausal && (
                    <label className="flex items-center gap-1 text-slate-400">
                      Lookback
                      <input type="number" min={32} value={waveletLookback}
                        onChange={e => setWaveletLookback(Math.max(32, Number(e.target.value) || 260))}
                        className="w-14 bg-dark-900 border border-slate-700/40 rounded px-1.5 py-0.5 text-white" />
                    </label>
                  )}
                  <button onClick={runWaveletAnalysis} disabled={loadingWavelet}
                    title="Les bandes se chargent automatiquement à l'ouverture de l'onglet — ce bouton force un nouveau calcul (ex. après avoir changé les paramètres)."
                    className="px-2.5 py-1 rounded text-xs font-medium border border-blue-600/40 bg-blue-700/30 text-blue-200 hover:bg-blue-700/50 transition-colors disabled:opacity-50">
                    {loadingWavelet ? '↻ Calcul…' : '▶ Start analyse'}
                  </button>
                  <button onClick={runWaveletReliability} disabled={loadingReliability}
                    title="Pour chaque retournement détecté en mode causal (walk-forward, sans anticipation), vérifie si un recalcul 10 jours plus tard confirme le même retournement — un indice de fiabilité par bande."
                    className="px-2.5 py-1 rounded text-xs font-medium border border-purple-600/40 bg-purple-700/30 text-purple-200 hover:bg-purple-700/50 transition-colors disabled:opacity-50">
                    {loadingReliability ? '↻ Calcul…' : '◈ Tester la fiabilité'}
                  </button>
                </div>
              </div>

              {waveletData ? (
                <>
                  <div className="flex flex-wrap gap-3 mb-2 text-[10px]">
                    {waveletData.bands.map((b: any, i: number) => (
                      <label key={b.label} className="flex items-center gap-1 cursor-pointer">
                        <input type="checkbox" checked={!hiddenBands.has(b.label)}
                          onChange={() => setHiddenBands(prev => {
                            const next = new Set(prev)
                            if (next.has(b.label)) next.delete(b.label); else next.add(b.label)
                            return next
                          })}
                        />
                        <span style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</span>
                        {b.reconstruction_failed && (
                          <span className="text-amber-400" title="Reconstruction indisponible pour cette bande (pas assez d'échelles, ou échec interne de la librairie ondelette) — la série est à zéro, ce n'est pas 'pas d'énergie'.">⚠</span>
                        )}
                      </label>
                    ))}
                  </div>
                  <ResponsiveContainer width="100%" height={280}>
                    <LineChart data={waveletChartData} margin={{ top: 4, right: 8, left: -14, bottom: 0 }}
                      onMouseMove={(state: any) => {
                        if (state?.activeTooltipIndex != null) setHoveredWaveletIdx(Number(state.activeTooltipIndex))
                      }}
                      onMouseLeave={() => setHoveredWaveletIdx(null)}
                    >
                      <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
                      <XAxis dataKey="date" tick={{ fontSize: 9, fill: '#64748b' }}
                        interval={Math.max(0, Math.floor(waveletChartData.length / 8))} />
                      {/* Two Y-axes: band oscillations (demeaned, small amplitude) would be
                          crushed flat against the raw price level (e.g. ~1.15 for EURUSD) on a
                          shared axis — same dual-scale idiom as InstrumentChart's theoryCurve overlay. */}
                      <YAxis yAxisId="price" tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
                      <YAxis yAxisId="band" orientation="right" tick={{ fontSize: 9, fill: '#64748b' }} domain={['auto', 'auto']} />
                      <Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', borderRadius: 6, fontSize: 10 }} />
                      <Line yAxisId="price" type="monotone" dataKey="original" stroke="#94a3b8" strokeWidth={1.5} dot={false} name="Prix" isAnimationActive={false} />
                      <Line yAxisId="price" type="monotone" dataKey="reconstruction" stroke="#22c55e" strokeWidth={1} strokeDasharray="4 2" dot={false} name="Reconstruction" isAnimationActive={false} />
                      {waveletData.bands.map((b: any, i: number) => !hiddenBands.has(b.label) && (
                        <Line key={b.label} yAxisId="band" type="monotone" dataKey={b.label}
                          stroke={WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]}
                          strokeWidth={1} dot={false} name={b.label} isAnimationActive={false} />
                      ))}
                    </LineChart>
                  </ResponsiveContainer>
                  {waveletStats && (
                    <div className="mt-2 flex items-center gap-4 flex-wrap text-[10px] text-slate-500">
                      <span>Corrélation reconstruction: <span className="text-slate-300 font-mono">{waveletStats.correlation != null ? waveletStats.correlation.toFixed(3) : '—'}</span></span>
                      <span>Erreur moy.: <span className="text-slate-300 font-mono">{waveletStats.meanAbsError.toFixed(3)}</span></span>
                      <span>Erreur max: <span className="text-slate-300 font-mono">{waveletStats.maxAbsError.toFixed(3)}</span></span>
                      {waveletCausal && <span className="ml-auto text-blue-400/70">Mode causal (walk-forward) — {waveletData.recomputations} recalculs</span>}
                    </div>
                  )}

                  {waveletEnergy && (
                    <div className="mt-3 pt-3 border-t border-slate-700/30">
                      <div className="flex items-center justify-between mb-2">
                        <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
                          Énergie par bande {hoveredWaveletIdx != null && waveletChartData[waveletEnergyIdx] && (
                            <span className="normal-case font-normal text-slate-500">— au {waveletChartData[waveletEnergyIdx].date}</span>
                          )}
                        </span>
                        {waveletEnergy.trendRatioByDate[waveletEnergyIdx] != null && (() => {
                          const ratio = waveletEnergy.trendRatioByDate[waveletEnergyIdx]!
                          const verdict = ratio >= 2 ? 'cycle lent dominant' : ratio >= 0.7 ? 'équilibré' : 'bruit dominant'
                          const color = ratio >= 2 ? 'text-emerald-400' : ratio >= 0.7 ? 'text-amber-400' : 'text-red-400'
                          return (
                            <span className="text-xs">
                              Trend Ratio <span className={clsx('font-mono font-bold', color)}>{ratio.toFixed(2)}</span>
                              <span className="text-slate-500"> — {verdict}</span>
                            </span>
                          )
                        })()}
                      </div>
                      <table className="w-full text-[11px]">
                        <thead>
                          <tr className="text-slate-500 text-left">
                            <th className="py-1 pr-3 font-normal">Bande</th>
                            <th className="py-1 pr-3 font-normal text-right">RMS</th>
                            <th className="py-1 pr-3 font-normal text-right" title="Pente de l'énergie sur les derniers jours — accélère (▲) ou ralentit (▼), en % de l'énergie actuelle par jour">dE/dt</th>
                            <th className="py-1 pr-3 font-normal text-right">% énergie</th>
                            <th className="py-1 pr-3 font-normal text-right">Δ énergie ({waveletEnergy.L}j)</th>
                            <th className="py-1 pr-3 font-normal text-right" title="|moyenne| / RMS sur la fenêtre — 1 = mouvement net dans un seul sens (directionnel), 0 = oscillation symétrique sans direction nette">Cohérence</th>
                          </tr>
                        </thead>
                        <tbody>
                          {waveletEnergy.bands.map((b, i) => {
                            const color = WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length]
                            const e = waveletEnergy.energyByBand[i][waveletEnergyIdx]
                            const rms = Math.sqrt(e)
                            const rel = waveletEnergy.relByDate[waveletEnergyIdx][i]
                            const prevIdx = waveletEnergyIdx - waveletEnergy.L
                            const delta = prevIdx >= 0 ? rel - waveletEnergy.relByDate[prevIdx][i] : null
                            const isSlow = i === waveletEnergy.slowIdx
                            const slope = waveletEnergy.slopeByBand[i][waveletEnergyIdx]
                            const slopePct = slope != null && e > 1e-12 ? (slope / e) * 100 : null
                            const coherence = waveletEnergy.coherenceByBand[i][waveletEnergyIdx]
                            const sparkStart = Math.max(0, waveletEnergyIdx - 59)
                            const sparkValues = waveletEnergy.energyByBand[i].slice(sparkStart, waveletEnergyIdx + 1).map(Math.sqrt)
                            return (
                              <tr key={b.label} className="border-t border-slate-800/60">
                                <td className="py-1 pr-3">
                                  <div className="flex items-center gap-2">
                                    <span style={{ color }}>{b.label}</span>
                                    {isSlow && <span className="text-slate-600">(lente)</span>}
                                    {b.reconstruction_failed && (
                                      <span className="text-amber-400" title="Reconstruction indisponible pour cette bande — série à zéro, les chiffres ci-contre ne reflètent pas une vraie absence d'énergie.">⚠</span>
                                    )}
                                    <Sparkline values={sparkValues} color={color} />
                                  </div>
                                </td>
                                <td className="py-1 pr-3 text-right text-slate-300 font-mono">{rms.toFixed(4)}</td>
                                <td className={clsx('py-1 pr-3 text-right font-mono', slopePct == null ? 'text-slate-600' : slopePct > 0.5 ? 'text-emerald-400' : slopePct < -0.5 ? 'text-red-400' : 'text-slate-400')}>
                                  {slopePct == null ? '—' : `${slopePct > 0.5 ? '▲' : slopePct < -0.5 ? '▼' : '→'} ${slopePct >= 0 ? '+' : ''}${slopePct.toFixed(1)}%/j`}
                                </td>
                                <td className="py-1 pr-3 text-right text-slate-200 font-mono">{rel.toFixed(0)}%</td>
                                <td className={clsx('py-1 pr-3 text-right font-mono', delta == null ? 'text-slate-600' : delta >= 0 ? 'text-emerald-400' : 'text-red-400')}>
                                  {delta == null ? '—' : `${delta >= 0 ? '+' : ''}${delta.toFixed(0)}%`}
                                </td>
                                <td className={clsx('py-1 pr-3 text-right font-mono', coherence >= 0.6 ? 'text-emerald-400' : coherence >= 0.3 ? 'text-amber-400' : 'text-slate-500')}>
                                  {coherence.toFixed(2)}
                                </td>
                              </tr>
                            )
                          })}
                          {waveletRegimeView && (
                            <tr className="border-t border-slate-700/40 bg-slate-800/20">
                              <td className="py-1 pr-3 text-slate-300 font-semibold">Volatilité</td>
                              <td className="py-1 pr-3 text-right text-slate-300 font-mono">
                                {waveletRegimeView.volRaw != null ? `${waveletRegimeView.volRaw.toFixed(1)}%` : '—'}
                              </td>
                              <td className={clsx('py-1 pr-3 text-right font-mono',
                                waveletRegimeView.volDelta == null ? 'text-slate-600' : waveletRegimeView.volDelta > 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-400')}>
                                {waveletRegimeView.volDelta == null ? '—' :
                                  `${waveletRegimeView.volDelta > 3 ? '▲' : waveletRegimeView.volDelta < -3 ? '▼' : '→'} ${waveletRegimeView.volDelta >= 0 ? '+' : ''}${waveletRegimeView.volDelta.toFixed(0)}pt`}
                              </td>
                              <td className="py-1 pr-3 text-right text-slate-200 font-mono">
                                {waveletRegimeView.volPercentile != null ? `${waveletRegimeView.volPercentile.toFixed(0)}e pct` : '—'}
                              </td>
                              <td className={clsx('py-1 pr-3 text-right', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
                                {bucketVolPercentile(waveletRegimeView.volPercentile).label}
                              </td>
                              <td className="py-1 pr-3" />
                            </tr>
                          )}
                        </tbody>
                      </table>
                      <p className="text-[10px] text-slate-600 mt-1">
                        Trend Ratio = (somme des bandes rapides) / (bande lente). Énergie haute + cohérence haute = cycle lent puissant et directionnel ; énergie haute + cohérence basse = oscillation ample sans direction nette. Volatilité en percentile de son propre historique (universel : 8% ne veut pas dire la même chose sur EURUSD que sur le Nasdaq). Survolez le graphique pour voir ces valeurs évoluer dans le temps.
                      </p>

                      {waveletRegimeView && (
                        <div className="mt-3 pt-3 border-t border-slate-700/30">
                          <div className="flex items-center gap-3 mb-2">
                            <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Régime</span>
                            <span className={clsx('text-sm font-bold', waveletRegimeView.regime.color)}>{waveletRegimeView.regime.name}</span>
                          </div>
                          <p className="text-xs text-slate-400 mb-3">{waveletRegimeView.regime.reading}</p>
                          <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px]">
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Structure (Trend Ratio)</div>
                              <div className={clsx('font-mono font-semibold', bucketTrendRatio(waveletRegimeView.trendRatio).color)}>
                                {waveletRegimeView.trendRatio != null ? waveletRegimeView.trendRatio.toFixed(2) : '—'} · {bucketTrendRatio(waveletRegimeView.trendRatio).label}
                              </div>
                            </div>
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Direction lente</div>
                              <div className="font-mono font-semibold text-slate-200">{waveletRegimeView.direction} · {waveletRegimeView.energieSlowPct.toFixed(0)}% énergie</div>
                            </div>
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Dynamique (dE lente/dt)</div>
                              <div className={clsx('font-mono font-semibold', bucketDynamic(waveletRegimeView.slowSlopePct).color)}>
                                {bucketDynamic(waveletRegimeView.slowSlopePct).label}
                              </div>
                            </div>
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Qualité (Cohérence lente)</div>
                              <div className={clsx('font-mono font-semibold', bucketCoherence(waveletRegimeView.coherenceSlow).color)}>
                                {waveletRegimeView.coherenceSlow != null ? waveletRegimeView.coherenceSlow.toFixed(2) : '—'} · {bucketCoherence(waveletRegimeView.coherenceSlow).label}
                              </div>
                            </div>
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Risque (Vol percentile)</div>
                              <div className={clsx('font-mono font-semibold', bucketVolPercentile(waveletRegimeView.volPercentile).color)}>
                                {bucketVolPercentile(waveletRegimeView.volPercentile).label}
                              </div>
                            </div>
                            <div className="bg-dark-900/50 rounded px-2 py-1.5">
                              <div className="text-slate-500">Dynamique du risque (ΔVol)</div>
                              <div className={clsx('font-mono font-semibold',
                                waveletRegimeView.volDelta == null ? 'text-slate-500' : waveletRegimeView.volDelta > 3 ? 'text-red-400' : waveletRegimeView.volDelta < -3 ? 'text-emerald-400' : 'text-slate-300')}>
                                {waveletRegimeView.volDelta == null ? '—' : waveletRegimeView.volDelta > 3 ? 'Risque ↑' : waveletRegimeView.volDelta < -3 ? 'Risque ↓' : 'Stable'}
                              </div>
                            </div>
                          </div>
                        </div>
                      )}
                    </div>
                  )}
                </>
              ) : (
                <div className="text-xs text-slate-500 text-center py-10">
                  {loadingWavelet ? 'Calcul en cours…' : 'Pas encore de décomposition en cache pour cet instrument (attend le prochain cycle) — "Start analyse" pour un calcul live'}
                </div>
              )}

              {waveletReliability && (
                <div className="mt-3 pt-3 border-t border-slate-700/30">
                  <div className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2">
                    Fiabilité des retournements (horizon de recalcul = cycle propre à chaque bande)
                  </div>
                  <table className="w-full text-[11px]">
                    <thead>
                      <tr className="text-slate-500 text-left">
                        <th className="py-1 pr-3 font-normal">Bande</th>
                        <th className="py-1 pr-3 font-normal text-right" title="Écart moyen entre deux pics (ou deux creux) consécutifs, mesuré sur l'historique — pas la période théorique de la bande.">Cycle moyen</th>
                        <th className="py-1 pr-3 font-normal text-right" title="= cycle moyen × 1.10 — le retournement peut être confirmé n'importe où entre la date d'origine et date+horizon, pas juste pile à cette date">Horizon max</th>
                        <th className="py-1 pr-3 font-normal text-right" title="Délai réel moyen (parmi les retournements confirmés) entre la date d'origine et l'apparition du retournement confirmant, en hindsight">Délai réel moyen</th>
                        <th className="py-1 pr-3 font-normal text-right" title="Écart absolu moyen entre le cycle moyen (l'horizon supposé) et le délai réel observé — petit = l'horizon est bien calibré, grand = il est trop long ou trop court par rapport à la réalité">Écart calibration</th>
                        <th className="py-1 pr-3 font-normal text-right" title="Différence absolue moyenne entre la hauteur du pic/creux mesurée en direct (causal) et sa hauteur une fois confirmée en hindsight — direction confirmée ne veut pas dire même amplitude : un pic peut se retourner au bon moment mais avec une hauteur très différente de ce qui avait été vu en direct.">Écart hauteur</th>
                        <th className="py-1 pr-3 font-normal text-right">Retournements testés</th>
                        <th className="py-1 pr-3 font-normal text-right">Confirmés</th>
                        <th className="py-1 pr-3 font-normal text-right">Indice de confiance</th>
                      </tr>
                    </thead>
                    <tbody>
                      {waveletReliability.bands.map((b: any, i: number) => {
                        const pct = b.confidence_pct
                        const color = pct == null ? 'text-slate-500' : pct >= 70 ? 'text-emerald-400' : pct >= 40 ? 'text-amber-400' : 'text-red-400'
                        const gap = b.calibration_gap_days
                        const gapColor = gap == null ? 'text-slate-600' : gap <= 2 ? 'text-emerald-400' : gap <= 8 ? 'text-amber-400' : 'text-red-400'
                        return (
                          <tr key={b.label} className="border-t border-slate-800/60">
                            <td className="py-1 pr-3" style={{ color: WAVELET_BAND_COLORS[i % WAVELET_BAND_COLORS.length] }}>{b.label}</td>
                            <td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_cycle_days != null ? `${b.avg_cycle_days}j` : '—'}</td>
                            <td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.confirm_horizon != null ? `+${b.confirm_horizon}j` : '—'}</td>
                            <td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_actual_delay_days != null ? `${b.avg_actual_delay_days}j` : '—'}</td>
                            <td className={clsx('py-1 pr-3 text-right font-mono', gapColor)}>{gap != null ? `${gap}j` : '—'}</td>
                            <td className="py-1 pr-3 text-right text-slate-400 font-mono">{b.avg_height_diff != null ? b.avg_height_diff.toFixed(4) : '—'}</td>
                            <td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_tested}</td>
                            <td className="py-1 pr-3 text-right text-slate-300 font-mono">{b.n_confirmed}</td>
                            <td className={clsx('py-1 pr-3 text-right font-mono font-semibold', color)}>
                              {pct != null ? `${pct.toFixed(0)}%` : 'n/a (pas assez de retournements testables)'}
                            </td>
                          </tr>
                        )
                      })}
                    </tbody>
                  </table>
                  <p className="text-[10px] text-slate-600 mt-1">
                    Pour chaque retournement détecté en mode causal (pente lissée sur {waveletReliability.smooth_days}j qui change de signe, sans regarder le futur),
                    l'horizon de recalcul est propre à chaque bande — cycle moyen historique (pic-à-pic) × 1.10, pas un nombre de jours fixe identique pour toutes
                    (10 jours ne veut rien dire pour une bande à 2j de période comme pour une à 20j). Le retournement confirme l'original s'il réapparaît n'importe
                    où entre la date d'origine et date+horizon. "Écart calibration" compare l'horizon supposé (cycle moyen) au délai réel observé ; "Écart hauteur"
                    compare l'amplitude du pic/creux vue en direct à celle confirmée en hindsight — comparez-le au RMS de la bande (tableau du dessus) pour juger
                    s'il est significatif. Un indice bas signale des retournements souvent dus à un effet de bord de la décomposition (peu fiable pile au bord de
                    la fenêtre disponible), pas à un vrai signal.
                  </p>
                </div>
              )}
            </div>
          )}

          {tabUnder === 'regime' && (
            <div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-4">
              <div className="flex items-center justify-between">
                <span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Curve Regime — état de marché de l'instrument</span>
                <span className="text-[10px] text-slate-600" title="Distinct du régime macro global (Inflation Shock, Goldilocks...) affiché ailleurs dans le Cockpit — celui-ci ne concerne que cet instrument.">
                  ≠ régime macro global
                </span>
              </div>

              {loadingCurveRegime ? (
                <div className="text-xs text-slate-500 text-center py-10">Calcul en cours…</div>
              ) : !curveRegime ? (
                <div className="text-xs text-slate-500 text-center py-10">Aucune donnée — réessayez ou vérifiez que l'instrument a un historique de prix.</div>
              ) : (() => {
                const top = curveRegime.top_match
                const profile = curveRegime.profile
                const vol = profile?.volatilite ?? {}
                const scoreColorFor = (s: number) => s >= 0.75 ? 'text-emerald-400' : s >= 0.5 ? 'text-amber-400' : 'text-slate-400'
                const AXIS_LABELS: Record<string, string> = { direction: 'Direction', dynamique: 'Dynamique', volatilite: 'Volatilité', structure_iv: 'Structure IV' }
                return (
                  <>
                    {/* Top match */}
                    <div className="flex items-center gap-4 flex-wrap">
                      <div>
                        <div className={clsx('text-2xl font-bold', top ? scoreColorFor(top.score) : 'text-slate-500')}>
                          {top ? top.label : 'Indéterminé'}
                        </div>
                        <div className="text-[10px] text-slate-600">
                          Confiance {top ? `${(top.score * 100).toFixed(0)}%` : '—'} · basé sur {top ? Object.keys(top.axis_hits).length : 0} axes calculables
                        </div>
                      </div>
                      {curveRegime.facteur_dominant_bias && (
                        <div className="text-[10px] px-2 py-1 rounded bg-dark-900/60 border border-slate-700/30 text-slate-400">
                          Biais macro ({curveRegime.macro_regime_label}) sur cette classe d'actif : <span className="font-semibold text-slate-300">{curveRegime.facteur_dominant_bias}</span>
                        </div>
                      )}
                    </div>

                    {!curveRegime.has_options_data && (
                      <div className="text-[10px] text-amber-400/80 bg-amber-900/10 border border-amber-700/20 rounded px-2 py-1.5">
                        Structure IV / Liquidité indisponibles — lie l'option chain de cet instrument (Config → Instruments Watchlist → "Option") pour les inclure dans le classement.
                      </div>
                    )}

                    {/* Axis breakdown */}
                    <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
                      {[
                        { key: 'direction', value: profile?.direction },
                        { key: 'dynamique', value: profile?.dynamique },
                        { key: 'volatilite', value: vol.level ? `${vol.level}${vol.trend && vol.trend !== 'stable' ? ` (${vol.trend})` : ''}` : null },
                        { key: 'structure_iv', value: profile?.structure_iv },
                      ].map(({ key, value }) => {
                        const hit = top?.axis_hits?.[key]
                        return (
                          <div key={key} className="bg-dark-900/50 rounded px-2 py-1.5">
                            <div className="text-slate-500 text-[10px]">{AXIS_LABELS[key]}</div>
                            <div className={clsx('font-mono font-semibold text-xs',
                              value == null ? 'text-slate-600' : hit === true ? 'text-emerald-400' : hit === false ? 'text-slate-300' : 'text-slate-300'
                            )}>
                              {value ?? 'n/a'}{hit === true && ' ✓'}
                            </div>
                          </div>
                        )
                      })}
                      <div className="bg-dark-900/50 rounded px-2 py-1.5">
                        <div className="text-slate-500 text-[10px]">Liquidité</div>
                        <div className="font-mono font-semibold text-xs text-slate-300">{profile?.liquidite ?? 'n/a'}</div>
                      </div>
                      <div className="bg-dark-900/50 rounded px-2 py-1.5" title="Aucune source de données de corrélation cross-actifs par instrument n'existe encore dans le Cockpit — axe non noté.">
                        <div className="text-slate-500 text-[10px]">Corrélation cross-actifs</div>
                        <div className="font-mono font-semibold text-xs text-slate-600">n/a</div>
                      </div>
                    </div>

                    {/* Reference info for the matched regime */}
                    {top && (
                      <div className="text-xs space-y-1.5 border-t border-slate-700/30 pt-3">
                        <div><span className="text-slate-500">Facteur dominant type : </span><span className="text-slate-300">{top.facteur_dominant_ref}</span></div>
                        <div><span className="text-slate-500">Déclencheurs typiques : </span><span className="text-slate-300">{top.declencheurs}</span></div>
                        <div><span className="text-slate-500">Stratégies privilégiées : </span><span className="text-slate-300">{top.strategies}</span></div>
                        <div><span className="text-slate-500">Exemple : </span><span className="text-slate-300">{top.exemple}</span></div>
                      </div>
                    )}

                    {/* Runner-up regimes — visible when the top match isn't a clean, unambiguous winner */}
                    {curveRegime.ranked?.length > 1 && (
                      <div className="border-t border-slate-700/30 pt-3">
                        <div className="text-[10px] text-slate-500 uppercase tracking-wide mb-1.5">Autres régimes candidats</div>
                        <div className="space-y-1">
                          {curveRegime.ranked.slice(1, 4).map((r: any) => (
                            <div key={r.key} className="flex items-center justify-between text-[10px]">
                              <span className="text-slate-400">{r.label}</span>
                              <span className={clsx('font-mono', scoreColorFor(r.score))}>{(r.score * 100).toFixed(0)}%</span>
                            </div>
                          ))}
                        </div>
                      </div>
                    )}
                  </>
                )
              })()}
            </div>
          )}

          <NarrativeCard
            narrative={narrative}
            loading={loadingNarr}
            onLoad={loadNarrative}
            instrument={snapshot.instrument}
          />
        </>
      )}

      {!loading && !snapshot && (
        <div className="text-center py-20 text-slate-500">
          <BarChart2 className="w-8 h-8 mx-auto mb-3 opacity-30" />
          <p>Aucune donnée disponible pour {instrumentId}</p>
        </div>
      )}

      {selectorOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectorOpen(false)} />}
    </div>
  )
}
