"""Fitted bond-curve models and immutable result records.

This module defines the fitted-bond research surface: observations are supplied
as clean prices in percent of par, curve values are converted to dirty prices
for the fit, and regression adjustments are added on top of the curve-implied
dirty price. Positive price residuals indicate a bond is rich to the fit,
while positive bp residuals indicate the observed yield is above the fitted
yield.
"""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Protocol

import numpy as np
from numpy.typing import NDArray

from fuggers_py.bonds.pricing import BondPricer
from fuggers_py.bonds.traits import Bond
from fuggers_py.core.daycounts import DayCountConvention
from fuggers_py.core.types import Date, Price
from fuggers_py.curves.errors import InvalidCurveInput
from fuggers_py.curves.term_structure import TermStructure
from fuggers_py.curves.value_type import ValueType
from fuggers_py.curves.wrappers import RateCurve
from fuggers_py.data.ids import InstrumentId
from fuggers_py.data.reference_data import BondReferenceData
from fuggers_py.math.interpolation import CubicSpline

from .regression import RegressionExposure


def _to_decimal(value: object) -> Decimal:
    if isinstance(value, Decimal):
        return value
    return Decimal(str(value))


def _decimal_tuple(values: NDArray[np.float64] | tuple[float, ...] | list[float]) -> tuple[Decimal, ...]:
    return tuple(Decimal(str(float(value))) for value in values)


class FittedBondCurveFamily(str, Enum):
    """Supported fitted-bond curve families."""

    EXPONENTIAL_SPLINE = "EXPONENTIAL_SPLINE"
    CUBIC_SPLINE_DISCOUNT_FACTOR = "CUBIC_SPLINE_DISCOUNT_FACTOR"


class FittedBondObjective(str, Enum):
    """Objective families used by the fitted-bond optimizer."""

    L1 = "L1"
    L2 = "L2"


@dataclass(frozen=True, slots=True)
class RegressionCoefficient:
    """Regression coefficient returned by a fitted-bond run."""

    name: str
    value: Decimal

    def __post_init__(self) -> None:
        object.__setattr__(self, "name", self.name.strip().lower())
        object.__setattr__(self, "value", _to_decimal(self.value))


@dataclass(frozen=True, slots=True)
class FittedBondObservation:
    """Single bond observation used in fitted-bond calibration.

    Parameters
    ----------
    clean_price:
        Clean price quoted in percent of par.
    settlement_date:
        Economic settlement date used for accrued interest and yield
        calculations.
    weight:
        Optional positive observation weight.
    regression_exposures:
        Optional additive regression exposures attached to the bond.
    reference_data:
        Optional metadata used by the regression design matrix.
    """

    instrument_id: InstrumentId
    bond: Bond
    clean_price: Decimal
    settlement_date: Date
    weight: Decimal | None = None
    regression_exposures: tuple[RegressionExposure, ...] = ()
    reference_data: BondReferenceData | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "instrument_id", InstrumentId.parse(self.instrument_id))
        object.__setattr__(self, "clean_price", _to_decimal(self.clean_price))
        if self.weight is not None:
            object.__setattr__(self, "weight", _to_decimal(self.weight))
        if self.clean_price <= Decimal(0):
            raise ValueError("FittedBondObservation clean_price must be positive.")
        if self.weight is not None and self.weight <= Decimal(0):
            raise ValueError("FittedBondObservation weight must be positive when supplied.")
        if self.reference_data is not None and self.reference_data.instrument_id != self.instrument_id:
            raise ValueError("FittedBondObservation reference_data instrument_id must match instrument_id.")

    def observed_dirty_price(self) -> Decimal:
        """Return the observed dirty price in percent of par."""
        return self.clean_price + self.bond.accrued_interest(self.settlement_date)

    def maturity_date(self) -> Date:
        """Return the bond maturity date."""
        return self.bond.maturity_date()

    def maturity_years(self) -> Decimal:
        """Return the ACT/365F maturity in year fractions from settlement."""
        return Decimal(str(DayCountConvention.ACT_365_FIXED.to_day_count().year_fraction(self.settlement_date, self.maturity_date())))

    def coupon_rate(self) -> Decimal | None:
        """Return the bond coupon rate when the bond exposes one."""
        coupon_rate = getattr(self.bond, "coupon_rate", None)
        if callable(coupon_rate):
            return _to_decimal(coupon_rate())
        return None

    def observed_yield(self, *, pricer: BondPricer | None = None) -> Decimal:
        """Return the observed yield to maturity from the clean price."""
        resolved_pricer = pricer or BondPricer()
        result = resolved_pricer.yield_from_price(
            self.bond,
            Price.new(self.clean_price, self.bond.currency()),
            self.settlement_date,
        )
        return result.ytm.value()


@dataclass(frozen=True, slots=True)
class FittedBondPointResult:
    """Per-bond fitted result record.

    The fitted and fair values are dirty prices in percent of par. A positive
    ``price_residual`` means the observed dirty price is above fair value. A
    positive ``bp_residual`` means the observed yield is higher than the fitted
    yield, which is the convention used when ranking cheap bonds.
    """

    instrument_id: InstrumentId
    bond: Bond
    settlement_date: Date
    maturity_date: Date
    maturity_years: Decimal
    coupon_rate: Decimal | None
    weight: Decimal
    observed_clean_price: Decimal
    observed_dirty_price: Decimal
    observed_yield: Decimal
    curve_clean_price: Decimal
    curve_dirty_price: Decimal
    fitted_clean_price: Decimal
    fitted_dirty_price: Decimal
    fitted_yield: Decimal
    fair_value_clean_price: Decimal
    fair_value_dirty_price: Decimal
    regression_adjustment: Decimal
    price_residual: Decimal
    bp_residual: Decimal
    reference_data: BondReferenceData | None = None


@dataclass(frozen=True, slots=True)
class FittedBondDiagnostics:
    """Summary diagnostics for a fitted-bond optimization."""

    objective_value: Decimal
    iterations: int
    converged: bool
    observation_count: int
    curve_parameter_count: int
    regression_parameter_count: int
    weighted_rmse_price: Decimal
    weighted_mae_price: Decimal
    max_abs_price_residual: Decimal
    weighted_mean_abs_bp_residual: Decimal
    max_abs_bp_residual: Decimal


@dataclass(frozen=True, slots=True)
class FittedBondFitResult:
    """Immutable fitted-bond result bundle."""

    reference_date: Date
    curve_family: FittedBondCurveFamily
    objective: FittedBondObjective
    metadata_fields: tuple[str, ...]
    curve: RateCurve
    curve_parameter_names: tuple[str, ...]
    curve_parameters: tuple[Decimal, ...]
    coefficients: tuple[RegressionCoefficient, ...]
    bonds: tuple[FittedBondPointResult, ...]
    diagnostics: FittedBondDiagnostics

    def get_bond(self, instrument_id: InstrumentId | str) -> FittedBondPointResult:
        """Return the per-bond result for ``instrument_id``."""
        resolved = InstrumentId.parse(instrument_id)
        for result in self.bonds:
            if result.instrument_id == resolved:
                return result
        raise KeyError(f"Unknown fitted bond result: {resolved}.")

    def coefficient_map(self) -> dict[str, Decimal]:
        """Return regression coefficients keyed by normalized exposure name."""
        return {coefficient.name: coefficient.value for coefficient in self.coefficients}

    def richest(self) -> FittedBondPointResult:
        """Return the bond with the largest positive price residual."""
        return max(self.bonds, key=lambda item: (item.price_residual, item.instrument_id.as_str()))

    def cheapest(self) -> FittedBondPointResult:
        """Return the bond with the largest positive bp residual."""
        return max(self.bonds, key=lambda item: (item.bp_residual, item.instrument_id.as_str()))


class FittedBondCurveModel(Protocol):
    """Protocol for fitted-bond curve model families."""

    family: FittedBondCurveFamily

    def parameter_names(self) -> tuple[str, ...]:
        ...

    def initial_parameters(self, *, observed_yields: NDArray[np.float64], max_t: float) -> NDArray[np.float64]:
        ...

    def build_curve(self, reference_date: Date, parameters: NDArray[np.float64], *, max_t: float) -> RateCurve:
        ...


@dataclass(frozen=True, slots=True)
class ExponentialSplineDiscountCurve(TermStructure):
    """Discount-factor curve implied by an exponential spline in zero rates."""

    _reference_date: Date
    coefficients: NDArray[np.float64]
    decay_factors: NDArray[np.float64]
    max_t: float

    def reference_date(self) -> Date:  # type: ignore[override]
        """Return the reference date."""
        return self._reference_date

    def value_type(self) -> ValueType:
        """Return discount-factor semantics."""
        return ValueType.discount_factor()

    def tenor_bounds(self) -> tuple[float, float]:
        """Return the supported tenor range."""
        return (0.0, float(self.max_t))

    def value_at(self, t: float) -> float:
        """Return the discount factor at tenor ``t``."""
        tenor = float(t)
        if tenor <= 0.0:
            return 1.0
        basis = np.concatenate(([1.0], np.exp(-self.decay_factors * tenor)))
        zero_rate = float(np.dot(self.coefficients, basis))
        return float(np.exp(np.clip(-tenor * zero_rate, -700.0, 700.0)))

    def max_date(self) -> Date:
        """Return the maximum date implied by ``max_t``."""
        return self.tenor_to_date(float(self.max_t))


@dataclass(frozen=True, slots=True)
class CubicSplineDiscountFactorCurve(TermStructure):
    """Discount-factor curve implied by a spline on log discount factors."""

    _reference_date: Date
    knot_tenors: NDArray[np.float64]
    log_discount_factors: NDArray[np.float64]
    _spline: CubicSpline
    max_t: float

    def reference_date(self) -> Date:  # type: ignore[override]
        """Return the reference date."""
        return self._reference_date

    def value_type(self) -> ValueType:
        """Return discount-factor semantics."""
        return ValueType.discount_factor()

    def tenor_bounds(self) -> tuple[float, float]:
        """Return the supported tenor range."""
        return (0.0, float(self.max_t))

    def value_at(self, t: float) -> float:
        """Return the discount factor at tenor ``t``."""
        tenor = float(t)
        if tenor <= 0.0:
            return 1.0
        log_df = float(self._spline.interpolate(tenor))
        return float(np.exp(np.clip(log_df, -700.0, 0.0)))

    def max_date(self) -> Date:
        """Return the maximum date implied by ``max_t``."""
        return self.tenor_to_date(float(self.max_t))


@dataclass(frozen=True, slots=True)
class ExponentialSplineCurveModel:
    """Exponential-spline fitted-bond curve model."""

    decay_factors: tuple[Decimal, ...] = (Decimal("0.50"), Decimal("1.50"))
    family: FittedBondCurveFamily = FittedBondCurveFamily.EXPONENTIAL_SPLINE

    def __post_init__(self) -> None:
        normalized = tuple(_to_decimal(value) for value in self.decay_factors)
        if not normalized:
            raise ValueError("ExponentialSplineCurveModel requires at least one decay factor.")
        if any(value <= Decimal(0) for value in normalized):
            raise ValueError("ExponentialSplineCurveModel decay factors must be positive.")
        object.__setattr__(self, "decay_factors", normalized)

    def parameter_names(self) -> tuple[str, ...]:
        """Return the ordered parameter names for the spline coefficients."""
        return ("beta0",) + tuple(f"beta_exp_{index}" for index in range(1, len(self.decay_factors) + 1))

    def initial_parameters(self, *, observed_yields: NDArray[np.float64], max_t: float) -> NDArray[np.float64]:
        """Return a simple starting point derived from the observed yields."""
        if observed_yields.size == 0:
            level = 0.03
        else:
            level = float(np.median(observed_yields))
        initial = np.zeros(len(self.decay_factors) + 1, dtype=float)
        initial[0] = level
        return initial

    def build_curve(self, reference_date: Date, parameters: NDArray[np.float64], *, max_t: float) -> RateCurve:
        """Build the fitted discount-factor curve for the supplied parameters."""
        if parameters.size != len(self.decay_factors) + 1:
            raise InvalidCurveInput("Exponential spline parameter length does not match the decay-factor configuration.")
        return RateCurve(
            ExponentialSplineDiscountCurve(
                _reference_date=reference_date,
                coefficients=np.asarray(parameters, dtype=float),
                decay_factors=np.asarray([float(value) for value in self.decay_factors], dtype=float),
                max_t=float(max_t),
            )
        )


@dataclass(frozen=True, slots=True)
class CubicSplineDiscountFactorCurveModel:
    """Cubic-spline fitted-bond curve model on log discount factors."""

    knot_tenors: tuple[Decimal, ...]
    family: FittedBondCurveFamily = FittedBondCurveFamily.CUBIC_SPLINE_DISCOUNT_FACTOR

    def __post_init__(self) -> None:
        normalized = tuple(sorted(_to_decimal(value) for value in self.knot_tenors))
        if len(normalized) < 2:
            raise ValueError("CubicSplineDiscountFactorCurveModel requires at least two positive knot tenors.")
        if normalized[0] <= Decimal(0):
            raise ValueError("Cubic spline knot tenors must be strictly positive.")
        if len(set(normalized)) != len(normalized):
            raise ValueError("Cubic spline knot tenors must be unique.")
        object.__setattr__(self, "knot_tenors", normalized)

    def parameter_names(self) -> tuple[str, ...]:
        """Return the ordered parameter names for the knot log-discount factors."""
        return tuple(f"log_df_{float(knot):.4f}y" for knot in self.knot_tenors)

    def initial_parameters(self, *, observed_yields: NDArray[np.float64], max_t: float) -> NDArray[np.float64]:
        """Return a starting log-discount vector derived from the median yield."""
        if float(max_t) > float(self.knot_tenors[-1]) + 0.25:
            raise InvalidCurveInput("Cubic spline knot_tenors must extend to the maximum fitted maturity.")
        if observed_yields.size == 0:
            level = 0.03
        else:
            level = float(np.median(observed_yields))
        return np.asarray([-level * float(knot) for knot in self.knot_tenors], dtype=float)

    def build_curve(self, reference_date: Date, parameters: NDArray[np.float64], *, max_t: float) -> RateCurve:
        """Build the fitted cubic-spline discount-factor curve."""
        if parameters.size != len(self.knot_tenors):
            raise InvalidCurveInput("Cubic spline parameter length does not match knot_tenors.")
        if float(max_t) > float(self.knot_tenors[-1]) + 0.25:
            raise InvalidCurveInput("Cubic spline knot_tenors must extend to the maximum fitted maturity.")
        knot_array = np.asarray([0.0] + [float(knot) for knot in self.knot_tenors], dtype=float)
        log_df_values = np.concatenate(([0.0], np.asarray(parameters, dtype=float)))
        support_max = max(float(max_t), float(self.knot_tenors[-1]))
        return RateCurve(
            CubicSplineDiscountFactorCurve(
                _reference_date=reference_date,
                knot_tenors=np.asarray([float(knot) for knot in self.knot_tenors], dtype=float),
                log_discount_factors=np.asarray(parameters, dtype=float),
                _spline=CubicSpline(knot_array, log_df_values, allow_extrapolation=True),
                max_t=support_max,
            )
        )


__all__ = [
    "CubicSplineDiscountFactorCurveModel",
    "CubicSplineDiscountFactorCurve",
    "ExponentialSplineCurveModel",
    "ExponentialSplineDiscountCurve",
    "FittedBondCurveFamily",
    "FittedBondCurveModel",
    "FittedBondDiagnostics",
    "FittedBondFitResult",
    "FittedBondObservation",
    "FittedBondObjective",
    "FittedBondPointResult",
    "RegressionCoefficient",
]
