"""Tools for dealing with bookmaker's odds and implied probabilities"""

from fractions import Fraction
from math import sqrt
from typing import Optional, Sequence

import scipy.optimize
import toolz


def overround(p: Sequence[float]) -> float:
    """Return the overround, given a sequence of implied probabilities."""
    return sum(p) - 1


def from_american(odds: int) -> float:
    """Return implied probability, given American odds."""
    odds = round(odds)
    if abs(odds) < 100:
        raise ValueError(f"Invalid American odds {odds}")
    if odds > 0:
        return 100 / (100 + odds)
    else:
        odds = -odds
        return odds / (100 + odds)


def to_american(p: float, ndigits: Optional[int] = None) -> int:
    """Return American odds, given an implied probability.

    Parameters
    ----------
    p
        Implied probability
    ndigits
        The number of digits to which to round the result. The default
        is to round to the nearest integer (American odds are not usually
        quoted to more decimal places), but this may be adjusted if
        more precision is needed (and the result will be a floating
        point number).
    """
    if p < 0.5:
        a = 100 / p - 100
    else:
        a = -(100 * p) / (1 - p)
    return round(a, ndigits)


def from_euro(odds: float) -> float:
    """Return implied probability, given Euro/Decimal odds."""
    return 1 / odds


def to_euro(p: float) -> float:
    """Return Euro/Decimal odds, given an implied probability."""
    return 1 / p


def from_british(odds: Fraction) -> float:
    """Return implied probability, given British odds."""
    return float(1 / (odds + 1))


def to_british(p: float, largest_denominator: int = 100) -> Fraction:
    """Return British odds, given an implied probability.

    Parameters
    ----------
    p
        Implied probability
    largest_denominator
        The largest denominator to use when representing the result.
        The smaller this number the more accuracy is lost.
    """
    return Fraction(1 / p - 1).limit_denominator(largest_denominator)


from_decimal = from_euro
to_decimal = to_euro


# -- Adjusting implied probabilities to true probabilities

_adjust_methods = {}


@toolz.curry
def add_method(name: str, f):
    _adjust_methods[name] = f
    return f


def validate_odds(odds: Sequence[float]) -> None:
    if len(odds) < 2:
        raise ValueError(f"Expected at least 2 outcomes, but got {len(odds)}")
    if any(x >= 1 for x in odds):
        raise ValueError(f"Expected all outcomes to have an implied probability < 1")
    r = overround(odds)
    if r < 0:
        raise ValueError(f"Odds {odds} have a negative overround ({r})")
    elif r > 0.5:
        raise ValueError(f"Odds {odds} have excessive overround ({r})")


def adjust_odds(odds: Sequence[float], method="power") -> list[float]:
    """Return fair odds given bookmaker odds.

    Parameters
    ----------
    odds
        The odds of each outcome, given as implied probabilites.

    The implied probabilities associated with the odds quoted by a bookmaker
    sum to a number > 1. This excess is the "overround" of the odds. There
    are a number of models for distributing the overround among the outcomes
    to recover the "fair odds" of each outcome.

    Notes
    -----
    All implemented methods are based on the descriptions in the paper by
    S. Clarke in the references section.

    References
    ----------
    S. Clarke, S. Kovalchik, M. Ingram
        Adjusting Bookmaker's odds to Allow for Overround.
    """
    try:
        f = _adjust_methods[method]
    except KeyError:
        raise ValueError(
            f"Unknown method '{method}', expected one of {list(_adjust_methods)}"
        ) from None
    validate_odds(odds)
    return f(odds)


@add_method("additive")
def adjust_odds_additive(odds: Sequence[float]) -> list[float]:
    """Adjust bookmaker odds using the additive method.

    In this method the overround is assumed to be split equality between
    the different outcomes, regardless of their relative implied probabilities.
    """
    y = overround(odds) / len(odds)
    return [x - y for x in odds]


@add_method("multiplicative")
def adjust_odds_multiplicative(odds: Sequence[float]) -> list[float]:
    """Adjust bookmaker odds using the multiplicative method.

    In this method the overround is assumed to be split among the
    different outcomes in proportion to their implied probabilities.
    """
    s = sum(odds)
    r = overround(odds)
    return [x - (r * x / s) for x in odds]


@add_method("power")
def adjust_odds_power(odds: Sequence[float]) -> list[float]:
    """Adjust bookmaker odds using the power method.

    In this method the true probabilities are assumed to be
    related to the implied probabilities by a power law,
    i.e. p_i = π_i^k, for some fixed k. The k is then
    chosen such that the true probabilities sum to 1.

    Notes
    -----
    The 'k' parameter for the power method is computed by
    solving the nonlinear equation Σ_i π_i^k = 1 using
    a root-finding algorithm.

    References
    ----------
    S.R. Clarke
        Adjusting true odds to allow for vigorish
    """
    # This bracketing interval should be sufficient for reasonable overrounds
    # (not > 50%) and reasonable distribution of implied probability between
    # the different outcomes.
    try:
        k = scipy.optimize.root_scalar(
            lambda k: sum(x ** k for x in odds) - 1,
            bracket=[0.9, 3],
            xtol=1e-14,
        ).root
    except ValueError as e:
        if "different signs" in str(e):
            raise ValueError(f"Bracketing interval too small for odds {odds}") from None

    return [x ** k for x in odds]


@add_method("shin")
def adjust_odds_shin(odds):
    """Adjust bookmaker odds using the Shin method.

    In this method we assume a fraction z of sharp bettors
    and adjust the true probabilities accordingly.

    Notes
    -----
    The 'z' parameter for the Shin method is computed
    by solving a nonlinear equation using a root-finding algorithm.

    References
    ----------
    H.S. Shin
        Prices of State Contingent Claims with Insider Traders
        and the Favorite-Longshot Bias
    """
    s = sum(odds)
    n = len(odds)

    def residual(z):
        return (
            sum(sqrt(z ** 2 + 4 * (1 - z) * pi ** 2 / s) for pi in odds)
            - 2
            - (n - 2) * z
        )

    # 'z' is in the interval [0, 1). We set the upper limit for the
    # bracketing interval to just below 1 to exclude a (spurious) solution at z=1.
    try:
        z = scipy.optimize.root_scalar(residual, bracket=[0, 0.999], xtol=1e-14).root
    except ValueError:
        raise ValueError(f"Shin method failed to converge for odds {odds}") from None

    return [
        (sqrt(z ** 2 + 4 * (1 - z) * pi ** 2 / s) - z) / (2 * (1 - z)) for pi in odds
    ]