import errors as errors
import math

## @brief Adds two numbers.
# @param a The first operand.
# @param b The second operand.
# @return The sum of a and b.
def add(a: float, b: float) -> float:
    return a + b

## @brief Multiplies two numbers.
# @param a The first operand.
# @param b The second operand.
# @return The product of a and b.
def multiply(a: float, b: float) -> float:
    return a * b

## @brief Divides two numbers.
# @param a The dividend.
# @param b The divisor.
# @return The quotient of a divided by b.
# @throws errors.ZeroDivisionMathError If the divisor is zero.
def divide(a: float, b: float) -> float:
    if b == 0.0:
        raise errors.ZeroDivisionMathError("Cannot divide by zero")
    return a / b

## @brief Subtracts the second number from the first.
# @param a The minuend.
# @param b The subtrahend.
# @return The difference between a and b.
def subtract(a: float, b: float) -> float:
    return a - b

## @brief Computes the factorial of a non-negative integer.
# @param n The integer to compute the factorial for.
# @return The factorial of n (n!).
# @throws TypeError If n is not an integer.
# @throws errors.DomainError If n is negative.
# @note Replaced tail-recursion with an iterative loop to prevent stack overflow in Python.
def factorial(n: int) -> int:
    if not isinstance(n, int):
        raise TypeError("factorial: n must be int")
    if n < 0:
        raise errors.DomainError("factorial: n must be non-negative")
    result = 1
    for i in range(2, n + 1):
        result *= i
    return result

## @brief Computes the exponential function e^x using a Taylor series expansion.
# @param x The exponent.
# @param eps Convergence tolerance. Iteration stops when the absolute term value is below this threshold.
# @param max_iter Maximum allowed iterations to prevent infinite loops.
# @return Approximated value of e^x.
# @throws errors.ConvergenceError If the series does not converge within the iteration limit.
def exp(x: float, eps: float = 1e-12, max_iter: int = 100000) -> float:
    term = 1.0
    x_next = 1.0
    iteration = 1
    while abs(term) > eps:
        term *= x / iteration
        x_next += term
        iteration += 1
        if iteration > max_iter:
            raise errors.ConvergenceError("Failed to converge within the maximum number of iterations")
    return x_next

## @brief Computes base raised to the power of exponent.
# @param base The base number.
# @param exponent The power to raise the base to.
# @param eps Convergence tolerance for internal exponential/logarithmic calls.
# @param max_iter Maximum iterations for internal computations.
# @return The value of base^exponent.
# @throws errors.DomainError For undefined cases (0^0, 0^negative, negative base with fractional exponent).
def pow(base: float, exponent: float, eps: float = 1e-12, max_iter: int = 100000) -> float:
    if base == 0.0 and exponent == 0.0:
        raise errors.DomainError("pow: 0^0 is undefined")
    if base == 0.0 and exponent < 0.0:
        raise errors.DomainError("pow: 0 cannot be raised to a negative power")
    if base < 0.0 and not float(exponent).is_integer():
        raise errors.DomainError("pow: negative base with non-integer exponent is not supported")
    if exponent == 0.0:
        return 1.0
    if float(exponent).is_integer():
        exp_int = int(exponent)
        if exp_int < 0:
            return 1.0 / pow(base, -exp_int, eps, max_iter)
        x_next = 1.0
        for _ in range(exp_int):
            x_next *= base
        return x_next
    return exp(exponent * ln(base, eps, max_iter), eps, max_iter)

## @brief Computes the n-th root of x using Newton's method.
# @param x The radicand.
# @param n The root degree.
# @param eps Convergence tolerance for the iterative method.
# @param max_iter Maximum allowed iterations.
# @return The n-th root of x.
# @throws errors.DomainError If n is zero, x is negative with an even/ fractional root, or n is non-numeric.
# @throws errors.ConvergenceError If the method fails to converge or encounters division by zero.
def root_n(x: float, n: float, eps: float = 1e-10, max_iter: int = 100000) -> float:
    if not isinstance(n, (int, float)):
        raise errors.DomainError("n must be numeric")
    if n == 0.0:
        raise errors.DomainError("n cannot be zero")
    if x == 0.0:
        return 0.0
    if x < 0.0:
        if not float(n).is_integer():
            raise errors.DomainError("Fractional root of negative number is not real")
        if int(n) % 2 == 0:
            raise errors.DomainError("Cannot compute even root of a negative number")
    guess = abs(x) ** (1.0 / n)
    if x < 0.0:
        guess = -guess
    for _ in range(max_iter):
        prev = guess
        denominator = prev ** (n - 1.0)
        if denominator == 0.0:
            raise errors.ConvergenceError("Division by zero during Newton iteration")
        guess = ((n - 1.0) * prev + x / denominator) / n
        if abs(guess - prev) < eps:
            return guess
    raise errors.ConvergenceError("Failed to converge within the maximum number of iterations")

## @brief Computes the natural logarithm (ln) of a positive number.
# @param x The argument of the logarithm.
# @param eps Convergence tolerance.
# @param max_iter Maximum allowed iterations.
# @return The natural logarithm of x.
# @throws errors.DomainError If x is not positive.
# @throws errors.ConvergenceError If the Newton-Raphson method fails to converge.
# @details Uses range reduction to bring x closer to 1.0, then applies Newton's method on f(z) = e^z - x.
def ln(x: float, eps: float = 1e-12, max_iter: int = 100000) -> float:
    if x <= 0.0:
        raise errors.DomainError("ln: x must be > 0")
    y = 0.0
    while x > 2.0:
        x /= math.e
        y += 1.0
    while x < 0.5:
        x *= math.e
        y -= 1.0
    z = x - 1.0
    for _ in range(max_iter):
        e_z = exp(z, eps, max_iter)
        delta = (x - e_z) / e_z
        z += delta
        if abs(delta) < eps:
            return y + z
    raise errors.ConvergenceError("ln: did not converge")

## @brief Computes the logarithm of x with a specified base.
# @param x The argument of the logarithm.
# @param base The logarithm base (defaults to e).
# @param eps Convergence tolerance for internal ln computations.
# @param max_iter Maximum iterations for internal computations.
# @return The logarithm of x with the given base.
# @throws errors.DomainError If x is not positive, or if base is <= 0 or equals 1.
def log(x: float, base: float = math.e, eps: float = 1e-12, max_iter: int = 100000) -> float:
    if x <= 0.0:
        raise errors.DomainError("log is only defined for positive numbers")
    if base <= 0.0 or base == 1.0:
        raise errors.DomainError("log: base must be > 0 and not 1")
    if x == 1.0:
        return 0.0
    return ln(x, eps, max_iter) / ln(base, eps, max_iter)
