"""
Pattern Lab — Historical backtest engine for pattern discovery.

Workflow:
  1. build_historical_context(date, tickers) — pull prices + technicals via yfinance
  2. run_ai_backtest(context, hint, horizon) — GPT-4o acts as analyst on that past date
  3. evaluate_outcomes(run) — fetch actual prices at T+horizon, score each idea
"""
import json
import logging
from datetime import datetime, timedelta
from math import log, sqrt, exp, erf
from typing import Optional

import numpy as np
import pandas as pd
import yfinance as yf

_log = logging.getLogger(__name__)


# ── Options P&L helpers ────────────────────────────────────────────────────────

def _ncdf(x: float) -> float:
    return 0.5 * (1.0 + erf(x / sqrt(2.0)))


def _bs_price(S: float, K: float, T: float, r: float, sigma: float, opt_type: str) -> float:
    """Black-Scholes European option price. T in years. At expiry (T≤0) returns intrinsic."""
    if T <= 0:
        return max(S - K, 0.0) if opt_type == "call" else max(K - S, 0.0)
    d1 = (log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt(T))
    d2 = d1 - sigma * sqrt(T)
    if opt_type == "call":
        return S * _ncdf(d1) - K * exp(-r * T) * _ncdf(d2)
    return K * exp(-r * T) * _ncdf(-d2) - S * _ncdf(-d1)


def _sigma_for(ticker: str) -> float:
    """Rough annualised vol estimate by asset class (ATM premium sizing)."""
    t = ticker.upper()
    if t.endswith("=X"):                  return 0.08   # FX pairs
    if "VIX" in t:                        return 0.80   # volatility index
    if t.startswith("^"):                 return 0.16   # equity indices
    if t.endswith("-USD") or "-USD" in t: return 0.65   # crypto
    if t.endswith("=F"):                  return 0.25   # commodity futures
    return 0.20                                         # ETFs / default


def _options_pnl_pct(
    strategy: str, ticker: str,
    actual_move_pct: float, horizon_days: int,
    expected_direction: str,
) -> Optional[float]:
    """
    Estimate % P&L on the suggested options strategy assuming ATM entry,
    full-horizon hold, and actual_move_pct underlying move by expiry.
    Normalised to S₀ = K = 100.
    """
    try:
        sigma = _sigma_for(ticker)
        T     = max(horizon_days / 365.0, 1 / 365.0)
        r     = 0.03
        S0    = 100.0
        K     = 100.0
        S1    = S0 * (1.0 + actual_move_pct / 100.0)
        strat = strategy.lower()

        if "straddle" in strat:
            cost    = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
            exit_v  = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")
        elif "strangle" in strat:
            Kc, Kp  = K * 1.05, K * 0.95
            cost    = _bs_price(S0, Kc, T, r, sigma, "call") + _bs_price(S0, Kp, T, r, sigma, "put")
            exit_v  = _bs_price(S1, Kc, 0, r, sigma, "call") + _bs_price(S1, Kp, 0, r, sigma, "put")
        elif "call spread" in strat or "bull call" in strat:
            Kh      = K * 1.10
            cost    = _bs_price(S0, K,  T, r, sigma, "call") - _bs_price(S0, Kh, T, r, sigma, "call")
            exit_v  = _bs_price(S1, K,  0, r, sigma, "call") - _bs_price(S1, Kh, 0, r, sigma, "call")
        elif "put spread" in strat or "bear put" in strat:
            Kl      = K * 0.90
            cost    = _bs_price(S0, K,  T, r, sigma, "put") - _bs_price(S0, Kl, T, r, sigma, "put")
            exit_v  = _bs_price(S1, K,  0, r, sigma, "put") - _bs_price(S1, Kl, 0, r, sigma, "put")
        elif "long call" in strat or ("call" in strat and "put" not in strat):
            cost    = _bs_price(S0, K, T, r, sigma, "call")
            exit_v  = _bs_price(S1, K, 0, r, sigma, "call")
        elif "long put" in strat or ("put" in strat and "call" not in strat):
            cost    = _bs_price(S0, K, T, r, sigma, "put")
            exit_v  = _bs_price(S1, K, 0, r, sigma, "put")
        else:
            # Unknown strategy: fallback based on direction
            if expected_direction == "down":
                cost   = _bs_price(S0, K, T, r, sigma, "put")
                exit_v = _bs_price(S1, K, 0, r, sigma, "put")
            elif expected_direction == "up":
                cost   = _bs_price(S0, K, T, r, sigma, "call")
                exit_v = _bs_price(S1, K, 0, r, sigma, "call")
            else:
                cost   = _bs_price(S0, K, T, r, sigma, "call") + _bs_price(S0, K, T, r, sigma, "put")
                exit_v = _bs_price(S1, K, 0, r, sigma, "call") + _bs_price(S1, K, 0, r, sigma, "put")

        if cost <= 0.01:
            return None
        return round((exit_v - cost) / cost * 100.0, 1)
    except Exception:
        return None


def _score_outcome(
    actual_move: float,
    expected_move: float,
    expected_dir: str,
    strategy: str,
    ticker: str,
    horizon_days: int,
) -> dict:
    """
    3-tier scoring:
      FULL    — right direction AND ≥ 50% of expected magnitude
      PARTIAL — right direction AND ≥ 15% of expected magnitude
      MISS    — wrong direction or negligible move
    `hit` (bool) = True only for FULL (for backtest_hits counter compatibility).
    """
    em = abs(float(expected_move))

    if expected_dir == "up":
        direction_correct = actual_move > 0
        direction_ratio   = (actual_move / em) if em > 0 else 0.0
    elif expected_dir == "down":
        direction_correct = actual_move < 0
        direction_ratio   = (-actual_move / em) if em > 0 else 0.0
    else:  # "any" / volatility
        direction_correct = True
        direction_ratio   = (abs(actual_move) / em) if em > 0 else 0.0

    direction_ratio = round(direction_ratio, 3)

    if direction_correct and direction_ratio >= 0.50:
        hit_type = "full"
    elif direction_correct and direction_ratio >= 0.15:
        hit_type = "partial"
    else:
        hit_type = "miss"

    options_pnl = _options_pnl_pct(strategy, ticker, actual_move, horizon_days, expected_dir)

    return {
        "hit":                    hit_type == "full",
        "hit_type":               hit_type,
        "direction_correct":      direction_correct,
        "direction_ratio":        direction_ratio,
        "estimated_options_pnl_pct": options_pnl,
    }


# ── Context builder ────────────────────────────────────────────────────────────

def _fetch_ticker(ticker: str, start: str, end: str) -> Optional[pd.DataFrame]:
    try:
        df = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
        if isinstance(df.columns, pd.MultiIndex):
            df.columns = df.columns.get_level_values(0)
        return df if not df.empty else None
    except Exception as e:
        _log.warning(f"[PatternLab] yfinance error for {ticker}: {e}")
        return None


def _rsi(closes: pd.Series, period: int = 14) -> Optional[float]:
    if len(closes) < period + 1:
        return None
    delta = closes.diff()
    gain = delta.clip(lower=0).rolling(period).mean()
    loss = (-delta.clip(upper=0)).rolling(period).mean()
    rs = gain / loss
    val = 100 - (100 / (1 + rs.iloc[-1]))
    return round(float(val), 1) if not np.isnan(val) else None


def build_historical_context(analysis_date: str, tickers: list) -> dict:
    dt = datetime.strptime(analysis_date, "%Y-%m-%d")
    hist_start = (dt - timedelta(days=400)).strftime("%Y-%m-%d")
    hist_end   = (dt + timedelta(days=3)).strftime("%Y-%m-%d")

    context: dict = {"analysis_date": analysis_date, "assets": {}}

    all_tickers = list(tickers)
    if "^VIX" not in all_tickers:
        all_tickers.append("^VIX")

    for ticker in all_tickers:
        df = _fetch_ticker(ticker, hist_start, hist_end)
        if df is None or "Close" not in df.columns:
            context["assets"][ticker] = {"error": "no data"}
            continue

        df.index = pd.to_datetime(df.index).tz_localize(None)
        sub = df[df.index.normalize() <= dt].copy()
        if sub.empty:
            context["assets"][ticker] = {"error": "no data before date"}
            continue

        closes = sub["Close"].dropna()
        price = float(closes.iloc[-1])

        def ret(days: int) -> Optional[float]:
            past = sub[sub.index.normalize() <= dt - timedelta(days=days)]
            if past.empty:
                return None
            p0 = float(past["Close"].dropna().iloc[-1])
            return round((price / p0 - 1) * 100, 2) if p0 else None

        ma50  = round(float(closes.tail(50).mean()), 2) if len(closes) >= 50 else None
        ma200 = round(float(closes.tail(200).mean()), 2) if len(closes) >= 200 else None

        context["assets"][ticker] = {
            "price":           round(price, 4),
            "ret_1w":          ret(5),
            "ret_1m":          ret(21),
            "ret_3m":          ret(63),
            "ret_ytd":         ret(int((dt - datetime(dt.year, 1, 1)).days)),
            "rsi_14":          _rsi(closes.tail(60)),
            "ma50":            ma50,
            "ma200":           ma200,
            "pct_from_ma200":  round((price / ma200 - 1) * 100, 2) if ma200 else None,
        }

    return context


# ── AI analysis ────────────────────────────────────────────────────────────────

async def run_ai_backtest(context: dict, theme_hint: str, horizon_days: int, openai_key: str) -> dict:
    from openai import AsyncOpenAI
    client = AsyncOpenAI(api_key=openai_key)

    analysis_date = context["analysis_date"]
    assets = context.get("assets", {})

    lines = []
    for ticker, d in assets.items():
        if "error" in d:
            continue
        line = f"  {ticker}: {d.get('price', '?')}"
        if d.get("ret_1w")  is not None: line += f"  1W:{d['ret_1w']:+.1f}%"
        if d.get("ret_1m")  is not None: line += f"  1M:{d['ret_1m']:+.1f}%"
        if d.get("ret_3m")  is not None: line += f"  3M:{d['ret_3m']:+.1f}%"
        if d.get("rsi_14")  is not None: line += f"  RSI:{d['rsi_14']:.0f}"
        if d.get("pct_from_ma200") is not None: line += f"  vsMA200:{d['pct_from_ma200']:+.1f}%"
        lines.append(line)

    market_block = "\n".join(lines) if lines else "No market data available."

    prompt = f"""You are a senior macro/options trader. Today is {analysis_date}.
You have full knowledge of what was happening in markets and geopolitics on this specific date.

## REAL MARKET DATA — {analysis_date}
{market_block}

## CONTEXT / THEME
{theme_hint}

## INSTRUCTIONS
Using the market data above AND your knowledge of the historical context on {analysis_date}:
- Identify 2 to 4 high-conviction trade patterns that were present at this time.
- For each pattern, recommend the best options/futures strategy.
- The investment horizon is {horizon_days} days from {analysis_date}.

For each pattern you MUST specify:
- The primary underlying ticker (must be a real, liquid, yfinance-compatible symbol)
- The expected percentage move in the UNDERLYING over {horizon_days} days
- The expected direction: "up", "down", or "any" (for volatility plays)
- A confidence score 0–100

Return ONLY this valid JSON (no markdown, no explanation):
{{
  "patterns": [
    {{
      "name": "Short descriptive pattern name",
      "description": "What you observed and why this matters",
      "category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
      "signal_direction": "bullish|bearish|volatility|neutral",
      "underlying": "TICKER",
      "strategy": "e.g. long put, long call, straddle, call spread",
      "expected_move_pct": 12.5,
      "expected_direction": "up|down|any",
      "horizon_days": {horizon_days},
      "confidence": 72,
      "rationale": "Concise explanation of the trade thesis at this historical moment"
    }}
  ]
}}"""

    try:
        resp = await client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.2,
            response_format={"type": "json_object"},
            timeout=60,
        )
        raw = resp.choices[0].message.content or "{}"
        return json.loads(raw)
    except Exception as e:
        _log.error(f"[PatternLab] AI backtest failed: {e}")
        return {"patterns": [], "error": str(e)}


# ── Outcome evaluator ──────────────────────────────────────────────────────────

def evaluate_outcomes(run: dict) -> list:
    analysis_date = run["analysis_date"]
    horizon_days  = int(run["horizon_days"])
    ai_result     = json.loads(run.get("ai_result") or "{}")
    patterns      = ai_result.get("patterns", [])

    dt       = datetime.strptime(analysis_date, "%Y-%m-%d")
    eval_dt  = dt + timedelta(days=horizon_days)
    # Don't evaluate if eval date is in the future
    if eval_dt.date() >= datetime.utcnow().date():
        return [{"error": f"Evaluation date {eval_dt.date()} is in the future"}]

    fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
    fetch_end   = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")

    outcomes = []
    for pat in patterns:
        ticker = pat.get("underlying", "")
        if not ticker:
            continue

        df = _fetch_ticker(ticker, fetch_start, fetch_end)
        if df is None or "Close" not in df.columns:
            outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "no data"})
            continue

        df.index = pd.to_datetime(df.index).tz_localize(None)

        entry_sub = df[df.index.normalize() <= dt]
        exit_sub  = df[df.index.normalize() <= eval_dt]
        if entry_sub.empty or exit_sub.empty:
            outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker, "error": "insufficient history"})
            continue

        entry_price   = float(entry_sub["Close"].dropna().iloc[-1])
        exit_price    = float(exit_sub["Close"].dropna().iloc[-1])
        actual_move   = round((exit_price / entry_price - 1) * 100, 2)

        expected_dir  = pat.get("expected_direction", "any")
        expected_move = float(pat.get("expected_move_pct", 0))
        strategy      = pat.get("strategy", "")

        score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, horizon_days)

        outcomes.append({
            "pattern_name":    pat.get("name"),
            "underlying":      ticker,
            "strategy":        strategy,
            "signal_direction": pat.get("signal_direction", ""),
            "expected_direction": expected_dir,
            "expected_move_pct":  expected_move,
            "actual_move_pct":    actual_move,
            "entry_price":     round(entry_price, 4),
            "exit_price":      round(exit_price, 4),
            "entry_date":      analysis_date,
            "exit_date":       eval_dt.strftime("%Y-%m-%d"),
            "confidence":      pat.get("confidence", 0),
            **score,
        })

    return outcomes


# ── Instrument Scan ────────────────────────────────────────────────────────────

async def run_instrument_scan(
    ticker: str, instrument_name: str,
    start_date: str, end_date: str,
    horizon_days: int, openai_key: str,
) -> dict:
    """
    Analyse a specific instrument over a historical period.
    The AI identifies 4-6 key pattern instances (each with its own entry date)
    that drove significant moves in the underlying.
    """
    from openai import AsyncOpenAI
    client = AsyncOpenAI(api_key=openai_key)

    # Fetch full period price history
    fetch_start = (datetime.strptime(start_date, "%Y-%m-%d") - timedelta(days=5)).strftime("%Y-%m-%d")
    fetch_end   = (datetime.strptime(end_date,   "%Y-%m-%d") + timedelta(days=5)).strftime("%Y-%m-%d")
    df = _fetch_ticker(ticker, fetch_start, fetch_end)

    monthly_lines = []
    weekly_spikes = []
    if df is not None and "Close" in df.columns:
        df.index = pd.to_datetime(df.index).tz_localize(None)
        s_dt = pd.Timestamp(start_date)
        e_dt = pd.Timestamp(end_date)
        sub  = df[(df.index >= s_dt) & (df.index <= e_dt)].copy()

        if not sub.empty:
            # Monthly returns
            monthly = sub["Close"].resample("ME").last().dropna()
            for i in range(1, len(monthly)):
                ret = (monthly.iloc[i] / monthly.iloc[i - 1] - 1) * 100
                monthly_lines.append(f"  {monthly.index[i].strftime('%Y-%m')}: {ret:+.1f}%  (price {monthly.iloc[i]:.2f})")

            # Weekly moves > 3%
            weekly = sub["Close"].resample("W").last().dropna()
            for i in range(1, len(weekly)):
                wret = (weekly.iloc[i] / weekly.iloc[i - 1] - 1) * 100
                if abs(wret) >= 3.0:
                    weekly_spikes.append(
                        f"  week of {weekly.index[i].strftime('%Y-%m-%d')}: {wret:+.1f}%"
                    )

    monthly_block = "\n".join(monthly_lines) if monthly_lines else "  (no monthly data)"
    spikes_block  = "\n".join(weekly_spikes[:20]) if weekly_spikes else "  (none > 3%)"

    prompt = f"""You are a senior macro/options trader with deep historical market knowledge.

Instrument: {instrument_name} [{ticker}]
Period: {start_date} → {end_date}

## ACTUAL MONTHLY PRICE RETURNS
{monthly_block}

## SIGNIFICANT WEEKLY MOVES (>3%)
{spikes_block}

## YOUR TASK
Based on the actual price data above and your historical knowledge of this period:
1. Identify 4 to 6 DISTINCT pattern/event instances that drove the most significant moves in {instrument_name}
2. Each pattern must have a specific ENTRY DATE (the trading signal date) within the period
3. Each pattern must be unique — different catalysts on different dates
4. The trade horizon per pattern is {horizon_days} days

For each pattern instance you MUST specify:
- `analysis_date`: the exact signal/entry date (YYYY-MM-DD, must be within {start_date} to {end_date})
- `underlying`: use "{ticker}" as the underlying
- `expected_direction`: "up", "down", or "any" (for volatility)
- `expected_move_pct`: realistic % move over {horizon_days} days (consistent with what actually happened)
- `confidence`: 0-100

Return ONLY this valid JSON:
{{
  "instrument": "{instrument_name}",
  "ticker": "{ticker}",
  "period": "{start_date} to {end_date}",
  "patterns": [
    {{
      "name": "Pattern name",
      "description": "What caused this move and why it was tradeable",
      "category": "géopolitique|macro_monétaire|technique|commodités_supply|risk_off|flux_saisonnier|géo_économique|crédit_stress",
      "signal_direction": "bullish|bearish|volatility|neutral",
      "analysis_date": "YYYY-MM-DD",
      "underlying": "{ticker}",
      "strategy": "long call|long put|straddle|call spread|put spread|...",
      "expected_direction": "up|down|any",
      "expected_move_pct": 5.0,
      "horizon_days": {horizon_days},
      "confidence": 75,
      "rationale": "Why this was a high-conviction trade at this specific moment"
    }}
  ]
}}"""

    try:
        resp = await client.chat.completions.create(
            model="gpt-4o",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.2,
            response_format={"type": "json_object"},
            timeout=90,
        )
        raw = resp.choices[0].message.content or "{}"
        return json.loads(raw)
    except Exception as e:
        _log.error(f"[PatternLab] Instrument scan AI failed: {e}")
        return {"patterns": [], "error": str(e)}


def evaluate_instrument_outcomes(run: dict) -> list:
    """
    Like evaluate_outcomes but each pattern has its own analysis_date.
    Used for instrument scans where the AI generates time-distributed pattern instances.
    """
    ai_result = json.loads(run.get("ai_result") or "{}")
    patterns  = ai_result.get("patterns", [])
    default_horizon = int(run["horizon_days"])

    outcomes = []
    for pat in patterns:
        ticker      = pat.get("underlying", "")
        pat_date    = pat.get("analysis_date") or run["analysis_date"]
        pat_horizon = int(pat.get("horizon_days") or default_horizon)
        if not ticker or not pat_date:
            continue

        try:
            dt      = datetime.strptime(pat_date, "%Y-%m-%d")
            eval_dt = dt + timedelta(days=pat_horizon)
        except ValueError:
            continue

        if eval_dt.date() >= datetime.utcnow().date():
            outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
                             "analysis_date": pat_date, "error": "eval date in the future"})
            continue

        fetch_start = (dt - timedelta(days=5)).strftime("%Y-%m-%d")
        fetch_end   = (eval_dt + timedelta(days=5)).strftime("%Y-%m-%d")
        df = _fetch_ticker(ticker, fetch_start, fetch_end)
        if df is None or "Close" not in df.columns:
            outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
                             "analysis_date": pat_date, "error": "no data"})
            continue

        df.index = pd.to_datetime(df.index).tz_localize(None)
        entry_sub = df[df.index.normalize() <= dt]
        exit_sub  = df[df.index.normalize() <= eval_dt]
        if entry_sub.empty or exit_sub.empty:
            outcomes.append({"pattern_name": pat.get("name"), "underlying": ticker,
                             "analysis_date": pat_date, "error": "insufficient history"})
            continue

        entry_price   = float(entry_sub["Close"].dropna().iloc[-1])
        exit_price    = float(exit_sub["Close"].dropna().iloc[-1])
        actual_move   = round((exit_price / entry_price - 1) * 100, 2)

        expected_dir  = pat.get("expected_direction", "any")
        expected_move = float(pat.get("expected_move_pct", 0))
        strategy      = pat.get("strategy", "")

        score = _score_outcome(actual_move, expected_move, expected_dir, strategy, ticker, pat_horizon)

        outcomes.append({
            "pattern_name":       pat.get("name"),
            "underlying":         ticker,
            "strategy":           strategy,
            "signal_direction":   pat.get("signal_direction", ""),
            "expected_direction": expected_dir,
            "expected_move_pct":  expected_move,
            "actual_move_pct":    actual_move,
            "entry_price":        round(entry_price, 4),
            "exit_price":         round(exit_price, 4),
            "analysis_date":      pat_date,
            "entry_date":         pat_date,
            "exit_date":          eval_dt.strftime("%Y-%m-%d"),
            "confidence":         pat.get("confidence", 0),
            **score,
        })

    return outcomes
