"""
Isolated cycle action: Check New Market Events.

Scans 4 sources and creates market_events for significant findings:
  - news     : geopolitical/macro news (RSS feeds, rule-scored)
  - eco      : ff_calendar releases with high surprise % (toutes devises, USD inclus)
  - technical: configurable signal catalog driven by Technical Desk
  - reports  : institutional reports (COT, EIA) with high importance

Desk configs are loaded from ai_desks table at runtime.
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

WATCH_INSTRUMENTS = [
    "SPY", "QQQ", "IWM", "EEM", "EFA",
    "GLD", "SLV", "USO", "TLT", "HYG",
    "USDJPY=X", "EURUSD=X", "VXX", "NVDA", "BTC-USD",
]

SUBTYPE_FROM_SERIES = {
    "UNRATE": "NFP", "PAYEMS": "NFP",
    "CPIAUCSL": "CPI", "CPILFESL": "CPI",
    "A191RL1Q225SBEA": "GDP", "GDP": "GDP",
    "FEDFUNDS": "FOMC", "DFF": "FOMC",
    "PCEPILFE": "PCE", "PCEPI": "PCE",
    "BAMLH0A0HYM2": "Credit", "BAMLC0A0CM": "Credit",
}

# Impact classification for FRED series
_SERIES_IMPACT = {
    "UNRATE": "high", "PAYEMS": "high",
    "CPIAUCSL": "high", "CPILFESL": "high",
    "A191RL1Q225SBEA": "high", "GDP": "high",
    "FEDFUNDS": "high", "DFF": "high",
    "PCEPILFE": "high", "PCEPI": "high",
    "BAMLH0A0HYM2": "medium", "BAMLC0A0CM": "medium",
}

_IMPACT_RANKS = {"high": 3, "medium": 2, "low": 1}


def _parse_numeric(s: Optional[str]) -> Optional[float]:
    """Parse numeric string with optional K/M/B/% suffix → float or None."""
    if not s:
        return None
    s = s.strip()
    mult = 1.0
    if s.endswith(("B", "b")):
        mult, s = 1e9, s[:-1]
    elif s.endswith(("M", "m")):
        mult, s = 1e6, s[:-1]
    elif s.endswith(("K", "k")):
        mult, s = 1e3, s[:-1]
    s = s.rstrip("%").replace(",", "").strip()
    try:
        return float(s) * mult
    except ValueError:
        return None


# ── Helpers ───────────────────────────────────────────────────────────────────

def _get_api_key() -> str:
    import os
    key = os.environ.get("OPENAI_API_KEY", "")
    if not key:
        from services.database import get_config
        key = get_config("openai_api_key") or ""
    return key


def _existing_event_keys() -> set:
    from services.database import get_all_market_events
    return {ev["name"].lower()[:50] for ev in get_all_market_events()}


def _is_dup(name: str, existing: set) -> bool:
    return name.lower()[:50] in existing


def _parse_date(raw: str) -> str:
    if not raw:
        return datetime.utcnow().strftime("%Y-%m-%d")
    try:
        return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d")
    except Exception:
        pass
    try:
        from email.utils import parsedate_to_datetime
        return parsedate_to_datetime(raw).strftime("%Y-%m-%d")
    except Exception:
        pass
    return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d")


def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]:
    """Save a market_event and immediately evaluate instrument impacts."""
    from services.database import save_market_event
    try:
        event_id = save_market_event(ev)
        existing.add(ev["name"].lower()[:50])
        logger.info(f"[check_events] ✓ saved event #{event_id}: {ev['name']}")
    except Exception as e:
        logger.error(f"[check_events] save failed for '{ev['name']}': {e}")
        return None

    try:
        from services.impact_service import evaluate_event_impacts
        evaluate_event_impacts(event_id, force=False)
    except Exception as e:
        logger.warning(f"[check_events] impact eval failed for #{event_id}: {e}")

    return {"name": ev["name"], "category": ev.get("category", ""), "date": ev.get("start_date", ""), "event_id": event_id}


# ── Semantic deduplication ────────────────────────────────────────────────────

def _semantic_dedup(
    title: str,
    source: str,
    date_str: str,
    summary: str,
    category: str,
    client: Any,
    dedup_lookback_days: int = 2,
    system_prompt_hint: str = "",
) -> bool:
    """
    Ask the AI whether this news already exists in recent market_events.
    Returns True if it's a duplicate (should be skipped).
    """
    from services.database import get_market_events_near_date
    dedup_categories = ["geopolitical", "fundamental", "report"]
    if category and category not in dedup_categories:
        dedup_categories.append(category)

    recent = get_market_events_near_date(date_str, days=dedup_lookback_days, categories=dedup_categories)
    if not recent:
        return False

    recent_block = "\n".join(
        f"  [{r['start_date']}] {r['name']} — {(r.get('description') or '')[:80]}"
        for r in recent[:15]
    )

    hint = f"\nNote du desk: {system_prompt_hint[:200]}" if system_prompt_hint else ""

    prompt = f"""Tu es un éditeur de base de données d'événements marchés.{hint}

NOUVELLE NEWS À VÉRIFIER:
- Titre: {title}
- Source: {source}
- Date: {date_str}
- Résumé: {summary[:300]}

ÉVÉNEMENTS EXISTANTS (±{dedup_lookback_days} jours):
{recent_block}

Cette news représente-t-elle le même fait qu'un événement déjà enregistré ?
Réponds JSON: {{"is_duplicate": true/false, "reason": "courte phrase"}}"""

    try:
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"},
            temperature=0.0,
            max_tokens=100,
        )
        parsed = json.loads(resp.choices[0].message.content)
        is_dup = bool(parsed.get("is_duplicate", False))
        if is_dup:
            logger.debug(f"[dedup] Skipping duplicate: '{title[:40]}' — {parsed.get('reason','')}")
        return is_dup
    except Exception as e:
        logger.debug(f"[dedup] AI check failed for '{title[:40]}': {e}")
        return False


# ── Source 1: Geopolitical / macro news ──────────────────────────────────────

def _check_news(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    from services.data_fetcher import fetch_geo_news

    min_impact    = float(desk_cfg.get("min_impact", 0.55))
    max_evaluate  = int(desk_cfg.get("max_evaluate", 15))
    dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
    dedup_days    = int(desk_cfg.get("dedup_lookback_days", 2))
    system_prompt = desk_cfg.get("_system_prompt", "")
    date_from     = desk_cfg.get("date_from")
    date_to       = desk_cfg.get("date_to")

    api_key = _get_api_key()
    if not api_key:
        logger.warning("[check_events/news] no OpenAI key — skipping")
        return []

    try:
        all_news = fetch_geo_news()
    except Exception as e:
        logger.warning(f"[check_events/news] fetch failed: {e}")
        return []

    # Build cutoff from date_from; date_to used as upper bound
    try:
        cutoff_from = datetime.fromisoformat(date_from) if date_from else datetime.utcnow() - timedelta(days=7)
        cutoff_to   = datetime.fromisoformat(date_to)   if date_to   else datetime.utcnow()
    except Exception:
        cutoff_from = datetime.utcnow() - timedelta(days=7)
        cutoff_to   = datetime.utcnow()

    candidates = []
    for n in all_news:
        if (n.get("impact_score") or 0) < min_impact:
            continue
        pub_date = _parse_date(n.get("date", ""))
        try:
            pub_dt = datetime.fromisoformat(pub_date)
            if pub_dt < cutoff_from or pub_dt > cutoff_to:
                continue
        except Exception:
            pass
        candidates.append(n)

    candidates = candidates[:max_evaluate]
    if not candidates:
        return []

    try:
        from openai import OpenAI
        client = OpenAI(api_key=api_key)
    except Exception as e:
        logger.warning(f"[check_events/news] OpenAI init failed: {e}")
        return []

    existing = _existing_event_keys()
    created: List[Dict] = []

    for n in candidates:
        title = n.get("title", "")
        if not title or _is_dup(title, existing):
            continue

        pub_date  = _parse_date(n.get("date", ""))
        news_summary = str(n.get("summary", ""))[:400]
        source    = n.get("source", "")

        # Semantic dedup before expensive classification call
        if dedup_enabled:
            if _semantic_dedup(
                title, source, pub_date, news_summary,
                category="geopolitical",
                client=client,
                dedup_lookback_days=dedup_days,
                system_prompt_hint=system_prompt,
            ):
                continue

        prompt = f"""Tu es un analyste macro. Cette news représente-t-elle un événement marché structurant qui mérite un enregistrement permanent ?

TITRE: {title}
SOURCE: {source}
DATE: {n.get('date', '')}
RÉSUMÉ: {news_summary}
SCORE IMPACT (règle): {n.get('impact_score', 0):.2f}

Réponds OUI seulement si c'est un fait avéré, pas une rumeur ou une opinion, et qu'il a un impact macro ou géopolitique mesurable.

FORMAT JSON STRICT:
{{
  "qualifies": true/false,
  "reason": "une phrase",
  "name": "Nom court (≤ 60 chars)",
  "category": "geopolitical|event_calendar|fundamental|report",
  "sub_type": "ex: Conflit, Tarifs, Sanctions, OPEC+, Crise...",
  "description": "1-2 phrases analytiques",
  "affected_assets": ["SPY","GLD",...],
  "impact_score": 0.5,
  "level": "short|medium|long"
}}"""

        try:
            resp = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": prompt}],
                response_format={"type": "json_object"},
                temperature=0.1,
                max_tokens=350,
            )
            parsed = json.loads(resp.choices[0].message.content)
        except Exception as e:
            logger.debug(f"[check_events/news] AI call failed for '{title[:40]}': {e}")
            continue

        if not parsed.get("qualifies"):
            continue

        ev_name = (parsed.get("name") or title)[:60]
        if _is_dup(ev_name, existing):
            continue

        source_ref = {
            "title":          title,
            "source":         source,
            "url":            n.get("url") or n.get("link", ""),
            "date":           pub_date,
            "original_score": round(float(n.get("impact_score", 0)), 3),
        }

        ev = {
            "name":            ev_name,
            "start_date":      pub_date,
            "level":           parsed.get("level", "short"),
            "category":        parsed.get("category", "geopolitical"),
            "sub_type":        parsed.get("sub_type", ""),
            "description":     parsed.get("description", title),
            "market_impact":   "",
            "affected_assets": parsed.get("affected_assets", []),
            "impact_score":    float(parsed.get("impact_score", 0.6)),
            "source_refs":     [source_ref],
            "origin":          "detector_news",
        }
        result = _save_and_evaluate(ev, existing)
        if result:
            result["source"] = "news"
            created.append(result)

    return created


# ── Source 2: Eco calendar — FRED surprises + ff_calendar ────────────────────

def _check_ff_calendar_surprises(
    currencies: List[str],
    min_impact: str,
    date_from: str,
    date_to: str,
    min_surprise_pct: float,
    lookback_releases: int,
    create_evt: bool,
    existing: set,
) -> List[Dict]:
    """
    Detect surprising releases in ff_calendar for the given currencies.
    Détecte les releases surprenantes dans ff_calendar pour toutes les devises données (USD inclus).
    """
    from services.database import get_conn

    impact_map = {
        "high":   ("high",),
        "medium": ("high", "medium"),
        "low":    ("high", "medium", "low"),
    }
    allowed_impacts = impact_map.get(min_impact, ("high", "medium"))

    try:
        conn = get_conn()
        ccy_ph  = ",".join("?" * len(currencies))
        imp_ph  = ",".join("?" * len(allowed_impacts))
        rows = conn.execute(
            f"""SELECT event_date, currency, impact, event_name,
                       actual_value, forecast_value, previous_value
                FROM ff_calendar
                WHERE currency IN ({ccy_ph})
                  AND impact IN ({imp_ph})
                  AND event_date >= ?
                  AND event_date <= ?
                  AND actual_value IS NOT NULL
                  AND forecast_value IS NOT NULL
                ORDER BY event_date DESC
                LIMIT 200""",
            (*currencies, *allowed_impacts, date_from, date_to),
        ).fetchall()
        conn.close()
    except Exception as e:
        logger.warning(f"[check_events/eco/ff] query failed: {e}")
        return []

    created = []
    for row in rows:
        d = dict(row)
        actual   = _parse_numeric(d.get("actual_value"))
        forecast = _parse_numeric(d.get("forecast_value"))
        if actual is None or forecast is None or abs(forecast) < 1e-9:
            continue
        s_pct = (actual - forecast) / abs(forecast) * 100
        if abs(s_pct) < min_surprise_pct:
            continue

        ev_date      = (d.get("event_date") or "")[:10]
        ev_name_base = d.get("event_name", "Unknown")
        ccy          = d.get("currency", "")
        sign         = "+" if s_pct >= 0 else ""
        ev_name      = f"{ccy} {ev_name_base} — Surprise {sign}{s_pct:.1f}% ({ev_date[:7]})"

        if _is_dup(ev_name, existing):
            continue

        # Historical context
        context_str = ""
        if lookback_releases > 0:
            try:
                conn2 = get_conn()
                hist = conn2.execute(
                    """SELECT event_date, actual_value, forecast_value
                       FROM ff_calendar
                       WHERE event_name = ? AND currency = ? AND event_date < ?
                         AND actual_value IS NOT NULL
                       ORDER BY event_date DESC LIMIT ?""",
                    (ev_name_base, ccy, ev_date, lookback_releases),
                ).fetchall()
                conn2.close()
                if hist:
                    context_str = " Historique récent: " + ", ".join(
                        f"{r[0][:7]}: réel={r[1]} consensus={r[2]}" for r in hist
                    )
            except Exception:
                pass

        impact    = d.get("impact", "low")
        level     = "medium" if impact == "high" else "short"
        direction = "hausse" if s_pct > 0 else "baisse"
        score     = min(0.85, 0.30 + abs(s_pct) / 100)

        source_ref = {
            "title":          f"Release: {ccy} {ev_name_base} ({ev_date})",
            "source":         "ff_calendar",
            "url":            "",
            "date":           ev_date,
            "original_score": round(score, 3),
        }

        ev = {
            "name":            ev_name,
            "start_date":      ev_date,
            "level":           level,
            "category":        "event_calendar",
            "sub_type":        ccy,
            "description":     (
                f"Surprise en {direction} de {sign}{s_pct:.1f}% vs consensus. "
                f"Réel: {d['actual_value']} / Consensus: {d['forecast_value']}."
                + context_str
            ),
            "market_impact":   "",
            "affected_assets": [],
            "impact_score":    score,
            "actual_value":    str(d["actual_value"]),
            "expected_value":  str(d["forecast_value"]),
            "surprise_pct":    float(s_pct),
            "source_refs":     [source_ref],
            "origin":          "detector_eco_ff",
        }
        if create_evt:
            result = _save_and_evaluate(ev, existing)
            if result:
                result["source"] = "eco"
                created.append(result)
        else:
            logger.info(f"[check_events/eco] create_market_event=False — skipping: {ev_name}")

    return created


def _check_eco(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    from services.database import get_conn

    z_threshold       = float(desk_cfg.get("z_threshold", 1.5))
    date_from         = desk_cfg.get("date_from") or (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d")
    date_to           = desk_cfg.get("date_to")   or datetime.utcnow().strftime("%Y-%m-%d")
    currencies        = list(desk_cfg.get("currencies") or ["USD", "EUR", "GBP", "JPY"])
    min_impact        = str(desk_cfg.get("min_impact", "medium")).lower()
    create_evt        = bool(desk_cfg.get("create_market_event", True))
    lookback_releases = int(desk_cfg.get("lookback_releases", 3))

    min_rank        = _IMPACT_RANKS.get(min_impact, 2)
    ff_surprise_min = max(10.0, z_threshold * 10)

    existing = _existing_event_keys()
    created: List[Dict] = []

    # ── ff_calendar — source unique pour toutes les devises (USD inclus) ──────
    # Anciennement : USD → economic_events (FRED), autres → ff_calendar.
    # Désormais ff_calendar couvre toutes les devises avec forecast + actual,
    # donc on unifie sur une seule source cohérente.
    created += _check_ff_calendar_surprises(
        currencies=currencies,
        min_impact=min_impact,
        date_from=date_from,
        date_to=date_to,
        min_surprise_pct=ff_surprise_min,
        lookback_releases=lookback_releases,
        create_evt=create_evt,
        existing=existing,
    )

    return created


# ── Technical signal detectors ────────────────────────────────────────────────

def _detect_ma_cross(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
    """Golden/Death cross detector for configured MA pairs."""
    import pandas as pd
    events = []
    pairs_cfg = params.get("pairs", [["MA50", "MA200"], ["MA50", "MA100"]])
    ma_map = {"MA20": 20, "MA50": 50, "MA100": 100, "MA200": 200}

    close = df["Close"].squeeze()

    # Pre-compute all required MAs
    needed: set = set()
    for pair in pairs_cfg:
        needed.update(pair)
    ma_series: Dict[str, Any] = {}
    for lbl in needed:
        period = ma_map.get(lbl)
        if period and len(df) >= period:
            ma_series[lbl] = close.rolling(period).mean()

    recent = df.tail(4)
    for i in range(1, len(recent)):
        date_str = str(recent.index[i])[:10]
        if date_str < cutoff:
            continue
        for fast_lbl, slow_lbl in pairs_cfg:
            if fast_lbl not in ma_series or slow_lbl not in ma_series:
                continue
            fp = ma_series[fast_lbl].iloc[-(len(recent) - i + 1)]
            fc = ma_series[fast_lbl].iloc[-(len(recent) - i)]
            sp = ma_series[slow_lbl].iloc[-(len(recent) - i + 1)]
            sc = ma_series[slow_lbl].iloc[-(len(recent) - i)]
            if any(pd.isna(v) for v in [fp, fc, sp, sc]):
                continue
            if fp < sp and fc >= sc:
                kind = "golden"
            elif fp > sp and fc <= sc:
                kind = "death"
            else:
                continue
            cross_label = "Golden Cross" if kind == "golden" else "Death Cross"
            events.append({
                "name":      f"{ticker} {fast_lbl}/{slow_lbl} {cross_label} ({date_str[:7]})",
                "date":      date_str,
                "direction": "bullish" if kind == "golden" else "bearish",
                "sub_type":  f"{fast_lbl}/{slow_lbl} Cross",
                "score":     0.65 if "MA200" in (fast_lbl, slow_lbl) else 0.45,
                "level":     "medium" if "MA200" in (fast_lbl, slow_lbl) else "short",
                "desc":      f"{cross_label}: {fast_lbl} {'au-dessus' if kind=='golden' else 'en-dessous'} de {slow_lbl} sur {ticker}.",
            })
    return events


def _detect_rsi_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
    """RSI oversold/overbought signal."""
    import pandas as pd
    period     = int(params.get("period", 14))
    oversold   = float(params.get("oversold", 30))
    overbought = float(params.get("overbought", 70))

    close = df["Close"].squeeze()
    if len(close) < period + 2:
        return []

    delta = close.diff()
    gain  = delta.clip(lower=0).rolling(period).mean()
    loss  = (-delta.clip(upper=0)).rolling(period).mean()
    rs    = gain / loss.replace(0, float("nan"))
    rsi   = 100 - (100 / (1 + rs))

    events = []
    recent = rsi.tail(3)
    for i in range(len(recent)):
        date_str = str(recent.index[i])[:10]
        if date_str < cutoff:
            continue
        val = recent.iloc[i]
        if pd.isna(val):
            continue
        if val <= oversold:
            direction, label = "bullish", "Oversold"
        elif val >= overbought:
            direction, label = "bearish", "Overbought"
        else:
            continue
        events.append({
            "name":      f"{ticker} RSI {label} ({date_str[:7]})",
            "date":      date_str,
            "direction": direction,
            "sub_type":  f"RSI {label}",
            "score":     0.50 if abs(val - 50) > 30 else 0.40,
            "level":     "short",
            "desc":      f"RSI({period}) à {val:.1f} sur {ticker} — signal {label.lower()} ({direction}).",
        })
    return events


def _detect_bb_squeeze(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
    """Bollinger Band squeeze detector."""
    import pandas as pd
    period          = int(params.get("period", 20))
    std_mult        = float(params.get("std", 2.0))
    width_threshold = float(params.get("width_threshold", 0.05))

    close = df["Close"].squeeze()
    if len(close) < period + 2:
        return []

    mid   = close.rolling(period).mean()
    std   = close.rolling(period).std()
    upper = mid + std_mult * std
    lower = mid - std_mult * std
    width = (upper - lower) / mid

    events = []
    recent = width.tail(3)
    for i in range(len(recent)):
        date_str = str(recent.index[i])[:10]
        if date_str < cutoff:
            continue
        w = recent.iloc[i]
        if pd.isna(w):
            continue
        if w <= width_threshold:
            events.append({
                "name":      f"{ticker} BB Squeeze ({date_str[:7]})",
                "date":      date_str,
                "direction": "neutral",
                "sub_type":  "BB Squeeze",
                "score":     0.45,
                "level":     "short",
                "desc":      f"Bandes de Bollinger({period},{std_mult}) très resserrées sur {ticker} — width={w:.3f}. Explosion de volatilité imminente.",
            })
    return events


def _detect_52w_extreme(ticker: str, df: Any, params: Dict, cutoff: str) -> List[Dict]:
    """New 52-week high/low detector."""
    import pandas as pd
    buffer_pct = float(params.get("buffer_pct", 0.5)) / 100

    close = df["Close"].squeeze()
    if len(close) < 252:
        return []

    high_52 = close.rolling(252).max()
    low_52  = close.rolling(252).min()

    events = []
    recent_close = close.tail(3)
    for i in range(len(recent_close)):
        date_str = str(recent_close.index[i])[:10]
        if date_str < cutoff:
            continue
        c   = recent_close.iloc[i]
        h52 = high_52.iloc[-(3 - i)]
        l52 = low_52.iloc[-(3 - i)]
        if pd.isna(c) or pd.isna(h52) or pd.isna(l52):
            continue
        if c >= h52 * (1 - buffer_pct):
            events.append({
                "name":      f"{ticker} Nouveau 52W High ({date_str[:7]})",
                "date":      date_str,
                "direction": "bullish",
                "sub_type":  "52W High",
                "score":     0.60,
                "level":     "medium",
                "desc":      f"{ticker} atteint un nouveau plus haut 52 semaines à {c:.2f} (précédent: {h52:.2f}).",
            })
        elif c <= l52 * (1 + buffer_pct):
            events.append({
                "name":      f"{ticker} Nouveau 52W Low ({date_str[:7]})",
                "date":      date_str,
                "direction": "bearish",
                "sub_type":  "52W Low",
                "score":     0.60,
                "level":     "medium",
                "desc":      f"{ticker} atteint un nouveau plus bas 52 semaines à {c:.2f} (précédent: {l52:.2f}).",
            })
    return events


# ── Source 3: Technical signals ───────────────────────────────────────────────

def _check_technical(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    try:
        import yfinance as yf
        import pandas as pd
    except ImportError:
        logger.warning("[check_events/technical] yfinance/pandas not available")
        return []

    instruments    = desk_cfg.get("_instruments") or WATCH_INSTRUMENTS
    lookback_days  = int(desk_cfg.get("lookback_days", 7))
    signals_config = desk_cfg.get("signals", {})

    # Determine which signals are active
    def sig_cfg(sig_id: str) -> Optional[Dict]:
        c = signals_config.get(sig_id, {})
        return c if c.get("enabled", False) else None

    ma_cross_cfg   = sig_cfg("ma_cross")
    rsi_cfg        = sig_cfg("rsi_extreme")
    bb_cfg         = sig_cfg("bb_squeeze")
    extreme_52w    = sig_cfg("new_52w_extreme")

    if not any([ma_cross_cfg, rsi_cfg, bb_cfg, extreme_52w]):
        logger.info("[check_events/technical] no active signals in desk config")
        return []

    existing = _existing_event_keys()
    created: List[Dict] = []
    cutoff   = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")

    for ticker in instruments:
        try:
            df = yf.download(ticker, period="2y", interval="1d", progress=False, auto_adjust=True)
            if df is None or len(df) < 20:
                continue

            # Flatten MultiIndex if needed (yfinance ≥ 0.2 returns MultiIndex columns)
            if hasattr(df.columns, "levels"):
                df.columns = df.columns.get_level_values(0)

            detected: List[Dict] = []
            if ma_cross_cfg and len(df) >= 210:
                detected += _detect_ma_cross(ticker, df, ma_cross_cfg, cutoff)
            if rsi_cfg:
                detected += _detect_rsi_extreme(ticker, df, rsi_cfg, cutoff)
            if bb_cfg:
                detected += _detect_bb_squeeze(ticker, df, bb_cfg, cutoff)
            if extreme_52w and len(df) >= 252:
                detected += _detect_52w_extreme(ticker, df, extreme_52w, cutoff)

            for sig in detected:
                ev_name = sig["name"]
                if _is_dup(ev_name, existing):
                    continue

                source_ref = {
                    "title":          f"Technical signal: {ev_name}",
                    "source":         "yfinance/computed",
                    "url":            f"https://finance.yahoo.com/quote/{ticker}",
                    "date":           sig["date"],
                    "original_score": sig["score"],
                }

                ev = {
                    "name":            ev_name,
                    "start_date":      sig["date"],
                    "level":           sig["level"],
                    "category":        "technical",
                    "sub_type":        sig["sub_type"],
                    "description":     sig["desc"],
                    "market_impact":   f"Signal {sig['direction']} sur {ticker}",
                    "affected_assets": [ticker],
                    "impact_score":    sig["score"],
                    "source_refs":     [source_ref],
                    "origin":          "detector_technical",
                }
                result = _save_and_evaluate(ev, existing)
                if result:
                    result["source"] = "technical"
                    created.append(result)

        except Exception as e:
            logger.debug(f"[check_events/technical] {ticker} failed: {e}")

    return created


# ── Source 4: Fundamental news ───────────────────────────────────────────────

def _check_fundamental(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Corporate fundamental events: layoffs, M&A, earnings, credit, regulatory."""
    from services.data_fetcher import fetch_geo_news

    min_impact    = float(desk_cfg.get("min_impact", 0.45))
    max_evaluate  = int(desk_cfg.get("max_evaluate", 20))
    dedup_enabled = bool(desk_cfg.get("dedup_enabled", True))
    dedup_days    = int(desk_cfg.get("dedup_lookback_days", 3))
    system_prompt = desk_cfg.get("_system_prompt", "")
    focus_types   = desk_cfg.get("focus_types", ["layoffs","earnings","ma","credit","regulatory","guidance"])
    date_from     = desk_cfg.get("date_from") or (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d")
    date_to       = desk_cfg.get("date_to")   or datetime.utcnow().strftime("%Y-%m-%d")

    api_key = _get_api_key()
    if not api_key:
        return []

    try:
        all_news = fetch_geo_news()
    except Exception as e:
        logger.warning(f"[check_events/fundamental] fetch failed: {e}")
        return []

    candidates = [
        n for n in all_news
        if (n.get("impact_score") or 0) >= min_impact
        and date_from <= _parse_date(n.get("date", "")) <= date_to
    ][:max_evaluate]

    if not candidates:
        return []

    try:
        from openai import OpenAI
        client = OpenAI(api_key=api_key)
    except Exception as e:
        logger.warning(f"[check_events/fundamental] OpenAI init failed: {e}")
        return []

    existing = _existing_event_keys()
    created: List[Dict] = []

    focus_str = ", ".join(focus_types)

    for n in candidates:
        title = n.get("title", "")
        if not title or _is_dup(title, existing):
            continue

        pub_date     = _parse_date(n.get("date", ""))
        news_summary = str(n.get("summary", ""))[:400]
        source       = n.get("source", "")

        if dedup_enabled:
            if _semantic_dedup(title, source, pub_date, news_summary,
                               category="fundamental", client=client,
                               dedup_lookback_days=dedup_days,
                               system_prompt_hint=system_prompt):
                continue

        prompt = f"""Tu es un analyste fondamental corporate. Cette news représente-t-elle un événement
fondamental CORPORATE structurant (types attendus: {focus_str}) ?

TITRE: {title}
SOURCE: {source}
DATE: {n.get('date', '')}
RÉSUMÉ: {news_summary}

Réponds OUI uniquement si c'est un fait avéré avec impact mesurable sur un secteur ou sur les indices.
Ignore les géopolitiques purs (guerres, sanctions) — ceux-là sont traités par le News Desk.

FORMAT JSON STRICT:
{{
  "qualifies": true/false,
  "fundamental_type": "layoffs|earnings|ma|credit|regulatory|guidance|other",
  "reason": "une phrase",
  "name": "Nom court (≤ 60 chars)",
  "company_sector": "ex: Tech, Energy, Financials, ou ticker si connu",
  "description": "1-2 phrases analytiques",
  "affected_assets": ["QQQ","HYG",...],
  "impact_score": 0.5,
  "level": "short|medium|long"
}}"""

        try:
            resp = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": prompt}],
                response_format={"type": "json_object"},
                temperature=0.1,
                max_tokens=350,
            )
            parsed = json.loads(resp.choices[0].message.content)
        except Exception as e:
            logger.debug(f"[check_events/fundamental] AI failed for '{title[:40]}': {e}")
            continue

        if not parsed.get("qualifies"):
            continue

        ev_name = (parsed.get("name") or title)[:60]
        if _is_dup(ev_name, existing):
            continue

        source_ref = {
            "title":          title,
            "source":         source,
            "url":            n.get("url") or n.get("link", ""),
            "date":           pub_date,
            "original_score": round(float(n.get("impact_score", 0)), 3),
        }

        ev = {
            "name":            ev_name,
            "start_date":      pub_date,
            "level":           parsed.get("level", "short"),
            "category":        "fundamental",
            "sub_type":        parsed.get("fundamental_type", "other"),
            "description":     parsed.get("description", title),
            "market_impact":   f"Secteur: {parsed.get('company_sector','')}",
            "affected_assets": parsed.get("affected_assets", []),
            "impact_score":    float(parsed.get("impact_score", 0.5)),
            "source_refs":     [source_ref],
            "origin":          "detector_fundamental",
        }
        result = _save_and_evaluate(ev, existing)
        if result:
            result["source"] = "fundamental"
            created.append(result)

    return created


# ── Source 5: Sentiment signals (Options Lab triggers) ────────────────────────

def _check_sentiment(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    """
    VIX family + SKEW signals — generates sentiment market_events that feed
    directly into the options lab as volatility regime triggers.
    """
    try:
        import yfinance as yf
        import pandas as pd
    except ImportError:
        logger.warning("[check_events/sentiment] yfinance not available")
        return []

    lookback_days  = int(desk_cfg.get("lookback_days", 5))
    signals_config = desk_cfg.get("signals", {})

    def sig_on(sig_id: str) -> Optional[Dict]:
        c = signals_config.get(sig_id, {})
        return c if c.get("enabled", True) else None

    vix_level_cfg   = sig_on("vix_level")
    vix_spike_cfg   = sig_on("vix_spike")
    vix_ts_cfg      = sig_on("vix_term_structure")
    vvix_cfg        = sig_on("vvix_extreme")
    skew_cfg        = sig_on("skew_extreme")

    if not any([vix_level_cfg, vix_spike_cfg, vix_ts_cfg, vvix_cfg, skew_cfg]):
        return []

    existing = _existing_event_keys()
    created: List[Dict] = []
    cutoff   = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")

    # ── Fetch VIX family ──────────────────────────────────────────────────────
    tickers = {"VIX": "^VIX", "VIX9D": "^VIX9D", "VIX3M": "^VIX3M", "VVIX": "^VVIX", "SKEW": "^SKEW"}
    series: Dict[str, Any] = {}
    for lbl, sym in tickers.items():
        try:
            df = yf.download(sym, period="30d", interval="1d", progress=False, auto_adjust=True)
            if df is not None and len(df) > 0:
                if hasattr(df.columns, "levels"):
                    df.columns = df.columns.get_level_values(0)
                series[lbl] = df["Close"].squeeze().dropna()
        except Exception as e:
            logger.debug(f"[sentiment] {sym} download failed: {e}")

    vix = series.get("VIX")
    if vix is None or len(vix) < 2:
        logger.warning("[check_events/sentiment] VIX data unavailable")
        return []

    def _emit(name: str, date_str: str, direction: str, sub_type: str,
              score: float, desc: str, assets: List[str], options_note: str = ""):
        if _is_dup(name, existing):
            return
        ev = {
            "name":            name,
            "start_date":      date_str,
            "level":           "short",
            "category":        "sentiment",
            "sub_type":        sub_type,
            "description":     desc + (f" {options_note}" if options_note else ""),
            "market_impact":   options_note,
            "affected_assets": assets,
            "impact_score":    score,
            "source_refs":     [{
                "title": f"Sentiment signal: {name}",
                "source": "CBOE/yfinance",
                "url": "https://www.cboe.com/tradable_products/vix/",
                "date": date_str,
                "original_score": score,
            }],
            "origin": "detector_sentiment",
        }
        result = _save_and_evaluate(ev, existing)
        if result:
            result["source"] = "sentiment"
            created.append(result)

    # ── VIX level threshold crossings ─────────────────────────────────────────
    if vix_level_cfg:
        thresholds = vix_level_cfg.get("thresholds", [20, 25, 30, 35, 45])
        recent = vix.tail(lookback_days + 2)
        for i in range(1, len(recent)):
            date_str = str(recent.index[i])[:10]
            if date_str < cutoff:
                continue
            prev_v, curr_v = float(recent.iloc[i-1]), float(recent.iloc[i])
            for lvl in thresholds:
                name = None
                if prev_v < lvl <= curr_v:
                    name = f"VIX franchit {lvl} à la hausse ({date_str[:7]})"
                    options_note = (
                        "Opportunité: vente de puts cash-secured sur SPY." if lvl < 25
                        else "Régime de peur — évaluer straddles ou risk reversals."
                    )
                    _emit(name, date_str, "bearish", f"VIX >{lvl}",
                          min(0.9, 0.4 + lvl * 0.01),
                          f"VIX dépasse {lvl} (précédent: {prev_v:.1f} → {curr_v:.1f}). Entrée en régime de volatilité élevée.",
                          ["VXX","SPY","QQQ","TLT"], options_note)
                elif prev_v >= lvl > curr_v:
                    name = f"VIX repasse sous {lvl} ({date_str[:7]})"
                    _emit(name, date_str, "bullish", f"VIX <{lvl}",
                          0.45,
                          f"VIX revient sous {lvl} ({prev_v:.1f} → {curr_v:.1f}). Détente de la volatilité.",
                          ["SPY","QQQ","VXX"],
                          "Opportunité: rachat de protection ou fermeture de couvertures.")

    # ── VIX spike intraday / daily ────────────────────────────────────────────
    if vix_spike_cfg:
        min_pct = float(vix_spike_cfg.get("min_pct_change", 15.0))
        recent  = vix.tail(lookback_days + 1)
        for i in range(1, len(recent)):
            date_str = str(recent.index[i])[:10]
            if date_str < cutoff:
                continue
            prev_v, curr_v = float(recent.iloc[i-1]), float(recent.iloc[i])
            if prev_v <= 0:
                continue
            pct_chg = (curr_v - prev_v) / prev_v * 100
            if abs(pct_chg) >= min_pct:
                direction = "bearish" if pct_chg > 0 else "bullish"
                sign = "+" if pct_chg > 0 else ""
                name = f"VIX spike {sign}{pct_chg:.0f}% ({date_str[:7]})"
                _emit(name, date_str, direction, "VIX Spike",
                      min(0.85, 0.4 + abs(pct_chg) * 0.01),
                      f"VIX variation journalière de {sign}{pct_chg:.1f}% ({prev_v:.1f} → {curr_v:.1f}). Choc de volatilité {'haussier' if pct_chg>0 else 'baissier'}.",
                      ["VXX","SPY","QQQ","TLT","GLD"],
                      "Signal pour stratégies de vol à court terme.")

    # ── VIX term structure inversion (VIX9D > VIX) ────────────────────────────
    if vix_ts_cfg and "VIX9D" in series:
        threshold = float(vix_ts_cfg.get("inversion_threshold", 1.05))
        vix9d = series["VIX9D"]
        common_idx = vix.index.intersection(vix9d.index)
        if len(common_idx) >= 2:
            for dt_idx in common_idx[-lookback_days:]:
                date_str = str(dt_idx)[:10]
                if date_str < cutoff:
                    continue
                ratio = float(vix9d[dt_idx]) / float(vix[dt_idx]) if float(vix[dt_idx]) > 0 else 0
                if ratio >= threshold:
                    name = f"VIX Term Structure Inversée — Peur court terme ({date_str[:7]})"
                    if not _is_dup(name, existing):
                        _emit(name, date_str, "bearish", "VIX Inversion",
                              0.70,
                              f"VIX9D ({float(vix9d[dt_idx]):.1f}) > VIX ({float(vix[dt_idx]):.1f}) — ratio {ratio:.2f}. La peur est concentrée sur le très court terme.",
                              ["VXX","SPY","TLT"],
                              "Stratégie: calendar spread bear — acheter protection courte vs vendre moyenne échéance.")

    # ── VVIX extreme ──────────────────────────────────────────────────────────
    if vvix_cfg and "VVIX" in series:
        threshold = float(vvix_cfg.get("threshold", 100.0))
        vvix      = series["VVIX"]
        recent    = vvix.tail(lookback_days)
        for i in range(len(recent)):
            date_str = str(recent.index[i])[:10]
            if date_str < cutoff:
                continue
            val = float(recent.iloc[i])
            if val >= threshold:
                name = f"VVIX extrême {val:.0f} ({date_str[:7]})"
                _emit(name, date_str, "bearish", "VVIX Extreme",
                      min(0.80, 0.45 + (val - threshold) * 0.005),
                      f"VVIX à {val:.1f} (seuil: {threshold}) — volatilité de la volatilité extrême. Marché très incertain sur la direction du VIX.",
                      ["VXX","SPY","QQQ"],
                      "Éviter les positions directionnelles sur vol. Stratégies non-directionnelles.")

    # ── SKEW extreme ──────────────────────────────────────────────────────────
    if skew_cfg and "SKEW" in series:
        low_thr  = float(skew_cfg.get("low_threshold", 120.0))
        high_thr = float(skew_cfg.get("high_threshold", 145.0))
        skew     = series["SKEW"]
        recent   = skew.tail(lookback_days)
        for i in range(len(recent)):
            date_str = str(recent.index[i])[:10]
            if date_str < cutoff:
                continue
            val = float(recent.iloc[i])
            if val >= high_thr:
                name = f"SKEW extrême haussier {val:.0f} ({date_str[:7]})"
                _emit(name, date_str, "bearish", "SKEW Extreme",
                      0.65,
                      f"CBOE SKEW à {val:.1f} — marché paye très cher pour les puts out-of-the-money. Couverture tail-risk forte.",
                      ["SPY","QQQ","TLT"],
                      "Skew élevé → vente de put spreads attractive (prime élevée sur strikes bas).")
            elif val <= low_thr:
                name = f"SKEW très bas {val:.0f} — complaisance ({date_str[:7]})"
                _emit(name, date_str, "bullish", "SKEW Low",
                      0.55,
                      f"CBOE SKEW à {val:.1f} — marché peu préoccupé par les risques tail. Signal de complaisance.",
                      ["VXX","SPY"],
                      "Skew bas → acheter protection bon marché (puts OTM relativement peu chers).")

    # ── Custom gauge threshold alerts ────────────────────────────────────────────
    gauge_thresholds = desk_cfg.get("gauge_thresholds", {})
    selected_gauges  = desk_cfg.get("_instruments") or []

    if gauge_thresholds and selected_gauges:
        from services.database import get_macro_gauge_history
        from services.data_fetcher import MACRO_GAUGE_CONFIG
        gauge_label_map = {gid: label for gid, label, _, _, _ in MACRO_GAUGE_CONFIG}
        gauge_label_map.update({
            "slope_10y3m":       "Slope 10Y-3M",
            "gold_copper_ratio": "Ratio Or/Cuivre",
            "spx_vs_200d":       "SPX vs MA 200j",
        })
        _gauge_assets: Dict[str, List[str]] = {
            "dxy":              ["GLD", "EEM", "EURUSD=X"],
            "us10y":            ["TLT", "IEF", "SPY"],
            "us3m":             ["TLT", "IEF"],
            "tips":             ["TLT", "GLD"],
            "tlt":              ["TLT", "IEF", "SPY"],
            "vix":              ["VXX", "SPY", "QQQ"],
            "hyg":              ["HYG", "LQD", "SPY"],
            "lqd":              ["LQD", "HYG", "TLT"],
            "ief":              ["IEF", "TLT"],
            "brent":            ["USO", "XOM"],
            "ng":               ["UNG", "XOM"],
            "gold":             ["GLD", "SLV"],
            "silver":           ["SLV", "GLD"],
            "copper":           ["XLI", "EEM"],
            "spx":              ["SPY", "QQQ"],
            "iwm":              ["IWM", "SPY"],
            "xli":              ["XLI", "SPY"],
            "xlk":              ["XLK", "QQQ"],
            "xlf":              ["XLF", "SPY"],
            "xlp":              ["XLP", "SPY"],
            "xlu":              ["XLU", "SPY"],
            "vvix":             ["VXX", "SPY"],
            "skew":             ["SPY", "QQQ", "TLT"],
            "ovx":              ["USO", "XOM"],
            "gvz":              ["GLD", "SLV"],
            "eem":              ["EEM", "EFA"],
            "emb":              ["EMB", "EEM"],
            "fxi":              ["FXI", "EEM"],
            "usdjpy":           ["USDJPY=X", "GLD"],
            "slope_10y3m":      ["TLT", "SPY", "HYG"],
            "gold_copper_ratio":["GLD", "EEM"],
            "spx_vs_200d":      ["SPY", "QQQ", "VXX"],
        }

        history = get_macro_gauge_history(days=lookback_days + 2)
        if len(history) >= 1:
            latest_snap   = history[0]
            latest_gauges = latest_snap.get("gauges", {})
            latest_date   = latest_snap["snapshot_date"]
            oldest_gauges = history[-1].get("gauges", {}) if len(history) > 1 else {}

            for gauge_id in selected_gauges:
                cfg_g = gauge_thresholds.get(gauge_id, {})
                if not cfg_g.get("enabled", False):
                    continue

                gauge_data = latest_gauges.get(gauge_id, {})
                value      = gauge_data.get("value")
                if value is None:
                    continue
                value = float(value)

                label      = gauge_label_map.get(gauge_id, gauge_id)
                assets     = _gauge_assets.get(gauge_id, [])
                old_data   = oldest_gauges.get(gauge_id, {})
                old_value  = old_data.get("value")
                old_value  = float(old_value) if old_value is not None else None

                low_thr  = cfg_g.get("low_threshold")
                high_thr = cfg_g.get("high_threshold")
                chg_thr  = cfg_g.get("change_pct_threshold")

                # High threshold crossing (old below, now at or above)
                if high_thr is not None:
                    high_thr = float(high_thr)
                    crossed = (old_value is not None and old_value < high_thr <= value)
                    at_level = (old_value is None and value >= high_thr)
                    if crossed or at_level:
                        name = f"{label} franchit {high_thr:.2g} à la hausse ({latest_date[:7]})"
                        prev_str = f" (précédent: {old_value:.2g})" if old_value is not None else ""
                        _emit(name, latest_date, "bearish", f"{gauge_id.upper()} High",
                              0.65,
                              f"{label} dépasse le seuil haut {high_thr:.2g}{prev_str} → valeur: {value:.2g}.",
                              assets,
                              f"Niveau haut sur {label} — surveiller exposition options.")

                # Low threshold crossing (old above, now at or below)
                if low_thr is not None:
                    low_thr = float(low_thr)
                    crossed = (old_value is not None and old_value > low_thr >= value)
                    at_level = (old_value is None and value <= low_thr)
                    if crossed or at_level:
                        name = f"{label} passe sous {low_thr:.2g} ({latest_date[:7]})"
                        prev_str = f" (précédent: {old_value:.2g})" if old_value is not None else ""
                        _emit(name, latest_date, "bullish", f"{gauge_id.upper()} Low",
                              0.65,
                              f"{label} passe sous le seuil bas {low_thr:.2g}{prev_str} → valeur: {value:.2g}.",
                              assets,
                              f"Niveau bas sur {label} — opportunité ou signal de retournement.")

                # Change % threshold (absolute value)
                if chg_thr is not None and old_value and old_value > 0:
                    pct_chg = (value - old_value) / old_value * 100
                    if abs(pct_chg) >= abs(float(chg_thr)):
                        sign      = "+" if pct_chg > 0 else ""
                        direction = "bullish" if pct_chg > 0 else "bearish"
                        name      = f"{label} variation {sign}{pct_chg:.1f}% ({latest_date[:7]})"
                        _emit(name, latest_date, direction, f"{gauge_id.upper()} Move",
                              min(0.80, 0.45 + abs(pct_chg) * 0.02),
                              f"{label} {sign}{pct_chg:.1f}% sur la période ({old_value:.2g} → {value:.2g}). Mouvement significatif.",
                              assets,
                              f"Mouvement {sign}{pct_chg:.1f}% sur {label} — ajuster stratégie de vol.")

    return created


# ── Source 6: Institutional reports ──────────────────────────────────────────

def _check_reports(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    from services.database import get_conn

    days           = int(desk_cfg.get("days", 7))
    min_importance = int(desk_cfg.get("min_importance", 3))

    try:
        cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
        conn   = get_conn()
        rows   = conn.execute(
            """SELECT * FROM institutional_reports
               WHERE report_date >= ? AND importance >= ?
               ORDER BY importance DESC, report_date DESC
               LIMIT 15""",
            (cutoff, min_importance),
        ).fetchall()
        conn.close()
        reports = [dict(r) for r in rows]
    except Exception as e:
        logger.warning(f"[check_events/reports] query failed: {e}")
        return []

    existing = _existing_event_keys()
    created: List[Dict] = []

    for rpt in reports:
        title    = rpt.get("title", "")
        rpt_type = rpt.get("report_type", "Report")
        rpt_date = (rpt.get("report_date") or "")[:10]

        if not title or _is_dup(title, existing):
            continue

        ev_name = title[:60]
        summary = rpt.get("ai_summary") or rpt.get("trading_implications", "")
        try:
            kp = json.loads(rpt.get("key_points_json") or "[]")
            if kp:
                summary = " ".join(kp[:2]) + " " + summary
        except Exception:
            pass

        assets: List[str] = []
        for sig_col, asset_list in [
            ("signal_energy",  ["USO", "XOM"]),
            ("signal_metals",  ["GLD", "SLV"]),
            ("signal_indices", ["SPY", "QQQ"]),
            ("signal_forex",   ["EURUSD=X", "USDJPY=X"]),
        ]:
            if rpt.get(sig_col, "neutral") not in ("neutral", "", None):
                assets.extend(asset_list)

        source_ref = {
            "title":          title,
            "source":         rpt.get("source", rpt_type),
            "url":            "",
            "date":           rpt_date,
            "original_score": round(min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), 3),
        }

        ev = {
            "name":            ev_name,
            "start_date":      rpt_date,
            "level":           "medium" if rpt.get("importance", 2) >= 4 else "short",
            "category":        "report",
            "sub_type":        rpt_type.upper(),
            "description":     summary[:500] or f"Rapport {rpt_type} du {rpt_date}.",
            "market_impact":   rpt.get("trading_implications", ""),
            "affected_assets": list(set(assets)),
            "impact_score":    min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
            "source_refs":     [source_ref],
            "origin":          "detector_report",
        }
        result = _save_and_evaluate(ev, existing)
        if result:
            result["source"] = "reports"
            created.append(result)

    return created


# ── Source 7: Macro gauge transitions (Eco Desk extension) ───────────────────

# Scenarios ordered by risk level for determining transition severity
_REGIME_SEVERITY = {
    "goldilocks": 1, "desinflation": 2, "soft_landing": 2,
    "reflation": 3, "stagflation": 4, "inflation_shock": 5,
    "recession": 5, "crise_liquidite": 6, "incertain": 0,
}

_REGIME_LABELS = {
    "goldilocks": "Goldilocks", "desinflation": "Disinflation",
    "soft_landing": "Soft Landing", "reflation": "Reflation",
    "stagflation": "Stagflation", "inflation_shock": "Inflation Shock",
    "recession": "Recession", "crise_liquidite": "Liquidity Crisis",
    "incertain": "Uncertain",
}


def _check_macro_gauges(desk_cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
    """
    Detect macro regime transitions and key gauge threshold crossings.
    Uses macro_gauge_snapshots table (daily persistence) as source.
    Falls back to macro_regime_history for regime transitions if no snapshots yet.
    """
    from services.database import get_macro_gauge_history, get_macro_regime_history

    gauge_signals = desk_cfg.get("gauge_signals", {})

    def sig_on(k: str) -> Optional[Dict]:
        c = gauge_signals.get(k, {})
        return c if c.get("enabled", True) else None

    regime_cfg  = sig_on("regime_transition")
    curve_cfg   = sig_on("yield_curve_inversion")
    dxy_cfg     = sig_on("dxy_shock")
    credit_cfg  = sig_on("credit_stress")
    gcr_cfg     = sig_on("gold_copper_ratio")

    existing = _existing_event_keys()
    created: List[Dict] = []

    def _emit(name: str, date_str: str, category: str, sub_type: str,
              score: float, level: str, desc: str, assets: List[str]):
        if _is_dup(name, existing):
            return
        ev = {
            "name":            name,
            "start_date":      date_str,
            "level":           level,
            "category":        category,
            "sub_type":        sub_type,
            "description":     desc,
            "market_impact":   "",
            "affected_assets": assets,
            "impact_score":    score,
            "source_refs":     [{
                "title":          f"Macro signal: {name}",
                "source":         "MacroRegime/DB",
                "url":            "",
                "date":           date_str,
                "original_score": score,
            }],
            "origin": "detector_macro_gauge",
        }
        result = _save_and_evaluate(ev, existing)
        if result:
            result["source"] = "eco"
            created.append(result)

    # ── Regime transition ─────────────────────────────────────────────────────
    if regime_cfg:
        history = get_macro_gauge_history(days=14)
        if len(history) >= 2:
            latest  = history[0]
            prev    = history[1]
            dom_new = latest.get("dominant") or "incertain"
            dom_old = prev.get("dominant") or "incertain"
            if dom_new != dom_old and dom_new != "incertain":
                date_str   = latest["snapshot_date"]
                sev_old    = _REGIME_SEVERITY.get(dom_old, 0)
                sev_new    = _REGIME_SEVERITY.get(dom_new, 0)
                direction  = "bearish" if sev_new > sev_old else "bullish"
                score      = min(0.90, 0.55 + abs(sev_new - sev_old) * 0.07)
                level      = "long" if abs(sev_new - sev_old) >= 3 else "medium"
                lbl_old    = _REGIME_LABELS.get(dom_old, dom_old)
                lbl_new    = _REGIME_LABELS.get(dom_new, dom_new)
                name       = f"Transition Régime Macro: {lbl_old} → {lbl_new} ({date_str[:7]})"
                desc       = (
                    f"Le régime macro dominant passe de {lbl_old} à {lbl_new}. "
                    f"Sévérité: {sev_old}→{sev_new}/6. "
                    f"Révision des biais d'actifs recommandée."
                )
                scores = latest.get("regime_scores", {})
                top3 = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:3]
                if top3:
                    desc += " Top 3 scénarios: " + ", ".join(
                        f"{_REGIME_LABELS.get(k,k)} ({v:.0%})" for k, v in top3
                    )
                _emit(name, date_str, "event_calendar", "RegimeTransition",
                      score, level, desc,
                      ["SPY","TLT","GLD","VXX","HYG","EURUSD=X"])

        # Fallback: use macro_regime_history if no snapshots yet
        elif not history:
            hist = get_macro_regime_history(days=14)
            if len(hist) >= 2:
                latest  = hist[0]
                prev    = hist[1]
                dom_new = latest.get("dominant") or "incertain"
                dom_old = prev.get("dominant") or "incertain"
                if dom_new != dom_old and dom_new != "incertain":
                    date_str = latest["timestamp"][:10]
                    sev_old  = _REGIME_SEVERITY.get(dom_old, 0)
                    sev_new  = _REGIME_SEVERITY.get(dom_new, 0)
                    score    = min(0.90, 0.55 + abs(sev_new - sev_old) * 0.07)
                    level    = "long" if abs(sev_new - sev_old) >= 3 else "medium"
                    lbl_old  = _REGIME_LABELS.get(dom_old, dom_old)
                    lbl_new  = _REGIME_LABELS.get(dom_new, dom_new)
                    name     = f"Transition Régime Macro: {lbl_old} → {lbl_new} ({date_str[:7]})"
                    _emit(name, date_str, "event_calendar", "RegimeTransition",
                          score, level,
                          f"Transition macro: {lbl_old} → {lbl_new}. Source: macro_regime_history.",
                          ["SPY","TLT","GLD","VXX","HYG"])

    # ── Gauge threshold crossings (from saved snapshots) ──────────────────────
    history = get_macro_gauge_history(days=desk_cfg.get("lookback_days", 7) + 2)
    if len(history) < 2:
        return created

    latest_snap  = history[0]
    oldest_snap  = history[-1]
    latest_gauges = latest_snap.get("gauges", {})
    oldest_gauges = oldest_snap.get("gauges", {})
    latest_date  = latest_snap["snapshot_date"]

    def gauge_val(snap_gauges: Dict, gid: str) -> Optional[float]:
        g = snap_gauges.get(gid, {})
        v = g.get("value")
        return float(v) if v is not None else None

    # ── Yield curve inversion ─────────────────────────────────────────────────
    if curve_cfg:
        threshold = float(curve_cfg.get("threshold", 0.0))
        # slope_10y3m is a derived gauge: positive = normal, negative = inverted
        slope_now = gauge_val(latest_gauges, "slope_10y3m")
        slope_old = gauge_val(oldest_gauges, "slope_10y3m")
        if slope_now is not None and slope_old is not None:
            if slope_old > threshold >= slope_now:
                name = f"Inversion Courbe 10Y-3M ({latest_date[:7]})"
                _emit(name, latest_date, "event_calendar", "YieldCurveInversion",
                      0.85, "long",
                      f"La courbe des taux US (10Y-3M) s'inverse à {slope_now:+.2f} pts "
                      f"(précédent: {slope_old:+.2f} pts). "
                      f"Signal historique de récession dans 12-18 mois.",
                      ["TLT","SPY","HYG","GLD","EURUSD=X"])
            elif slope_old <= threshold < slope_now:
                name = f"Désincurve 10Y-3M — Reflation ({latest_date[:7]})"
                _emit(name, latest_date, "event_calendar", "YieldCurveDesinversion",
                      0.70, "medium",
                      f"La courbe 10Y-3M revient positive à {slope_now:+.2f} pts. "
                      f"Signal de détente des craintes de récession.",
                      ["SPY","XLF","IWM","TLT"])

    # ── DXY shock ────────────────────────────────────────────────────────────
    if dxy_cfg:
        pct_thr = float(dxy_cfg.get("pct_threshold", 2.0))
        dxy_now = gauge_val(latest_gauges, "dxy")
        dxy_old = gauge_val(oldest_gauges, "dxy")
        if dxy_now and dxy_old and dxy_old > 0:
            pct_chg = (dxy_now - dxy_old) / dxy_old * 100
            if abs(pct_chg) >= pct_thr:
                sign    = "+" if pct_chg > 0 else ""
                direct  = "bullish" if pct_chg > 0 else "bearish"
                name    = f"DXY choc {sign}{pct_chg:.1f}% ({latest_date[:7]})"
                _emit(name, latest_date, "event_calendar", "DXYShock",
                      min(0.80, 0.45 + abs(pct_chg) * 0.07), "medium",
                      f"Dollar DXY {sign}{pct_chg:.1f}% sur la période "
                      f"({dxy_old:.1f} → {dxy_now:.1f}). "
                      f"{'Appréciation USD: pression sur EM et matières premières.' if pct_chg > 0 else 'Dépréciation USD: favorable aux matières premières et EM.'}",
                      ["GLD","EEM","USO","EURUSD=X","USDJPY=X"])

    # ── Credit stress (HYG) ───────────────────────────────────────────────────
    if credit_cfg:
        pct_thr = float(credit_cfg.get("pct_threshold", -1.5))
        hyg_chg = gauge_val(latest_gauges, "hyg")
        if hyg_chg is None:
            # Fallback: compute from values
            hyg_now = gauge_val(latest_gauges, "hyg")
            hyg_old = gauge_val(oldest_gauges, "hyg")
            if hyg_now and hyg_old and hyg_old > 0:
                hyg_chg = (hyg_now - hyg_old) / hyg_old * 100
        else:
            hyg_chg = float(latest_gauges.get("hyg", {}).get("change_pct") or 0)
        if hyg_chg is not None and hyg_chg <= pct_thr:
            name = f"Stress Crédit HYG ({latest_date[:7]})"
            _emit(name, latest_date, "event_calendar", "CreditStress",
                  min(0.80, 0.45 + abs(hyg_chg) * 0.1), "medium",
                  f"HYG (High Yield) chute de {hyg_chg:.1f}% — signal de stress crédit. "
                  f"Spreads HY en élargissement. Surveiller LQD et TLT pour contagion.",
                  ["HYG","LQD","SPY","TLT","VXX"])

    # ── Gold/Copper ratio regime ──────────────────────────────────────────────
    if gcr_cfg:
        fear_thr   = float(gcr_cfg.get("fear_threshold", 700))
        growth_thr = float(gcr_cfg.get("growth_threshold", 500))
        gcr_now = gauge_val(latest_gauges, "gold_copper_ratio")
        gcr_old = gauge_val(oldest_gauges, "gold_copper_ratio")
        if gcr_now and gcr_old:
            if gcr_old < fear_thr <= gcr_now:
                name = f"Ratio Or/Cuivre zone peur > {fear_thr:.0f} ({latest_date[:7]})"
                _emit(name, latest_date, "event_calendar", "GoldCopperRatio",
                      0.70, "medium",
                      f"Ratio Or/Cuivre franchit {fear_thr:.0f} ({gcr_old:.0f} → {gcr_now:.0f}). "
                      f"L'or surperforme le cuivre — signal de risk-off, craintes de récession.",
                      ["GLD","SPY","EEM","USO"])
            elif gcr_old > growth_thr >= gcr_now:
                name = f"Ratio Or/Cuivre zone croissance < {growth_thr:.0f} ({latest_date[:7]})"
                _emit(name, latest_date, "event_calendar", "GoldCopperRatio",
                      0.60, "medium",
                      f"Ratio Or/Cuivre sous {growth_thr:.0f} ({gcr_old:.0f} → {gcr_now:.0f}). "
                      f"Le cuivre surperforme l'or — signal de risk-on, expansion économique.",
                      ["EEM","XLI","SPY","GLD"])

    return created


# ── Main entry point ──────────────────────────────────────────────────────────

def check_new_market_events(
    sources: Optional[List[str]] = None,
    # Unified date window — overrides eco_days/news_lookback_hours when provided
    date_from: Optional[str] = None,
    date_to:   Optional[str] = None,
    # Legacy overrides — used when called without an active desk
    news_impact_min: float = 0.55,
    eco_z_threshold: float = 1.5,
    technical_lookback_days: int = 7,
    report_days: int = 7,
    report_min_importance: int = 3,
) -> Dict[str, Any]:
    """
    Scans all (or selected) sources for all 6 market_event categories.
    Desk configs from ai_desks table override legacy params when available.
    Sources: news, fundamental, eco, technical, reports, sentiment
    """
    if sources is None:
        sources = ["news", "fundamental", "eco", "technical", "reports", "sentiment"]

    # Load desk configs from DB
    try:
        from services.database import get_ai_desk_by_type
        news_desk        = get_ai_desk_by_type("news")
        fundamental_desk = get_ai_desk_by_type("fundamental")
        tech_desk        = get_ai_desk_by_type("technical")
        eco_desk         = get_ai_desk_by_type("eco")
        report_desk      = get_ai_desk_by_type("report")
        sentiment_desk   = get_ai_desk_by_type("sentiment")
    except Exception as e:
        logger.warning(f"[check_events] Could not load desk configs: {e}")
        news_desk = fundamental_desk = tech_desk = eco_desk = report_desk = sentiment_desk = None

    def _desk_cfg(desk: Optional[Dict], fallback: Dict) -> Dict:
        if not desk:
            return fallback
        cfg = dict(desk.get("config") or {})
        cfg["_instruments"]   = desk.get("instruments") or None
        cfg["_system_prompt"] = desk.get("system_prompt") or ""
        return cfg

    # Compute default date_from fallback from desk config or 7-day default
    _now        = datetime.utcnow()
    _date_to    = date_to   or _now.strftime("%Y-%m-%d")
    _date_from  = date_from or (_now - timedelta(days=7)).strftime("%Y-%m-%d")

    news_cfg = _desk_cfg(news_desk, {
        "min_impact": news_impact_min,
        "max_evaluate": 15, "dedup_enabled": False, "dedup_lookback_days": 2,
    })
    news_cfg["date_from"] = _date_from
    news_cfg["date_to"]   = _date_to

    fundamental_cfg = _desk_cfg(fundamental_desk, {
        "min_impact": 0.45, "max_evaluate": 20,
        "dedup_enabled": True, "dedup_lookback_days": 3,
    })
    fundamental_cfg["date_from"] = _date_from
    fundamental_cfg["date_to"]   = _date_to

    eco_cfg = _desk_cfg(eco_desk, {
        "z_threshold": eco_z_threshold,
        "gauge_signals": {
            "regime_transition":    {"enabled": True},
            "yield_curve_inversion": {"enabled": True, "threshold": 0.0},
            "dxy_shock":            {"enabled": True, "pct_threshold": 2.0},
            "credit_stress":        {"enabled": True, "pct_threshold": -1.5},
            "gold_copper_ratio":    {"enabled": True, "fear_threshold": 700, "growth_threshold": 500},
        },
    })
    eco_cfg["date_from"] = _date_from
    eco_cfg["date_to"]   = _date_to
    tech_cfg = _desk_cfg(tech_desk, {
        "lookback_days": technical_lookback_days,
        "signals": {
            "ma_cross":        {"enabled": True, "pairs": [["MA50","MA200"],["MA50","MA100"]]},
            "rsi_extreme":     {"enabled": True, "period": 14, "oversold": 30, "overbought": 70},
            "bb_squeeze":      {"enabled": True, "period": 20, "std": 2.0, "width_threshold": 0.05},
            "new_52w_extreme": {"enabled": True, "buffer_pct": 0.5},
        },
    })
    report_cfg = _desk_cfg(report_desk, {
        "days": report_days, "min_importance": report_min_importance,
    })
    sentiment_cfg = _desk_cfg(sentiment_desk, {
        "lookback_days": 5,
        "signals": {
            "vix_level":          {"enabled": True, "thresholds": [20, 25, 30, 35, 45]},
            "vix_spike":          {"enabled": True, "min_pct_change": 15.0},
            "vix_term_structure": {"enabled": True, "inversion_threshold": 1.05},
            "vvix_extreme":       {"enabled": True, "threshold": 100.0},
            "skew_extreme":       {"enabled": True, "low_threshold": 120.0, "high_threshold": 145.0},
        },
    })

    ALL_SOURCES = ["news", "fundamental", "eco", "technical", "reports", "sentiment"]
    results: Dict[str, Any] = {s: [] for s in ALL_SOURCES}
    results["total_created"] = 0
    results["ran_at"] = datetime.utcnow().isoformat()

    runners = [
        ("news",        lambda: _check_news(news_cfg)),
        ("fundamental", lambda: _check_fundamental(fundamental_cfg)),
        ("eco",         lambda: _check_eco(eco_cfg) + _check_macro_gauges(eco_cfg)),
        ("technical",   lambda: _check_technical(tech_cfg)),
        ("reports",     lambda: _check_reports(report_cfg)),
        ("sentiment",   lambda: _check_sentiment(sentiment_cfg)),
    ]

    for src, fn in runners:
        if src not in sources:
            continue
        try:
            results[src] = fn()
        except Exception as e:
            logger.error(f"[check_events] {src} source error: {e}")

    results["total_created"] = sum(len(results[s]) for s in ALL_SOURCES)
    counts = " ".join(f"{s}={len(results[s])}" for s in ALL_SOURCES)
    logger.info(f"[check_events] Done — {results['total_created']} new events: {counts}")

    # Auto-assign or auto-create causal templates for eco events
    if "eco" in sources and bool(eco_cfg.get("auto_template", False)):
        eco_events = results.get("eco", [])
        logger.info(f"[check_events/auto_template] auto_template=ON, {len(eco_events)} new eco events to process")
        if eco_events:
            try:
                from routers.causal_lab import auto_assign_template
                for ev in eco_events:
                    eid   = ev.get("event_id")
                    ename = ev.get("name", "?")
                    logger.info(f"[check_events/auto_template] processing event #{eid} '{ename}'")
                    if eid:
                        res = auto_assign_template(eid)
                        if "error" in res:
                            logger.warning(f"[check_events/auto_template] event #{eid} error: {res['error']}")
                        else:
                            action     = res.get("action", "?")
                            name       = res.get("name", "")
                            tmpl_id    = res.get("template_id")
                            confidence = res.get("confidence", 0)
                            logger.info(
                                f"[check_events/auto_template] event #{eid} → {action}: "
                                f"'{name}' (tmpl #{tmpl_id}, conf={confidence:.2f})"
                            )
            except Exception as e:
                logger.warning(f"[check_events/auto_template] failed: {e}", exc_info=True)
        else:
            logger.info("[check_events/auto_template] no new eco events this cycle")

    return results
