"""
Vol surface model for the Strategy Builder.

Builds a smile-by-expiry model from a real option chain slice (option_chain.py)
and projects a shocked scenario surface (spot/IV-level/skew/term shocks +
optional manual per-cell overrides from the frontend grid).
"""
import math
from typing import Any, Callable, Dict, List, Optional, Tuple

import numpy as np


class Surface:
    """Current-market smile, interpolated per expiry over log-moneyness."""

    def __init__(self, spot: float, expiries: List[Dict[str, Any]]):
        self.spot = spot
        self._tenors: List[Tuple[float, Callable[[float], float]]] = []  # (days, smile_fn)
        for exp in expiries:
            points = _smile_points(exp, spot)
            if not points:
                continue
            self._tenors.append((exp["days_to_expiry"], _build_smile_fn(points)))
        self._tenors.sort(key=lambda x: x[0])

    def iv_at(self, strike: float, days: float) -> float:
        if not self._tenors:
            return 0.20
        moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))

        if days <= self._tenors[0][0]:
            return self._tenors[0][1](moneyness)
        if days >= self._tenors[-1][0]:
            return self._tenors[-1][1](moneyness)

        for (d0, f0), (d1, f1) in zip(self._tenors, self._tenors[1:]):
            if d0 <= days <= d1:
                iv0, iv1 = f0(moneyness), f1(moneyness)
                w = (days - d0) / max(d1 - d0, 1e-6)
                return iv0 + (iv1 - iv0) * w
        return self._tenors[-1][1](moneyness)


class ScenarioSurface:
    """Shocked surface at the scenario horizon: parametric shifts + manual overrides."""

    def __init__(
        self,
        base: Surface,
        scenario_spot: float,
        iv_level_shift: float,
        skew_tilt: float,
        term_shift: float,
        manual_overrides: Optional[Dict[Tuple[int, float], float]] = None,
    ):
        self.base = base
        self.spot = scenario_spot
        self.iv_level_shift = iv_level_shift
        self.skew_tilt = skew_tilt
        self.term_shift = term_shift
        self.manual_overrides = manual_overrides or {}

    def iv_at(self, strike: float, days: float) -> float:
        override = self._match_override(strike, days)
        if override is not None:
            return override
        base_iv = self.base.iv_at(strike, days)
        moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))
        shocked = (
            base_iv
            + self.iv_level_shift
            + self.skew_tilt * moneyness
            + self.term_shift * (days / 30.0)
        )
        return max(0.01, shocked)

    def _match_override(self, strike: float, days: float) -> Optional[float]:
        if not self.manual_overrides:
            return None
        strike_pct = strike / max(self.spot, 1e-6)
        best = None
        best_dist = None
        for (o_days, o_pct), iv in self.manual_overrides.items():
            dist = abs(o_days - days) / 30.0 + abs(o_pct - strike_pct)
            if best_dist is None or dist < best_dist:
                best_dist, best = dist, iv
        # Only snap to an override if it's reasonably close to the requested cell
        if best_dist is not None and best_dist < 0.08:
            return best
        return None


def _smile_points(expiry: Dict[str, Any], spot: float) -> List[Tuple[float, float]]:
    """Average call/put IV per strike (filtering stale/zero quotes) -> [(log-moneyness, iv), ...]."""
    by_strike: Dict[float, List[float]] = {}
    for row in expiry.get("calls", []) + expiry.get("puts", []):
        if row["iv"] and row["iv"] > 0.01:
            by_strike.setdefault(row["strike"], []).append(row["iv"])
    if not by_strike:
        return []
    points = [
        (math.log(k / spot), sum(v) / len(v))
        for k, v in sorted(by_strike.items())
    ]
    return points


def _build_smile_fn(points: List[Tuple[float, float]]) -> Callable[[float], float]:
    xs = np.array([p[0] for p in points])
    ys = np.array([p[1] for p in points])

    if len(xs) >= 4:
        from scipy.interpolate import CubicSpline
        spline = CubicSpline(xs, ys, extrapolate=False)

        def fn(x: float) -> float:
            if x <= xs[0]:
                return float(ys[0])
            if x >= xs[-1]:
                return float(ys[-1])
            v = spline(x)
            return float(max(0.01, v))
        return fn

    def fn_linear(x: float) -> float:
        return float(max(0.01, np.interp(x, xs, ys)))
    return fn_linear


def build_surface(chain_slice: Dict[str, Any]) -> Surface:
    return Surface(chain_slice["spot"], chain_slice["expiries"])


def apply_scenario(
    surface: Surface,
    spot_shock_pct: float = 0.0,
    iv_level_shift: float = 0.0,
    skew_tilt: float = 0.0,
    term_shift: float = 0.0,
    manual_grid: Optional[List[Dict[str, Any]]] = None,
) -> ScenarioSurface:
    """
    manual_grid: list of {days_to_expiry, strike_pct, iv} cells edited by the user in the
    frontend grid — takes precedence over the parametric shock at nearby (days, strike_pct).
    """
    scenario_spot = surface.spot * (1 + spot_shock_pct / 100.0)
    overrides: Dict[Tuple[int, float], float] = {}
    for cell in (manual_grid or []):
        if cell.get("iv") is not None:
            overrides[(int(cell["days_to_expiry"]), float(cell["strike_pct"]))] = float(cell["iv"])
    return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_shift, overrides)
