"""Bond-layer yield helpers shared by bonds and analytics."""

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from math import exp

from fuggers_py.math import SolverConfig, brent, newton_raphson
from fuggers_py.math.errors import ConvergenceFailed, DivisionByZero, InvalidBracket

from .errors import BondPricingError, YieldConvergenceFailed
from .types import YieldConvention


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


def current_yield(coupon_rate: object, clean_price: object) -> Decimal:
    price = _to_decimal(clean_price)
    if price <= 0:
        raise BondPricingError(reason="Clean price must be positive for current yield.")
    rate = _to_decimal(coupon_rate)
    annual_coupon = rate * Decimal(100)
    return (annual_coupon / price) * Decimal(100)


def current_yield_from_amount(coupon_amount: object, clean_price: object) -> Decimal:
    price = _to_decimal(clean_price)
    if price <= 0:
        raise BondPricingError(reason="Clean price must be positive for current yield.")
    amount = _to_decimal(coupon_amount)
    return (amount / price) * Decimal(100)


def current_yield_from_bond(bond: object, clean_price: object) -> Decimal:
    if hasattr(bond, "coupon_rate"):
        rate = bond.coupon_rate() if callable(bond.coupon_rate) else bond.coupon_rate
    else:
        raise BondPricingError(reason="Bond does not expose coupon_rate for current yield.")

    if hasattr(bond, "notional"):
        notional = bond.notional() if callable(bond.notional) else bond.notional
    else:
        notional = Decimal(100)

    coupon_amount = _to_decimal(rate) * _to_decimal(notional)
    return current_yield_from_amount(coupon_amount, clean_price)


def current_yield_simple(coupon_rate: float, clean_price: float) -> float:
    if clean_price <= 0:
        raise BondPricingError(reason="Clean price must be positive for current yield.")
    return float(coupon_rate) * 100.0 / float(clean_price) * 100.0


@dataclass(frozen=True, slots=True)
class YieldResult:
    yield_value: float
    iterations: int
    residual: float
    method: str
    convention: YieldConvention
    converged: bool = True

    def yield_percent(self) -> float:
        return float(self.yield_value) * 100.0

    def yield_decimal(self) -> float:
        return float(self.yield_value)


@dataclass(frozen=True, slots=True)
class YieldSolver:
    tolerance: float = 1e-10
    max_iterations: int = 100

    def _discount_factor(self, y: float, t: float, *, convention: YieldConvention, frequency: int) -> float:
        tt = float(t)
        if tt == 0.0:
            return 1.0
        if convention in {
            YieldConvention.STREET_CONVENTION,
            YieldConvention.TRUE_YIELD,
            YieldConvention.BOND_EQUIVALENT_YIELD,
            YieldConvention.ANNUAL,
        }:
            f = float(max(1, frequency))
            return float((1.0 + y / f) ** (-tt * f))
        if convention is YieldConvention.CONTINUOUS:
            return exp(-y * tt)
        if convention in {YieldConvention.SIMPLE_YIELD, YieldConvention.DISCOUNT_YIELD}:
            return 1.0 / (1.0 + y * tt)
        f = float(max(1, frequency))
        return float((1.0 + y / f) ** (-tt * f))

    def _discount_factor_derivative(self, y: float, t: float, *, convention: YieldConvention, frequency: int) -> float:
        tt = float(t)
        if tt == 0.0:
            return 0.0
        df = self._discount_factor(y, tt, convention=convention, frequency=frequency)
        if convention in {
            YieldConvention.STREET_CONVENTION,
            YieldConvention.TRUE_YIELD,
            YieldConvention.BOND_EQUIVALENT_YIELD,
            YieldConvention.ANNUAL,
        }:
            f = float(max(1, frequency))
            return -tt * df / (1.0 + y / f)
        if convention is YieldConvention.CONTINUOUS:
            return -tt * df
        if convention in {YieldConvention.SIMPLE_YIELD, YieldConvention.DISCOUNT_YIELD}:
            return -tt / ((1.0 + y * tt) ** 2)
        f = float(max(1, frequency))
        return -tt * df / (1.0 + y / f)

    def solve(
        self,
        *,
        dirty_price: float,
        cashflows: list[float],
        times: list[float],
        frequency: int,
        convention: YieldConvention = YieldConvention.STREET_CONVENTION,
        initial_guess: float | None = None,
    ) -> YieldResult:
        if len(cashflows) != len(times):
            raise BondPricingError(reason="cashflows and times length mismatch.")
        if not cashflows:
            raise BondPricingError(reason="No cashflows supplied to yield solver.")

        target = float(dirty_price)

        def pv_at_yield(y: float) -> float:
            return sum(
                cf * self._discount_factor(y, t, convention=convention, frequency=frequency)
                for cf, t in zip(cashflows, times, strict=True)
            )

        def pv_derivative(y: float) -> float:
            return sum(
                cf * self._discount_factor_derivative(y, t, convention=convention, frequency=frequency)
                for cf, t in zip(cashflows, times, strict=True)
            )

        guess = (
            float(initial_guess)
            if initial_guess is not None
            else self._estimate_initial_yield(cashflows, times, target)
        )

        def objective(y: float) -> float:
            return pv_at_yield(y) - target

        config = SolverConfig(tolerance=self.tolerance, max_iterations=self.max_iterations)

        def _try_newton(g: float) -> YieldResult | None:
            try:
                res = newton_raphson(objective, pv_derivative, g, config=config)
            except (ConvergenceFailed, DivisionByZero):
                return None
            return YieldResult(
                yield_value=float(res.root),
                iterations=int(res.iterations),
                residual=float(res.residual),
                method="Newton",
                convention=convention,
                converged=bool(res.converged),
            )

        result = _try_newton(guess)
        if result is not None:
            return result

        for g in (0.01, 0.03, 0.05, 0.08, 0.10, 0.15):
            result = _try_newton(float(g))
            if result is not None:
                return result

        for a, b in [
            (guess - 0.1, guess + 0.1),
            (-0.1, 0.5),
            (-0.2, 1.0),
            (-0.5, 2.0),
        ]:
            try:
                res = brent(objective, float(a), float(b), config=config)
            except (InvalidBracket, ConvergenceFailed):
                continue
            return YieldResult(
                yield_value=float(res.root),
                iterations=int(res.iterations),
                residual=float(res.residual),
                method="Brent",
                convention=convention,
                converged=bool(res.converged),
            )

        raise YieldConvergenceFailed(iterations=self.max_iterations, residual=abs(float(objective(guess))))

    @staticmethod
    def _estimate_initial_yield(cashflows: list[float], times: list[float], dirty_price: float) -> float:
        if not cashflows:
            return 0.05
        years_to_maturity = max(times[-1], 1e-12)
        face_value = min(cashflows[-1], 100.0)
        total_amount = sum(cashflows)
        annual_coupon = (total_amount - face_value) / years_to_maturity
        if dirty_price > 0.0:
            guess = annual_coupon / dirty_price
            return max(min(guess, 1.0), -0.5)
        return 0.05


__all__ = [
    "YieldResult",
    "YieldSolver",
    "current_yield",
    "current_yield_from_amount",
    "current_yield_from_bond",
    "current_yield_simple",
]
