r"""
regime_live.py
==============
Lightweight regime classification module called by dashboard_server.py
on each refresh cycle.

Provides:
  compute_regimes(trades_eu, trades_uj, base_path) -> dict

Returns a dict suitable for inclusion in dashboard_data.json with
Activity tier (from signal density) and Macro tier (from spread
state) for each pair, plus descriptive context.

This module is read-only — it never writes thresholds. The cutoffs
come from data/research/regime_thresholds.json which is calibrated
by src/research/regime_classifier_v1.py.

If regime_thresholds.json is missing or malformed, compute_regimes()
returns a dict with all tiers set to "Unknown" — the dashboard renders
a placeholder rather than crashing.
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path

import numpy as np
import pandas as pd

# Activity tier names — separate from macro tier names to avoid confusion
ACTIVITY_NAMES_EU = ["Quiet", "Light", "Steady", "Active", "Peak"]
ACTIVITY_NAMES_UJ = ["Quiet", "Steady", "Active"]
ACTIVITY_PCTS_EU  = [10, 30, 60, 90]
ACTIVITY_PCTS_UJ  = [25, 75]
ACTIVITY_WINDOW_EU = 14  # days
ACTIVITY_WINDOW_UJ = 60  # days

# Macro tier names match the calibration script
MACRO_NAMES_EU = ["Compressed", "Subdued", "Normal", "Elevated", "High Dislocation"]
MACRO_NAMES_UJ = ["Subdued", "Normal", "Active"]

LEVEL_WEIGHT = 0.5

# Per-tier descriptions for the dashboard.
# EU has 5 tiers; UJ has 3 tiers. The names "Active" and "Quiet" appear
# in both systems but mean different percentile bands, so descriptions
# are kept separate.
ACTIVITY_DESCRIPTIONS_EU = {
    "Peak":   "Strategy firing at top 10% of historical density. Setups occurring multiple times per day on average.",
    "Active": "Above-average signal density (60-90th percentile of history). Steady stream of setups.",
    "Steady": "Typical signal density at historical median (~7 trades / 14d). Standard operating rhythm.",
    "Light":  "Below-average signal density (10-30th percentile). Setups present but less frequent than average.",
    "Quiet":  "Signal density in bottom 10% of history. Few setups expected — strategy edge unchanged, frequency is the variable.",
    "Unknown":"Cutoffs not yet calibrated — run regime_classifier_v1.py first.",
}
ACTIVITY_DESCRIPTIONS_UJ = {
    "Active": "Above-average signal density (top 25% of history). Common during BoJ policy shifts, JPY intervention windows, or Fed-BoJ divergence stretches.",
    "Steady": "Typical signal density at historical median (~5 trades / 60d). Standard operating regime.",
    "Quiet":  "Signal density in bottom 25% of history (<3 trades / 60d). Common when US and JP central banks are in similar policy stances.",
    "Unknown":"Cutoffs not yet calibrated — run regime_classifier_v1.py first.",
}

MACRO_DESCRIPTIONS_EU = {
    "High Dislocation": "US-DE 2Y spread heavily out of sync. Composite score in top 10% of history. Spreads moving fast and/or sitting at extreme levels.",
    "Elevated":         "Above-average spread dislocation (60-90th percentile). Either velocity is high, level is wide, or both.",
    "Normal":           "Typical macro conditions. Spread moving and sitting in normal historical range.",
    "Subdued":          "Below-average dislocation. Spreads stable, level near central tendency.",
    "Compressed":       "US-DE 2Y spread tight or rangebound. Composite score in bottom 10% of history.",
    "Unknown":          "Cutoffs not yet calibrated — run regime_classifier_v1.py first.",
}
MACRO_DESCRIPTIONS_UJ = {
    "Active":           "US-JP 2Y spread showing meaningful moves. Composite score in top 25% of history.",
    "Normal":           "Spread in typical range with normal movement. Standard operating regime.",
    "Subdued":          "Spread compressed or stable. Bottom 25% of history. Common when both central banks are in similar policy stances.",
    "Unknown":          "Cutoffs not yet calibrated — run regime_classifier_v1.py first.",
}


def _assign_tier(value, cutoffs, names):
    """Given a value, list of ascending cutoffs (length N-1), and list of N tier
    names (lowest-first), return the tier name."""
    if value is None:
        return "Unknown"
    if isinstance(value, float) and np.isnan(value):
        return "Unknown"
    for i, c in enumerate(cutoffs):
        if value < c:
            return names[i]
    return names[-1]


def _load_thresholds(base_path):
    """Load regime_thresholds.json if it exists. Returns None on failure."""
    p = Path(base_path) / "data" / "research" / "regime_thresholds.json"
    if not p.exists():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except Exception:
        return None


def _load_rate(base_path, fname, col):
    p = Path(base_path) / "data" / "raw" / "rates" / fname
    if not p.exists():
        return None
    try:
        df = pd.read_csv(p)
        df.columns = [c.lower() for c in df.columns]
        df["date"] = pd.to_datetime(df["date"])
        df[col] = pd.to_numeric(df[col], errors="coerce")
        return df[["date", col]].dropna().sort_values("date").reset_index(drop=True)
    except Exception:
        return None


def _build_spread_series(base_path, a_csv, a_col, b_csv, b_col):
    """Build daily spread = A - B, forward-filled across calendar days."""
    a = _load_rate(base_path, a_csv, a_col)
    b = _load_rate(base_path, b_csv, b_col)
    if a is None or b is None:
        return None
    dr = pd.DataFrame({"date": pd.date_range(
        min(a["date"].min(), b["date"].min()),
        max(a["date"].max(), b["date"].max()), freq="D")})
    a = pd.merge(dr, a, on="date", how="left").ffill()
    b = pd.merge(dr, b, on="date", how="left").ffill()
    df = pd.merge(a, b, on="date", how="inner").dropna()
    df["spread"] = df[a_col] - df[b_col]
    return df[["date", "spread"]].reset_index(drop=True)


def _compute_composite_score(spread_df, velocity_days):
    """Compute the macro composite score series. Returns latest row dict."""
    df = spread_df.copy()
    df["velocity"] = df["spread"] - df["spread"].shift(velocity_days)
    warmup = velocity_days + 30
    if len(df) < warmup + 1:
        return None
    lm = df["spread"].iloc[warmup:].mean()
    ls = df["spread"].iloc[warmup:].std()
    vm = df["velocity"].iloc[warmup:].mean()
    vs = df["velocity"].iloc[warmup:].std()
    df["level_z"]    = (df["spread"]   - lm) / ls
    df["velocity_z"] = (df["velocity"] - vm) / vs
    df["composite"]  = df["velocity_z"].abs() + LEVEL_WEIGHT * df["level_z"].abs()
    latest = df.dropna(subset=["composite"]).iloc[-1]
    return {
        "date":       latest["date"].isoformat(),
        "spread":     float(latest["spread"]),
        "level_z":    float(latest["level_z"]),
        "velocity_z": float(latest["velocity_z"]),
        "composite":  float(latest["composite"]),
    }


def _load_live_trade_times(base_path, pair):
    """Load entry/close timestamps from the live monitor's trade_history_<pair>.json.
    
    The live JSON schema records `closed_at` (trade close time), not entry_time.
    For activity counting purposes we use close time as a proxy for entry time
    — trades hold 24-52h max so the shift is small relative to the 14d/60d
    activity windows.
    
    Returns pandas Series of timestamps, or empty Series on failure.
    """
    p = Path(base_path) / "data" / "logs" / f"trade_history_{pair.lower()}.json"
    if not p.exists():
        return pd.Series(dtype="datetime64[ns]")
    try:
        live = json.loads(p.read_text(encoding="utf-8"))
        if not isinstance(live, list):
            return pd.Series(dtype="datetime64[ns]")
        times = []
        for trade in live:
            # Try multiple field names since schema may evolve
            for k in ("entry_time", "closed_at", "entry", "open_time", "close_time", "time"):
                if k in trade:
                    try:
                        t = pd.to_datetime(trade[k])
                        times.append(t)
                        break
                    except Exception:
                        pass
        return pd.Series(times)
    except Exception:
        return pd.Series(dtype="datetime64[ns]")


def _count_trades_in_window(base_path, pair, window_days):
    """Count trades in the trailing window_days ending NOW, from live JSON only.
    
    The backtest CSVs are not consulted here — they're historical artifacts
    that don't update continuously. For live regime classification we use the
    live monitor's trade_history_<pair>.json as the single source of truth.
    
    Returns a tuple (count, total_live_trades):
      count             = trades within window
      total_live_trades = total trades in live history (for "early sample" detection)
    """
    times = _load_live_trade_times(base_path, pair)
    if times.empty:
        return 0, 0
    
    now = pd.Timestamp.now()
    cutoff = now - pd.Timedelta(days=window_days)
    in_window = int(((times > cutoff) & (times <= now)).sum())
    return in_window, len(times)


def compute_regimes(base_path):
    """Compute Activity + Macro regime tiers for both pairs.
    
    Args:
      base_path: project root Path (where data/ lives)
    
    Returns:
      dict suitable for direct inclusion in dashboard_data.json
    
    Activity counts are sourced from the live monitor's trade history JSON
    (data/logs/trade_history_eurusd.json and trade_history_usdjpy.json) only.
    Backtest CSVs are not consulted at runtime — those are historical and
    do not auto-update. When live history is small (early deployment), an
    "early_sample" flag is set so the dashboard can show a caveat.
    """
    base_path = Path(base_path)
    
    out = {
        "updated_at": datetime.now().isoformat(timespec="seconds"),
        "eurusd": {"activity": {}, "macro": {}},
        "usdjpy": {"activity": {}, "macro": {}},
        "errors": [],
    }
    
    # Load calibrated thresholds (cutoffs from full historical CSV via classifier)
    thresholds = _load_thresholds(base_path)
    if thresholds is None:
        out["errors"].append("regime_thresholds.json missing — run regime_classifier_v1.py")
    
    # "Early sample" thresholds — below this many total live trades, flag as limited
    EU_MIN_TRADES_FOR_RELIABLE = 14   # ~1 month of validated EU activity
    UJ_MIN_TRADES_FOR_RELIABLE = 6    # ~2 months of validated UJ activity
    
    # ── EURUSD Activity tier ──
    eu_count, eu_total = _count_trades_in_window(base_path, "eurusd", ACTIVITY_WINDOW_EU)
    eu_act_cutoffs = None
    if thresholds and "eurusd" in thresholds:
        eu_act_cutoffs = thresholds["eurusd"].get("activity_cutoffs")
    eu_act_tier = _assign_tier(eu_count, eu_act_cutoffs or [0, 0, 0, 0], ACTIVITY_NAMES_EU) \
        if eu_act_cutoffs else "Unknown"
    out["eurusd"]["activity"] = {
        "tier":          eu_act_tier,
        "count":         eu_count,
        "window_days":   ACTIVITY_WINDOW_EU,
        "cutoffs":       eu_act_cutoffs,
        "description":   ACTIVITY_DESCRIPTIONS_EU.get(eu_act_tier, ""),
        "total_live":    eu_total,
        "early_sample":  eu_total < EU_MIN_TRADES_FOR_RELIABLE,
    }
    
    # ── USDJPY Activity tier ──
    uj_count, uj_total = _count_trades_in_window(base_path, "usdjpy", ACTIVITY_WINDOW_UJ)
    uj_act_cutoffs = None
    if thresholds and "usdjpy" in thresholds:
        uj_act_cutoffs = thresholds["usdjpy"].get("activity_cutoffs")
    uj_act_tier = _assign_tier(uj_count, uj_act_cutoffs or [0, 0], ACTIVITY_NAMES_UJ) \
        if uj_act_cutoffs else "Unknown"
    out["usdjpy"]["activity"] = {
        "tier":          uj_act_tier,
        "count":         uj_count,
        "window_days":   ACTIVITY_WINDOW_UJ,
        "cutoffs":       uj_act_cutoffs,
        "description":   ACTIVITY_DESCRIPTIONS_UJ.get(uj_act_tier, ""),
        "total_live":    uj_total,
        "early_sample":  uj_total < UJ_MIN_TRADES_FOR_RELIABLE,
    }
    
    # ── EURUSD Macro tier ──
    eu_macro_tier = "Unknown"
    eu_macro_data = None
    if thresholds and "eurusd" in thresholds:
        eu_macro_cutoffs   = thresholds["eurusd"].get("cutoffs")
        eu_velocity_days   = thresholds["eurusd"].get("velocity_days", 14)
        if eu_macro_cutoffs:
            spread = _build_spread_series(base_path, "us2y.csv", "us2y", "de2y.csv", "de2y")
            if spread is not None:
                eu_macro_data = _compute_composite_score(spread, eu_velocity_days)
                if eu_macro_data:
                    eu_macro_tier = _assign_tier(
                        eu_macro_data["composite"], eu_macro_cutoffs, MACRO_NAMES_EU)
    out["eurusd"]["macro"] = {
        "tier":         eu_macro_tier,
        "data":         eu_macro_data,
        "description":  MACRO_DESCRIPTIONS_EU.get(eu_macro_tier, ""),
    }
    
    # ── USDJPY Macro tier ──
    uj_macro_tier = "Unknown"
    uj_macro_data = None
    if thresholds and "usdjpy" in thresholds:
        uj_macro_cutoffs   = thresholds["usdjpy"].get("cutoffs")
        uj_velocity_days   = thresholds["usdjpy"].get("velocity_days", 60)
        if uj_macro_cutoffs:
            spread = _build_spread_series(base_path, "us2y.csv", "us2y", "jp2y.csv", "jp2y")
            if spread is not None:
                uj_macro_data = _compute_composite_score(spread, uj_velocity_days)
                if uj_macro_data:
                    uj_macro_tier = _assign_tier(
                        uj_macro_data["composite"], uj_macro_cutoffs, MACRO_NAMES_UJ)
    out["usdjpy"]["macro"] = {
        "tier":         uj_macro_tier,
        "data":         uj_macro_data,
        "description":  MACRO_DESCRIPTIONS_UJ.get(uj_macro_tier, ""),
    }
    
    return out


# Smoke test when run directly
if __name__ == "__main__":
    base = Path(__file__).resolve().parents[2]
    result = compute_regimes(base)
    print(json.dumps(result, indent=2, default=str))
