"""
Synthetic option chain generation using the trained **XGBoost IV surface** model.

Provides :class:`SyntheticLoader` with the same ergonomics as
:class:`~RenTech.core.options_data_loader.IVolatilityLoader` (``iter_chain_dates``,
``get_chain_for_date``) while building quotes on demand via
:class:`SyntheticOptionChain.find_target_leg`.

On open positions, call :meth:`SyntheticLoader.attach_position_quotes` so
``find_contract_in_chain`` receives refreshed bid/ask/mid for MTM in the backtester.
"""

from __future__ import annotations

import math
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator

import numpy as np
import pandas as pd

_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

from RenTech.core.iv_surface_model import (  # noqa: E402
    DEFAULT_MODEL_FILENAME,
    synthetic_black_scholes,
)
from RenTech.core.options_data_loader import OptionChain, OptionContract  # noqa: E402

# Half-spread around synthetic mid ($/share) so bid/ask stay realistic for execution logic.
DEFAULT_SYNTHETIC_SPREAD = 0.05


def _norm_day(ts: pd.Timestamp | object) -> pd.Timestamp:
    return pd.Timestamp(ts).normalize()


def _bs_put_delta(S: float, K: float, T: float, r: float, sigma: float) -> float:
    """Black–Scholes delta for a European put (per share)."""
    from scipy.stats import norm

    eps = 1e-12
    T = max(float(T), eps)
    sigma = max(float(sigma), eps)
    S, K = float(S), float(K)
    sqrt_t = math.sqrt(T)
    d1 = (math.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * sqrt_t)
    return float(norm.cdf(d1) - 1.0)


def _vector_put_delta(
    S: np.ndarray,
    K: np.ndarray,
    T: np.ndarray,
    r: float,
    sigma: np.ndarray,
) -> np.ndarray:
    from scipy.stats import norm

    eps = 1e-12
    T = np.maximum(T, eps)
    sigma = np.maximum(sigma, eps)
    sqrt_t = np.sqrt(T)
    d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * sqrt_t)
    return norm.cdf(d1) - 1.0


def _vector_put_gamma_theta_vega(
    S: np.ndarray,
    K: np.ndarray,
    T: np.ndarray,
    r: float,
    sigma: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Gamma, theta (per day), vega (per 1 vol point) for puts."""
    from scipy.stats import norm

    eps = 1e-12
    T = np.maximum(T, eps)
    sigma = np.maximum(sigma, eps)
    sqrt_t = np.sqrt(T)
    d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * sqrt_t)
    d2 = d1 - sigma * sqrt_t
    disc = np.exp(-r * T)
    pdf1 = norm.pdf(d1)
    gamma = pdf1 / (S * sigma * sqrt_t)
    vega = (S * pdf1 * sqrt_t) / 100.0
    theta_y = -S * pdf1 * sigma / (2.0 * sqrt_t) + r * K * disc * norm.cdf(-d2)
    theta = theta_y / 365.0
    return gamma, theta, vega


def _build_strike_grid(spy_price: float, *, lo_pct: float, hi_pct: float, step: float) -> np.ndarray:
    lo = spy_price * lo_pct
    hi = spy_price * hi_pct
    if step <= 0:
        raise ValueError("step must be positive")
    n = int(math.ceil((hi - lo) / step)) + 1
    strikes = np.linspace(lo, hi, n)
    # Snap to step grid for cleaner strikes
    strikes = np.round(strikes / step) * step
    return np.unique(strikes[strikes > 0])


def _predict_iv_matrix(
    model: Any,
    vix_level: float,
    dte: float,
    moneyness: np.ndarray,
    is_call: float,
) -> np.ndarray:
    """Batch IV prediction from XGBoost model."""
    import pandas as pd

    n = len(moneyness)
    X = pd.DataFrame(
        {
            "vix_level": np.full(n, float(vix_level), dtype=np.float64),
            "dte": np.full(n, float(dte), dtype=np.float64),
            "moneyness": moneyness.astype(np.float64),
            "is_call": np.full(n, float(is_call), dtype=np.float64),
        }
    )
    iv = model.predict(X).astype(np.float64)
    return np.clip(iv, 1e-4, 5.0)


@dataclass
class SyntheticOptionChain(OptionChain):
    """
    Lightweight chain: ``contracts`` may stay empty until
    :meth:`SyntheticLoader.attach_position_quotes` injects MTM rows, or until
    :meth:`find_target_leg` returns a single synthesized :class:`OptionContract`
    (without adding it to ``contracts`` unless you append it yourself).
    """

    spy_price: float = 0.0
    vix_level: float = 0.0
    risk_free_rate: float = 0.04
    model: Any = None
    strike_step: float = 1.0
    moneyness_low: float = 0.70
    moneyness_high: float = 1.10
    spread_abs: float = DEFAULT_SYNTHETIC_SPREAD

    def find_target_leg(
        self,
        target_dte: int,
        target_delta: float,
        option_type: str,
    ) -> OptionContract:
        """
        Search strikes from ``moneyness_low``–``moneyness_high`` × spot in ``strike_step``
        increments; predict IV; price with Black–Scholes; pick strike with delta closest to
        ``target_delta``.
        """
        if self.model is None:
            raise ValueError("SyntheticOptionChain has no model reference.")
        ot = str(option_type).strip().upper()[:1]
        if ot not in ("C", "P"):
            ot = "P" if "P" in str(option_type).upper() else "C"
        is_call = 1.0 if ot == "C" else 0.0

        S = float(self.spy_price)
        vix = float(self.vix_level)
        r = float(self.risk_free_rate)
        as_of = _norm_day(self.as_of)

        expiration = as_of + pd.Timedelta(days=int(target_dte))
        T = float(target_dte) / 365.0
        if T <= 0:
            raise ValueError("target_dte must be >= 1")

        strikes = _build_strike_grid(
            S, lo_pct=self.moneyness_low, hi_pct=self.moneyness_high, step=self.strike_step
        )
        moneyness = strikes / S
        iv_hat = _predict_iv_matrix(self.model, vix, float(target_dte), moneyness, is_call)

        S_arr = np.full_like(strikes, S, dtype=np.float64)
        T_arr = np.full_like(strikes, T, dtype=np.float64)
        ot_arr = np.full(len(strikes), "C" if is_call else "P", dtype=object)
        mids = synthetic_black_scholes(S_arr, strikes, T_arr, r, iv_hat, ot_arr)

        if is_call:
            from scipy.stats import norm

            eps = 1e-12
            Ta = np.maximum(T_arr, eps)
            sa = np.maximum(iv_hat, eps)
            sqrt_t = np.sqrt(Ta)
            d1 = (np.log(S_arr / strikes) + (r + 0.5 * sa**2) * Ta) / (sa * sqrt_t)
            deltas = norm.cdf(d1)
        else:
            deltas = _vector_put_delta(S_arr, strikes, T_arr, r, iv_hat)

        gam, th, ve = _vector_put_gamma_theta_vega(S_arr, strikes, T_arr, r, iv_hat)
        if is_call:
            # Reuse put greeks module from iv_surface_model would mix; compute call gamma/vega quickly
            from scipy.stats import norm

            eps = 1e-12
            Ta = np.maximum(T_arr, eps)
            sa = np.maximum(iv_hat, eps)
            sqrt_t = np.sqrt(Ta)
            d1 = (np.log(S_arr / strikes) + (r + 0.5 * sa**2) * Ta) / (sa * sqrt_t)
            d2 = d1 - sa * sqrt_t
            disc = np.exp(-r * Ta)
            pdf1 = norm.pdf(d1)
            gam = pdf1 / (S_arr * sa * sqrt_t)
            ve = (S_arr * pdf1 * sqrt_t) / 100.0
            th = (-S_arr * pdf1 * sa / (2.0 * sqrt_t) - r * strikes * disc * norm.cdf(d2)) / 365.0

        i = int(np.argmin(np.abs(deltas - float(target_delta))))
        K = float(strikes[i])
        mid = float(max(mids[i], 0.0))
        iv = float(iv_hat[i])
        de = float(deltas[i])
        ga = float(gam[i])
        th_ = float(th[i])
        ve_ = float(ve[i])
        sp = float(self.spread_abs)
        bid = max(mid - sp, 0.0)
        ask = max(mid + sp, bid + 1e-4)

        return OptionContract(
            date=as_of,
            expiration=expiration,
            strike=K,
            option_type=ot,
            bid=bid,
            ask=ask,
            mid=mid,
            iv=iv,
            delta=de,
            gamma=ga,
            theta=th_,
            vega=ve_,
        )


class SyntheticLoader:
    """
    Drop-in replacement for :class:`~RenTech.core.options_data_loader.IVolatilityLoader`
    when backtesting with **model-generated** surfaces.

    Parameters
    ----------
    spy_vix
        Daily index (normalized dates) with at least ``close`` (SPY) and ``vix_close``.
    model_path
        Path to ``spy_iv_surface.joblib`` (default: next to ``iv_surface_model.py``).
    risk_free_rate
        Continuous rate passed to Black–Scholes (default 4%).
    strike_step
        Strike grid step ($) for :meth:`SyntheticOptionChain.find_target_leg`.
    moneyness_low, moneyness_high
        Strike search bounds as fractions of spot (default 0.70–1.10).
    spread_abs
        Half-spread around synthetic mid: ``bid = mid - spread_abs``, ``ask = mid + spread_abs``.
    """

    def __init__(
        self,
        spy_vix: pd.DataFrame,
        *,
        model_path: str | Path | None = None,
        risk_free_rate: float = 0.04,
        strike_step: float = 1.0,
        moneyness_low: float = 0.70,
        moneyness_high: float = 1.10,
        spread_abs: float = DEFAULT_SYNTHETIC_SPREAD,
    ) -> None:
        self._df = spy_vix.copy()
        self._df.index = pd.to_datetime(self._df.index).normalize()
        self._df = self._df[~self._df.index.duplicated(keep="last")].sort_index()
        for col in ("close", "vix_close"):
            if col not in self._df.columns:
                raise ValueError(f"spy_vix must include column {col!r}")

        path = Path(model_path) if model_path is not None else Path(__file__).resolve().parent / DEFAULT_MODEL_FILENAME
        if not path.is_file():
            raise FileNotFoundError(f"IV model not found: {path}")
        try:
            import joblib
        except ImportError as e:
            raise ImportError("pip install joblib") from e
        self._model = joblib.load(path)
        self._r = float(risk_free_rate)
        self._strike_step = float(strike_step)
        self._m_lo = float(moneyness_low)
        self._m_hi = float(moneyness_high)
        self._spread_abs = float(spread_abs)

    def iter_chain_dates(self) -> Iterator[pd.Timestamp]:
        """All calendar dates in the SPY/VIX panel (same convention as loader dates)."""
        for d in self._df.index:
            yield _norm_day(pd.Timestamp(d))

    def get_chain_for_date(self, target_date: pd.Timestamp) -> SyntheticOptionChain:
        """
        Return a :class:`SyntheticOptionChain` with **empty** ``contracts`` and spot/VIX
        for ``target_date``. Quotes are produced in :meth:`find_target_leg` or
        :meth:`attach_position_quotes`.
        """
        ts = _norm_day(pd.Timestamp(target_date))
        if ts not in self._df.index:
            raise KeyError(f"No SPY/VIX row for {ts.date()}")
        row = self._df.loc[ts]
        spy = float(row["close"])
        vix = float(row["vix_close"])
        return SyntheticOptionChain(
            as_of=ts,
            contracts=[],
            spy_price=spy,
            vix_level=vix,
            risk_free_rate=self._r,
            model=self._model,
            strike_step=self._strike_step,
            moneyness_low=self._m_lo,
            moneyness_high=self._m_hi,
            spread_abs=self._spread_abs,
        )

    def attach_position_quotes(
        self,
        chain: SyntheticOptionChain,
        position: Any | None,
    ) -> None:
        """
        Populate ``chain.contracts`` with fresh synthetic quotes for each leg in ``position``
        (if any), using **remaining** calendar DTE and today's SPY/VIX so MTM matches
        ``find_contract_in_chain`` in the backtester.
        """
        if position is None:
            chain.contracts = []
            return

        legs = getattr(position, "legs", None)
        if not legs:
            chain.contracts = []
            return

        as_of = _norm_day(chain.as_of)
        S = float(chain.spy_price)
        vix = float(chain.vix_level)
        r = float(chain.risk_free_rate)
        model = chain.model
        sp = float(chain.spread_abs)

        out: list[OptionContract] = []
        for leg in legs:
            exp = _norm_day(leg.expiration)
            dte_rem = max(0, int((exp - as_of).days))
            if dte_rem < 1:
                # Expired: still attach intrinsic-style quote for same-day handling
                T = max(1e-6, 1.0 / 365.0)
                dte_feat = 1.0
            else:
                T = dte_rem / 365.0
                dte_feat = float(dte_rem)

            K = float(leg.strike)
            ot = str(leg.option_type).strip().upper()[:1]
            if ot not in ("C", "P"):
                ot = "P"
            is_call = 1.0 if ot == "C" else 0.0
            m = K / S
            iv_hat = float(
                np.clip(
                    model.predict(
                        pd.DataFrame(
                            {
                                "vix_level": [vix],
                                "dte": [dte_feat],
                                "moneyness": [m],
                                "is_call": [is_call],
                            }
                        )
                    )[0],
                    1e-4,
                    5.0,
                )
            )
            mid = float(synthetic_black_scholes(S, K, T, r, iv_hat, ot))
            mid = max(mid, 0.0)
            if ot == "C":
                from scipy.stats import norm

                eps = 1e-12
                Ta = max(T, eps)
                sa = max(iv_hat, eps)
                sqrt_t = math.sqrt(Ta)
                d1 = (math.log(S / K) + (r + 0.5 * sa**2) * Ta) / (sa * sqrt_t)
                de = float(norm.cdf(d1))
                d2 = d1 - sa * sqrt_t
                disc = math.exp(-r * Ta)
                pdf1 = norm.pdf(d1)
                ga = pdf1 / (S * sa * sqrt_t)
                ve = (S * pdf1 * sqrt_t) / 100.0
                th = (-S * pdf1 * sa / (2.0 * sqrt_t) - r * K * disc * norm.cdf(d2)) / 365.0
            else:
                de = _bs_put_delta(S, K, T, r, iv_hat)
                garr, tarr, varr = _vector_put_gamma_theta_vega(
                    np.array([S]), np.array([K]), np.array([T]), r, np.array([iv_hat])
                )
                ga = float(garr[0])
                th = float(tarr[0])
                ve = float(varr[0])

            bid = max(mid - sp, 0.0)
            ask = max(mid + sp, bid + 1e-4)
            out.append(
                OptionContract(
                    date=as_of,
                    expiration=exp,
                    strike=K,
                    option_type=ot,
                    bid=bid,
                    ask=ask,
                    mid=mid,
                    iv=iv_hat,
                    delta=de,
                    gamma=ga,
                    theta=th,
                    vega=ve,
                )
            )

        chain.contracts = out
