"""Sequential bootstrapper (`fuggers_py.curves.calibration.sequential`)."""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import Iterable

import numpy as np

from fuggers_py.core.daycounts import DayCountConvention
from fuggers_py.core.types import Compounding, Date
from fuggers_py.math import SolverConfig, brent
from fuggers_py.math.errors import ConvergenceFailed, InvalidBracket

from ..curves.discrete import DiscreteCurve, ExtrapolationMethod, InterpolationMethod
from ..errors import CurveConstructionError, InvalidCurveInput
from ..value_type import ValueType, ValueTypeKind
from ..wrappers import RateCurve
from .instruments import CalibrationInstrument, InstrumentSet


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


def _float(value: object) -> float:
    if isinstance(value, Decimal):
        return float(value)
    return float(value)


def _tenor_years(reference_date: Date, date: Date, *, day_count: DayCountConvention) -> float:
    dc = day_count.to_day_count()
    return float(dc.year_fraction(reference_date, date))


@dataclass(frozen=True, slots=True)
class CalibrationInstrumentResult:
    instrument: CalibrationInstrument
    market_quote: Decimal
    model_quote: Decimal
    residual: Decimal


@dataclass(frozen=True, slots=True)
class CalibrationResult:
    curve: RateCurve
    instrument_results: list[CalibrationInstrumentResult]

    @property
    def max_abs_error(self) -> Decimal:
        if not self.instrument_results:
            return Decimal(0)
        return max(abs(item.residual) for item in self.instrument_results)


@dataclass(frozen=True, slots=True)
class SequentialBootstrapper:
    reference_date: Date
    instruments: InstrumentSet
    value_type_kind: ValueTypeKind = ValueTypeKind.DISCOUNT_FACTOR
    interpolation_method: InterpolationMethod | None = None
    extrapolation_method: ExtrapolationMethod = ExtrapolationMethod.FLAT
    curve_day_count: DayCountConvention = DayCountConvention.ACT_365_FIXED
    zero_compounding: Compounding = Compounding.CONTINUOUS
    solver_config: SolverConfig = SolverConfig(tolerance=1e-12, max_iterations=200)

    def _value_type(self) -> ValueType:
        if self.value_type_kind is ValueTypeKind.DISCOUNT_FACTOR:
            return ValueType.discount_factor()
        if self.value_type_kind is ValueTypeKind.ZERO_RATE:
            return ValueType.zero_rate(self.zero_compounding, self.curve_day_count)
        raise InvalidCurveInput(f"Unsupported calibration value type: {self.value_type_kind}.")

    def _default_interpolation(self) -> InterpolationMethod:
        if self.value_type_kind is ValueTypeKind.DISCOUNT_FACTOR:
            return InterpolationMethod.LOG_LINEAR
        return InterpolationMethod.LINEAR

    def _anchor_pillar(self) -> tuple[float, float]:
        if self.value_type_kind is ValueTypeKind.DISCOUNT_FACTOR:
            return 0.0, 1.0
        return 0.0, 0.0

    def _build_curve(self, tenors: Iterable[float], values: Iterable[float]) -> RateCurve:
        t_list = list(tenors)
        v_list = list(values)
        if not t_list:
            raise CurveConstructionError("Cannot build a curve with zero pillars.")

        # Ensure anchor pillar at t=0 for numerical stability / required length.
        anchor_t, anchor_v = self._anchor_pillar()
        if t_list[0] > 0.0:
            t_list = [anchor_t] + t_list
            v_list = [anchor_v] + v_list
        elif t_list[0] < 0.0:
            raise InvalidCurveInput("Curve pillars must be non-negative in tenor.")
        elif abs(t_list[0]) < 1e-12 and len(t_list) == 1:
            # Need at least 2 pillars; append a tiny stub for interpolation.
            t_list = [anchor_t, max(1e-6, t_list[0] + 1e-6)]
            v_list = [anchor_v, v_list[0]]

        interpolation = self.interpolation_method or self._default_interpolation()
        curve = DiscreteCurve(
            self.reference_date,
            t_list,
            v_list,
            value_type=self._value_type(),
            interpolation_method=interpolation,
            extrapolation_method=self.extrapolation_method,
        )
        return RateCurve(curve)

    def _residual_fn(
        self,
        instrument: CalibrationInstrument,
        tenors: list[float],
        values: list[float],
        tenor: float,
    ):
        quote = _float(instrument.quote)

        def residual(x: float) -> float:
            curve = self._build_curve(tenors + [float(tenor)], values + [float(x)])
            model = _float(instrument.par_rate(curve))
            return model - quote

        return residual

    def _find_bracket(self, residual, grid: list[float]) -> tuple[float, float] | None:
        prev_x = None
        prev_val = None
        for x in grid:
            try:
                val = residual(x)
            except Exception:
                prev_x = None
                prev_val = None
                continue
            if not np.isfinite(val):
                prev_x = None
                prev_val = None
                continue
            if val == 0.0:
                return x, x
            if prev_x is not None and prev_val is not None and val * prev_val < 0:
                return prev_x, x
            prev_x, prev_val = x, val
        return None

    def _solve_for_pillar(self, residual) -> float:
        if self.value_type_kind is ValueTypeKind.DISCOUNT_FACTOR:
            grid = [1e-6, 0.01, 0.1, 0.5, 0.9, 1.1, 1.5, 2.0]
        else:
            grid = [-0.05, -0.01, 0.0, 0.01, 0.03, 0.05, 0.1, 0.2, 0.5]

        bracket = self._find_bracket(residual, grid)
        if bracket is None:
            raise CurveConstructionError("Unable to bracket bootstrap root for calibration instrument.")

        a, b = bracket
        if a == b:
            return float(a)
        try:
            result = brent(residual, float(a), float(b), config=self.solver_config)
        except (InvalidBracket, ConvergenceFailed) as exc:
            raise CurveConstructionError("Bootstrap root solve failed to converge.") from exc
        return float(result.root)

    def bootstrap(self) -> CalibrationResult:
        if not self.instruments.instruments:
            raise InvalidCurveInput("SequentialBootstrapper requires a non-empty InstrumentSet.")

        tenors: list[float] = []
        values: list[float] = []

        for instrument in self.instruments.instruments:
            maturity = instrument.maturity_date()
            tenor = _tenor_years(self.reference_date, maturity, day_count=self.curve_day_count)
            if tenor <= 0.0:
                raise InvalidCurveInput("Instrument maturity must be after reference date.")

            residual = self._residual_fn(instrument, tenors, values, tenor)
            solved = self._solve_for_pillar(residual)
            tenors.append(float(tenor))
            values.append(float(solved))

        curve = self._build_curve(tenors, values)
        results: list[CalibrationInstrumentResult] = []
        for instrument in self.instruments.instruments:
            model = _to_decimal(instrument.par_rate(curve), field_name="model_quote")
            market = _to_decimal(instrument.quote, field_name="market_quote")
            residual = model - market
            results.append(
                CalibrationInstrumentResult(
                    instrument=instrument,
                    market_quote=market,
                    model_quote=model,
                    residual=residual,
                )
            )

        return CalibrationResult(curve=curve, instrument_results=results)


__all__ = [
    "CalibrationInstrumentResult",
    "CalibrationResult",
    "SequentialBootstrapper",
]
