"""
Instrument Models Router — graphes causaux exhaustifs par instrument.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel

router = APIRouter(prefix="/api/instrument-models", tags=["instrument-models"])


class OverrideBody(BaseModel):
    value: float
    note: Optional[str] = ""


class BulkOverrideItem(BaseModel):
    node_id: str
    value: float
    note: Optional[str] = ""


class BulkOverrideBody(BaseModel):
    overrides: List[BulkOverrideItem]


class VirtualEvent(BaseModel):
    date: str
    category: str = "unclassified"
    pips: float = 0.0
    label: str = "Event virtuel"
    absorption_days: int = 14
    rise_days: int = 1
    plateau_days: int = 0
    decay_type: str = "exp"


class WhatIfBody(BaseModel):
    period: str = "1y"
    virtual_events: List[VirtualEvent] = []
    start_date: Optional[str] = None


class NodeConfigBody(BaseModel):
    macro_key: Optional[str] = None     # "" to clear, None = no-op


class NodeScenarioBody(BaseModel):
    node_id: str
    label: str
    horizon: str = "mt"                 # 'ct' | 'mt' | 'lt'
    target_date: str                    # YYYY-MM-DD
    target_value: float
    confidence: float = 0.7            # 0.0 – 1.0
    trajectory: str = "linear"         # 'step' | 'linear' | 'exp'
    absorption_days: int = 30
    notes: Optional[str] = ""


class CalibrateBody(BaseModel):
    ref_date: Optional[str] = None


class CalibrationOverrideBody(BaseModel):
    absorption_days: int = 7
    rise_days: int = 0
    plateau_days: int = 0
    decay_type: str = "exp"


@router.get("", response_model=List[Dict[str, Any]])
def list_instrument_models():
    from services.database import get_conn
    from services.instrument_models import INSTRUMENT_MODELS
    conn = get_conn()
    try:
        rows = conn.execute(
            "SELECT instrument, updated_at FROM instrument_models ORDER BY instrument"
        ).fetchall()
        result = []
        for r in rows:
            inst = r["instrument"]
            meta = INSTRUMENT_MODELS.get(inst, {})
            counts: dict[str, int] = {}
            for n in meta.get("nodes", []):
                t = n.get("node_type", "unknown")
                counts[t] = counts.get(t, 0) + 1
            result.append({
                "instrument": inst,
                "name": meta.get("name", inst),
                "description": meta.get("description", ""),
                "n_input_event":  counts.get("input_event", 0),
                "n_input_manual": counts.get("input_manual", 0),
                "n_intermediate": counts.get("intermediate", 0),
                "updated_at": r["updated_at"],
            })
        return result
    finally:
        conn.close()


@router.get("/{instrument}/gauge-suggestions")
def get_gauge_suggestions(
    instrument: str,
    at_date: Optional[str] = Query(None, description="YYYY-MM-DD — snapshot le plus proche de cette date"),
) -> Dict[str, Any]:
    """Suggestions de valeurs depuis les gauges de marché (optionnellement à une date historique)."""
    from services.database import get_conn
    from services.gauge_sync import suggest_from_gauges, get_latest_gauges
    from services.instrument_models import INSTRUMENT_MODELS
    conn = get_conn()
    try:
        inst = instrument.upper()
        if inst not in INSTRUMENT_MODELS:
            raise HTTPException(status_code=404, detail=f"Instrument {inst} non supporté")
        gauges, snap_date = get_latest_gauges(conn, at_date=at_date)
        if not gauges:
            raise HTTPException(status_code=404, detail="Aucun snapshot de gauges disponible")
        suggestions = suggest_from_gauges(inst, gauges)
        return {
            "instrument":    inst,
            "gauge_date":    snap_date,
            "requested_date": at_date,
            "n_suggestions": len(suggestions),
            "suggestions":   suggestions,
            "gauges_available": list(gauges.keys()),
        }
    finally:
        conn.close()


@router.post("/{instrument}/apply-suggestions")
def apply_gauge_suggestions(
    instrument: str,
    body: BulkOverrideBody,
) -> Dict[str, Any]:
    """Applique une liste d'overrides en bulk (depuis gauge sync ou saisie rapide)."""
    from services.database import get_conn
    from services.instrument_models import set_node_override
    conn = get_conn()
    try:
        inst = instrument.upper()
        saved = []
        for item in body.overrides:
            set_node_override(conn, inst, item.node_id, item.value, item.note or "")
            saved.append(item.node_id)
        return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)}
    finally:
        conn.close()


@router.get("/{instrument}/macro-sync")
def get_macro_sync(instrument: str) -> Dict[str, Any]:
    """Retourne les valeurs actuelles + forecasts FF Calendar pour tous les nœuds macro-liés."""
    from services.database import get_conn
    from services.instrument_models import get_macro_sync_items
    conn = get_conn()
    try:
        items = get_macro_sync_items(conn, instrument)
        return {"instrument": instrument.upper(), "items": items, "count": len(items)}
    finally:
        conn.close()


class MacroSyncApplyBody(BaseModel):
    items: list[dict]  # [{node_id, value, note}]

@router.post("/{instrument}/macro-sync/apply")
def apply_macro_sync(instrument: str, body: MacroSyncApplyBody) -> Dict[str, Any]:
    """Applique les valeurs macro (actuelles ou forecasts) comme node overrides."""
    from services.database import get_conn
    from services.instrument_models import set_node_override
    conn = get_conn()
    try:
        inst = instrument.upper()
        saved = []
        for item in body.items:
            nid   = item.get("node_id", "")
            value = item.get("value")
            note  = item.get("note", "[Sync Marché FF Calendar]")
            if nid and value is not None:
                set_node_override(conn, inst, nid, float(value), note)
                saved.append(nid)
        return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)}
    finally:
        conn.close()


@router.delete("/{instrument}/overrides")
def clear_all_overrides(instrument: str) -> Dict[str, Any]:
    """Supprime TOUTES les overrides d'un instrument (reset à zéro)."""
    from services.database import get_conn
    conn = get_conn()
    try:
        inst = instrument.upper()
        conn.execute("DELETE FROM instrument_node_overrides WHERE instrument=?", (inst,))
        conn.commit()
        return {"ok": True, "instrument": inst}
    finally:
        conn.close()


@router.get("/{instrument}/regime")
def get_instrument_regime(
    instrument: str,
    at_date: Optional[str] = Query(None),
) -> Dict[str, Any]:
    """Régime de marché courant pour cet instrument (détecté depuis events actifs)."""
    from services.database import get_conn
    from services.instrument_models import _compute_event_by_category, detect_regime
    from datetime import datetime, date as date_type
    conn = get_conn()
    try:
        try:
            ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
        except ValueError:
            ref_date = datetime.utcnow().date()
        ev_by_cat = _compute_event_by_category(conn, instrument.upper(), ref_date)
        return detect_regime(ev_by_cat)
    finally:
        conn.close()


@router.get("/{instrument}/price-history")
def get_price_history(
    instrument: str,
    period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"),
    refresh: bool = Query(False),
) -> Dict[str, Any]:
    """Cours historiques réels depuis yfinance (cache SQLite 6h)."""
    from services.database import get_conn
    from services.price_history import get_price_history as fetch_prices
    from services.instrument_models import INSTRUMENT_MODELS
    conn = get_conn()
    try:
        inst = instrument.upper()
        prices = fetch_prices(conn, inst, period, force_refresh=refresh)
        meta   = INSTRUMENT_MODELS.get(inst, {})
        return {
            "instrument": inst,
            "ticker":     meta.get("yf_ticker", ""),
            "period":     period,
            "n_points":   len(prices),
            "prices":     prices,
        }
    finally:
        conn.close()


def _ph_period(start_date: Optional[str], fallback_period: str) -> str:
    """Compute the price-history period string needed to cover start_date → today."""
    if not start_date:
        return fallback_period
    try:
        from datetime import datetime as _dt
        days = (_dt.utcnow().date() - _dt.fromisoformat(start_date[:10]).date()).days + 10
        for p, d in [("5d", 7), ("1mo", 35), ("3mo", 95), ("6mo", 190), ("1y", 370), ("2y", 740)]:
            if days <= d:
                return p
        return "2y"
    except Exception:
        return fallback_period


@router.get("/{instrument}/timeline")
def get_instrument_timeline(
    instrument: str,
    period:     str           = Query("1y",  description="5d|1mo|3mo|6mo|1y|2y"),
    start_date: Optional[str] = Query(None,  description="Date de début ISO (override period)"),
) -> List[Dict[str, Any]]:
    """Simulation jour par jour de tous les nœuds du modèle sur la période."""
    from services.database import get_conn
    from services.instrument_models import simulate_timeline
    from services.price_history import get_price_history
    conn = get_conn()
    try:
        get_price_history(conn, instrument.upper(), _ph_period(start_date, period))
        data = simulate_timeline(conn, instrument.upper(), period, start_date=start_date)
        if not data:
            raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
        return data
    finally:
        conn.close()


@router.post("/{instrument}/timeline-whatif")
def timeline_whatif(
    instrument: str,
    body: WhatIfBody,
) -> List[Dict[str, Any]]:
    """Simulation what-if avec events virtuels injectés dans la timeline."""
    from services.database import get_conn
    from services.instrument_models import simulate_timeline
    from services.price_history import get_price_history
    conn = get_conn()
    try:
        get_price_history(conn, instrument.upper(), _ph_period(body.start_date, body.period))
        ve_list = [ve.dict() for ve in body.virtual_events]
        data = simulate_timeline(
            conn, instrument.upper(), body.period,
            virtual_events=ve_list, start_date=body.start_date
        )
        if not data:
            raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
        return data
    finally:
        conn.close()


# ── Magnitude estimation ──────────────────────────────────────────────────────

def _parse_num(s: Optional[str]) -> Optional[float]:
    if not s:
        return None
    t = str(s).strip().upper()
    try:
        if t.endswith('K'):   return float(t[:-1]) * 1_000
        if t.endswith('M'):   return float(t[:-1]) * 1_000_000
        if t.endswith('B'):   return float(t[:-1]) * 1_000_000_000
        return float(s.replace('%', '').replace(',', ''))
    except Exception:
        return None


def _event_scale(event_name: str) -> tuple[float, float]:
    """(pips_per_unit, surprise_threshold) for rule-based estimation."""
    n = event_name.lower()
    if any(k in n for k in ['non-farm employment change', 'nfp']):
        return 0.0007, 20_000      # per job
    if 'adp non-farm' in n:
        return 0.0005, 15_000
    if 'unemployment claims' in n:
        return -0.0003, 8_000      # more claims = worse = negative
    if any(k in n for k in ['cpi', 'pce', 'ppi', 'inflation', 'hicp']):
        return 80.0, 0.1           # per %
    if any(k in n for k in ['interest rate', 'rate decision', 'fed fund']):
        return 300.0, 0.1
    if any(k in n for k in ['pmi', 'ism']):
        return 6.0, 0.5
    if 'gdp' in n:
        return 70.0, 0.1
    if any(k in n for k in ['retail sales', 'consumer spending']):
        return 55.0, 0.2
    if 'unemployment rate' in n:
        return -100.0, 0.1         # higher rate = worse
    if 'employment change' in n:
        return 0.0005, 15_000
    return 25.0, 0.5               # default


_INST_DIR: Dict[tuple, int] = {
    ("EURUSD", "USD"): -1,  ("EURUSD", "EUR"): +1,
    ("USDJPY", "USD"): +1,  ("USDJPY", "JPY"): -1,
    ("GBPUSD", "USD"): -1,  ("GBPUSD", "GBP"): +1,
    ("XAUUSD", "USD"): -1,
    ("SP500",  "USD"): +1,
    ("TLT",    "USD"): -1,
    ("EEM",    "USD"): -1,
    ("QQQ",    "USD"): +1,
}
_INST_DIR_INFL: Dict[tuple, int] = {
    ("SP500", "USD"): -1, ("QQQ", "USD"): -1, ("TLT", "USD"): -1,
}


def _estimate_pip_magnitude(
    event_name: str, currency: str, instrument: str,
    actual: Optional[str], forecast: Optional[str], previous: Optional[str],
) -> tuple[float, bool, Optional[float]]:
    """Returns (estimated_pips, has_surprise, surprise_delta). No AI."""
    a = _parse_num(actual)
    f = _parse_num(forecast)
    p = _parse_num(previous)

    if a is None:
        return 0.0, False, None          # future event

    ref = f if f is not None else p
    if ref is None:
        return 0.0, False, None

    delta              = a - ref
    scale, threshold   = _event_scale(event_name)
    has_surprise       = abs(delta) >= threshold
    raw                = max(-100.0, min(100.0, delta * scale))

    n            = event_name.lower()
    is_inflation = any(k in n for k in ['cpi', 'pce', 'ppi', 'inflation', 'hicp'])
    inst         = instrument.upper()
    cur          = currency.upper()
    direction    = _INST_DIR_INFL.get((inst, cur), _INST_DIR.get((inst, cur), 0)) if is_inflation \
                   else _INST_DIR.get((inst, cur), 0)

    if direction == 0:
        return round(abs(raw), 1), has_surprise, round(delta, 4)
    return round(raw * direction, 1), has_surprise, round(delta, 4)


_INSTRUMENT_CURRENCIES: Dict[str, List[str]] = {
    "EURUSD": ["EUR", "USD"],
    "USDJPY": ["USD", "JPY"],
    "XAUUSD": ["USD"],
    "SP500":  ["USD"],
    "TLT":    ["USD"],
    "GBPUSD": ["GBP", "USD"],
    "EEM":    ["USD", "CNY"],
    "QQQ":    ["USD"],
}


def _ff_event_to_category(event_name: str) -> str:
    n = event_name.lower()
    if any(k in n for k in ["fomc", "fed ", "ecb", "boe", "boj", "rba", "interest rate", "rate decision", "monetary policy", "central bank"]):
        return "central_bank"
    if any(k in n for k in ["cpi", "pce", "ppi", "inflation", "core price"]):
        return "monetary_shock"
    if any(k in n for k in ["nfp", "non-farm", "payroll", "employment change", "unemployment"]):
        return "monetary_shock"
    if any(k in n for k in ["gdp", "growth", "retail sales", "manufacturing", "pmi", "ism"]):
        return "growth_shock"
    if any(k in n for k in ["oil", "opec", "crude", "energy", "natural gas"]):
        return "commodity"
    if any(k in n for k in ["tarif", "trade war", "sanction", "geopolit"]):
        return "geopolitical"
    return "unclassified"


@router.get("/{instrument}/calendar-events")
def get_calendar_events(
    instrument:   str,
    days_back:    int           = Query(90,   description="Jours en arriere depuis at_date"),
    days_forward: int           = Query(180,  description="Jours en avant depuis at_date"),
    impacts:      Optional[str] = Query("high,medium", description="Filtre impact FF (high,medium,low)"),
    at_date:      Optional[str] = Query(None),
) -> List[Dict[str, Any]]:
    """
    Events economiques du calendrier Forex Factory pour cet instrument.
    Source : ff_calendar (Forex Factory). Decorrele des market_events / CausalLab.
    Auto-sync live si ff_calendar vide pour la periode demandee.
    """
    from services.database import get_conn
    from datetime import datetime, date as date_type, timedelta
    import json as _json
    conn = get_conn()
    try:
        inst_upper = instrument.upper()
        try:
            ref = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
        except ValueError:
            ref = datetime.utcnow().date()

        date_from = str(ref - timedelta(days=days_back))
        date_to   = str(ref + timedelta(days=days_forward))

        currencies    = _INSTRUMENT_CURRENCIES.get(inst_upper, ["USD"])
        impact_filter = [i.strip() for i in impacts.split(",")] if impacts else ["high", "medium"]

        # Build event_category → node_label map from instrument graph
        event_node_map: dict[str, str] = {}
        try:
            graph_row = conn.execute(
                "SELECT graph_json FROM instrument_models WHERE instrument=?", [inst_upper]
            ).fetchone()
            if graph_row:
                gdef = _json.loads(graph_row["graph_json"])
                event_node_map = {
                    n.get("event_category", ""): n.get("label", n["id"])
                    for n in gdef.get("nodes", [])
                    if n.get("node_type") == "input_event" and n.get("event_category")
                }
                event_node_map.pop("", None)
        except Exception:
            pass

        # Auto-sync live si ff_calendar vide pour cette periode
        ph = ",".join("?" * len(currencies))
        n_existing = conn.execute(
            f"SELECT COUNT(*) FROM ff_calendar WHERE event_date>=? AND event_date<=? AND currency IN ({ph})",
            [date_from, date_to, *currencies]
        ).fetchone()[0]

        if n_existing == 0:
            try:
                from services.ff_calendar import sync_live
                sync_live()
            except Exception:
                pass

        pi = ",".join("?" * len(impact_filter))
        rows = conn.execute(
            f"""SELECT event_date, event_time, currency, impact, event_name,
                       actual_value, forecast_value, previous_value
                FROM ff_calendar
                WHERE event_date >= ? AND event_date <= ?
                  AND currency IN ({ph})
                  AND impact IN ({pi})
                ORDER BY event_date ASC, event_time ASC""",
            [date_from, date_to, *currencies, *impact_filter]
        ).fetchall()

        result = []
        for row in rows:
            r         = dict(row)
            ev_date   = r["event_date"]
            is_future = ev_date > str(ref)
            category  = _ff_event_to_category(r["event_name"])
            est_pips, has_surprise, surp_delta = _estimate_pip_magnitude(
                r["event_name"], r["currency"], inst_upper,
                r.get("actual_value"), r.get("forecast_value"), r.get("previous_value"),
            )
            result.append({
                "date":            ev_date,
                "event_time":      r.get("event_time", ""),
                "currency":        r["currency"],
                "impact":          r["impact"],
                "event_name":      r["event_name"],
                "label":           f"[{r['currency']}] {r['event_name']}",
                "actual_value":    r.get("actual_value"),
                "forecast_value":  r.get("forecast_value"),
                "previous_value":  r.get("previous_value"),
                "category":        category,
                "routed_node":     event_node_map.get(category, "direct"),
                "is_future":       is_future,
                "estimated_pips":  est_pips,
                "has_surprise":    has_surprise,
                "surprise_delta":  surp_delta,
            })
        return result
    finally:
        conn.close()


@router.put("/{instrument}/events/{analysis_id}/calibration")
def update_event_calibration(
    instrument:  str,
    analysis_id: int,
    body:        CalibrationOverrideBody,
) -> Dict[str, Any]:
    """Écrase les paramètres de lifecycle d'une analyse event (override par-event)."""
    from services.database import get_conn
    import json as _json
    conn = get_conn()
    try:
        row = conn.execute(
            "SELECT id FROM causal_event_analyses WHERE id=? AND instrument=?",
            (analysis_id, instrument.upper())
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail="Analysis not found")
        calib = {
            "absorption_days": body.absorption_days,
            "rise_days":       body.rise_days,
            "plateau_days":    body.plateau_days,
            "decay_type":      body.decay_type,
        }
        conn.execute("""
            INSERT INTO event_calibration_overrides (analysis_id, calibration_json)
            VALUES (?, ?)
            ON CONFLICT(analysis_id) DO UPDATE SET calibration_json=excluded.calibration_json, updated_at=datetime('now')
        """, (analysis_id, _json.dumps(calib)))
        conn.commit()
        return {"ok": True, "analysis_id": analysis_id, "calibration": calib}
    finally:
        conn.close()


@router.delete("/{instrument}/events/{analysis_id}/calibration")
def reset_event_calibration(instrument: str, analysis_id: int) -> Dict[str, Any]:
    """Supprime l'override de calibration — retour aux paramètres du template."""
    from services.database import get_conn
    conn = get_conn()
    try:
        conn.execute("DELETE FROM event_calibration_overrides WHERE analysis_id=?", (analysis_id,))
        conn.commit()
        return {"ok": True, "analysis_id": analysis_id}
    finally:
        conn.close()


@router.post("/{instrument}/calibrate")
def calibrate_intercept(
    instrument: str,
    body: CalibrateBody,
) -> Dict[str, Any]:
    """Auto-calcule l'intercept depuis le cours réel à une date de référence."""
    from services.database import get_conn
    from services.instrument_models import get_model_state, INSTRUMENT_MODELS
    from services.price_history import calibrate_intercept as do_calibrate, get_price_history
    conn = get_conn()
    try:
        inst   = instrument.upper()
        state  = get_model_state(conn, inst, body.ref_date)
        if not state:
            raise HTTPException(status_code=404, detail=f"Modèle introuvable")
        # Make sure prices are cached
        get_price_history(conn, inst, "3mo")
        intercept = do_calibrate(conn, inst, state["structural_pips"], body.ref_date)
        meta = INSTRUMENT_MODELS.get(inst, {})
        return {
            "instrument":       inst,
            "ref_date":         body.ref_date,
            "structural_pips":  state["structural_pips"],
            "pip_to_price":     meta.get("pip_to_price", 0.0001),
            "calibrated_intercept": intercept,
            "current_intercept":    meta.get("price_intercept", 0.0),
        }
    finally:
        conn.close()


@router.get("/{instrument}/scenarios")
def list_scenarios(instrument: str, node_id: Optional[str] = Query(None)) -> List[Dict[str, Any]]:
    """Tous les scénarios CT/MT/LT d'un instrument (ou d'un nœud spécifique)."""
    from services.database import get_conn
    from services.instrument_models import get_node_scenarios
    conn = get_conn()
    try:
        return get_node_scenarios(conn, instrument.upper(), node_id)
    finally:
        conn.close()


@router.post("/{instrument}/scenarios")
def create_scenario(instrument: str, body: NodeScenarioBody) -> Dict[str, Any]:
    """Crée un scénario forecast sur un nœud."""
    from services.database import get_conn
    from services.instrument_models import add_node_scenario
    conn = get_conn()
    try:
        sid = add_node_scenario(
            conn, instrument.upper(), body.node_id, body.label,
            body.horizon, body.target_date, body.target_value,
            body.confidence, body.trajectory, body.absorption_days, body.notes or "",
        )
        return {"ok": True, "id": sid, "node_id": body.node_id}
    finally:
        conn.close()


@router.delete("/{instrument}/scenarios/{scenario_id}")
def remove_scenario(instrument: str, scenario_id: int) -> Dict[str, Any]:
    """Supprime un scénario forecast."""
    from services.database import get_conn
    from services.instrument_models import delete_node_scenario
    conn = get_conn()
    try:
        delete_node_scenario(conn, scenario_id)
        return {"ok": True, "id": scenario_id}
    finally:
        conn.close()


@router.patch("/{instrument}/nodes/{node_id}")
def update_node_config(
    instrument: str,
    node_id: str,
    body: NodeConfigBody,
) -> Dict[str, Any]:
    """Met à jour la configuration d'un nœud (macro_key) dans le graph_json persisté."""
    import json as _json
    from services.database import get_conn
    conn = get_conn()
    try:
        inst = instrument.upper()
        row = conn.execute(
            "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,)
        ).fetchone()
        if not row:
            raise HTTPException(status_code=404, detail=f"Instrument {inst} introuvable")

        graph_def = _json.loads(row["graph_json"])
        found = False
        for node in graph_def.get("nodes", []):
            if node["id"] == node_id:
                if body.macro_key is not None:
                    if body.macro_key == "":
                        node.pop("macro_key", None)   # clear
                    else:
                        node["macro_key"] = body.macro_key
                found = True
                break

        if not found:
            raise HTTPException(status_code=404, detail=f"Nœud {node_id} introuvable")

        conn.execute(
            "UPDATE instrument_models SET graph_json=?, updated_at=datetime('now') WHERE instrument=?",
            (_json.dumps(graph_def), inst)
        )
        conn.commit()
        return {"ok": True, "node_id": node_id, "macro_key": body.macro_key}
    finally:
        conn.close()


@router.get("/{instrument}/macro-guidance")
def get_macro_guidance(instrument: str) -> List[Dict[str, Any]]:
    """
    Retourne l'état courant et le prochain forecast pour chaque nœud input_manual
    avec un macro_key configuré.
    """
    import json as _json
    from services.database import get_conn
    from services.instrument_models import build_macro_node_timeline, FF_MACRO_KEYS, _parse_ff_num
    from datetime import datetime, timedelta, date as date_type
    conn = get_conn()
    try:
        inst = instrument.upper()
        row = conn.execute(
            "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,)
        ).fetchone()
        if not row:
            return []

        graph_def = _json.loads(row["graph_json"])
        today     = datetime.utcnow().date()
        result    = []

        for node in graph_def.get("nodes", []):
            if node.get("node_type") != "input_manual":
                continue
            macro_key = node.get("macro_key")
            if not macro_key:
                continue

            meta     = FF_MACRO_KEYS.get(macro_key, {})
            currency = meta.get("currency", "")
            patterns = meta.get("names", [])

            # Current interpolated value
            date_from = today - timedelta(days=90)
            date_to   = today + timedelta(days=180)
            tl        = build_macro_node_timeline(conn, macro_key, date_from, date_to)
            current_v = tl.get(str(today))

            # Next scheduled event from ff_calendar
            next_event = None
            if currency and patterns:
                try:
                    ff_rows = conn.execute(
                        """SELECT event_date, event_name, forecast_value, previous_value
                           FROM ff_calendar
                           WHERE currency=? AND event_date > ?
                           ORDER BY event_date ASC LIMIT 20""",
                        (currency, str(today))
                    ).fetchall()
                    for r in ff_rows:
                        if any(p in r["event_name"].lower() for p in patterns):
                            ev_date    = date_type.fromisoformat(r["event_date"])
                            forecast_v = _parse_ff_num(r.get("forecast_value") or r.get("previous_value"))
                            next_event = {
                                "date":       r["event_date"],
                                "name":       r["event_name"],
                                "forecast":   forecast_v,
                                "days_until": (ev_date - today).days,
                            }
                            break
                except Exception:
                    pass

            # Unit-aware conversion (same logic as get_macro_sync_items)
            unit = node.get("unit", "")
            def _uc(v):
                if v is None: return None
                if unit == "K" and abs(v) >= 1_000: return round(v / 1_000, 1)
                if unit == "pts" and v > 10:        return round(v - 50.0, 1)
                return round(v, 4)

            next_fv = _uc(next_event.get("forecast") if next_event else None)
            if next_event:
                next_event = {**next_event, "forecast": next_fv}

            result.append({
                "node_id":       node["id"],
                "node_label":    node.get("label", node["id"]),
                "macro_key":     macro_key,
                "unit":          unit,
                "current_value": _uc(current_v),
                "next_event":    next_event,
            })

        return result
    finally:
        conn.close()


@router.get("/{instrument}")
def get_instrument_model(
    instrument: str,
    at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"),
) -> Dict[str, Any]:
    """Graphe complet avec valeurs courantes des nœuds (toutes couches)."""
    from services.database import get_conn
    from services.instrument_models import get_model_state
    conn = get_conn()
    try:
        state = get_model_state(conn, instrument.upper(), at_date)
        if not state:
            raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
        return state
    finally:
        conn.close()


@router.put("/{instrument}/nodes/{node_id}/override")
def set_override(instrument: str, node_id: str, body: OverrideBody) -> Dict[str, Any]:
    """Définit ou met à jour la valeur manuelle d'un nœud."""
    from services.database import get_conn
    from services.instrument_models import set_node_override
    conn = get_conn()
    try:
        set_node_override(conn, instrument.upper(), node_id, body.value, body.note or "")
        return {"ok": True, "instrument": instrument.upper(), "node_id": node_id, "value": body.value}
    finally:
        conn.close()


@router.delete("/{instrument}/nodes/{node_id}/override")
def clear_override(instrument: str, node_id: str) -> Dict[str, Any]:
    """Supprime l'override manuel d'un nœud (retour neutre/events)."""
    from services.database import get_conn
    from services.instrument_models import clear_node_override
    conn = get_conn()
    try:
        clear_node_override(conn, instrument.upper(), node_id)
        return {"ok": True, "instrument": instrument.upper(), "node_id": node_id}
    finally:
        conn.close()


@router.get("/{instrument}/nodes/overrides")
def get_all_overrides(instrument: str) -> List[Dict[str, Any]]:
    """Toutes les overrides manuelles pour un instrument."""
    from services.database import get_conn
    conn = get_conn()
    try:
        rows = conn.execute(
            "SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=? ORDER BY set_at DESC",
            (instrument.upper(),)
        ).fetchall()
        return [dict(r) for r in rows]
    finally:
        conn.close()


class CalendarBackfillBody(BaseModel):
    from_date: str   # ISO date string "YYYY-MM-DD"
    to_date:   Optional[str] = None  # defaults to today


@router.post("/ff-calendar/backfill")
def backfill_calendar(body: CalendarBackfillBody) -> Dict[str, Any]:
    """
    Backfill ff_calendar with historical data from ForexFactory HTML scraper.
    Scrapes week by week from from_date to to_date (or today).
    Skips weeks already populated. Runs synchronously — may take several minutes for long ranges.
    """
    from datetime import date as date_type
    from services.ff_calendar import sync_historical_range
    try:
        from_d = date_type.fromisoformat(body.from_date[:10])
        to_d   = date_type.fromisoformat(body.to_date[:10]) if body.to_date else date_type.today()
    except ValueError as e:
        raise HTTPException(status_code=400, detail=f"Invalid date: {e}")

    if (to_d - from_d).days > 730:
        raise HTTPException(status_code=400, detail="Range too large (max 2 years)")

    result = sync_historical_range(from_d, to_d)
    return result
