import time
from functools import partial
from unittest.mock import patch

import pytest

from soniclue_basekit.attempter import (
    Attempter,
    ConstantPause,
    ExponentialBackoff,
    NoMoreAttempt,
    RandomPause,
    _callable_description,
)


# --- _callable_description ---


def test_callable_description_named_function():
    def my_func():
        pass

    assert _callable_description(my_func) == "my_func"


def test_callable_description_lambda():
    assert _callable_description(lambda: None) == "<lambda>"


def test_callable_description_partial():
    def inner():
        pass

    assert _callable_description(partial(inner)) == "inner"


def test_callable_description_callable_class():
    class MyWorker:
        def __call__(self):
            pass

    assert _callable_description(MyWorker()) == "MyWorker"


# --- NoMoreAttempt ---


def test_no_more_attempt_cause_none_by_default():
    e = NoMoreAttempt()
    assert e.cause is None


def test_no_more_attempt_stores_cause():
    cause = ValueError("reason")
    e = NoMoreAttempt(cause=cause)
    assert e.cause is cause


# --- ConstantPause ---


def test_constant_pause_returns_interval():
    p = ConstantPause(2.5)
    assert p.get_next_pause(0) == 2.5
    assert p.get_next_pause(5) == 2.5


def test_constant_pause_zero_is_valid():
    p = ConstantPause(0)
    assert p.get_next_pause(0) == 0


def test_constant_pause_negative_raises():
    with pytest.raises(AssertionError):
        ConstantPause(-1)


# --- RandomPause ---


def test_random_pause_in_range():
    p = RandomPause(0.5, 1.5)
    for _ in range(20):
        v = p.get_next_pause(0)
        assert 0.5 <= v <= 1.5


def test_random_pause_equal_bounds():
    p = RandomPause(1.0, 1.0)
    assert p.get_next_pause(0) == 1.0


def test_random_pause_invalid_raises():
    with pytest.raises(AssertionError):
        RandomPause(2.0, 1.0)


def test_random_pause_negative_low_raises():
    with pytest.raises(AssertionError):
        RandomPause(-0.1, 1.0)


# --- ExponentialBackoff ---


def test_exponential_backoff_attempt_zero_returns_base():
    b = ExponentialBackoff(base=2.0, multiplier=3.0)
    assert b.get_next_pause(0) == pytest.approx(2.0)


def test_exponential_backoff_grows_with_attempt():
    b = ExponentialBackoff(base=1.0, multiplier=2.0)
    assert b.get_next_pause(1) == pytest.approx(2.0)
    assert b.get_next_pause(2) == pytest.approx(4.0)


def test_exponential_backoff_jitter_adds_nonnegative():
    b = ExponentialBackoff(base=1.0, multiplier=2.0, jitter=0.5)
    base_val = 1.0  # attempt=0
    for _ in range(20):
        v = b.get_next_pause(0)
        assert base_val <= v <= base_val + 0.5


def test_exponential_backoff_invalid_base_raises():
    with pytest.raises(AssertionError):
        ExponentialBackoff(base=0, multiplier=2.0)


def test_exponential_backoff_invalid_multiplier_raises():
    with pytest.raises(AssertionError):
        ExponentialBackoff(base=1.0, multiplier=0.5)


def test_exponential_backoff_invalid_jitter_raises():
    with pytest.raises(AssertionError):
        ExponentialBackoff(base=1.0, multiplier=2.0, jitter=-1.0)


# --- Attempter ---


def test_attempter_description_auto_derived():
    def my_task():
        return 1

    a = Attempter(my_task)
    assert a.description == "my_task"


def test_attempter_description_explicit():
    a = Attempter(lambda: 1, description="custom")
    assert a.description == "custom"


def test_execute_succeeds_on_first_try():
    a = Attempter(lambda: 42)
    assert a.execute(ConstantPause(0), max_attempts=3) == 42


def test_execute_retries_and_succeeds():
    calls = [0]

    def _flaky():
        calls[0] += 1
        if calls[0] < 3:
            raise ValueError("not yet")
        return "ok"

    a = Attempter(_flaky)
    result = a.execute(ConstantPause(0), max_attempts=5)
    assert result == "ok"
    assert calls[0] == 3


def test_execute_max_attempts_one_raises_on_failure():
    a = Attempter(lambda: (_ for _ in ()).throw(RuntimeError("boom")))
    with pytest.raises(RuntimeError, match="boom"):
        a.execute(ConstantPause(0), max_attempts=1)


def test_execute_exhausts_all_attempts_and_reraises():
    calls = [0]

    def _always_fail():
        calls[0] += 1
        raise ValueError(f"fail #{calls[0]}")

    a = Attempter(_always_fail)
    with pytest.raises(ValueError, match="fail #3"):
        a.execute(ConstantPause(0), max_attempts=3)
    assert calls[0] == 3


def test_execute_no_more_attempt_raises_cause_when_present():
    calls = [0]

    def _stop_early():
        calls[0] += 1
        raise NoMoreAttempt(cause=ValueError("done"))

    a = Attempter(_stop_early)
    with pytest.raises(ValueError, match="done"):
        a.execute(ConstantPause(0), max_attempts=10)
    assert calls[0] == 1


def test_execute_no_more_attempt_raises_itself_when_no_cause():
    calls = [0]

    def _stop_early():
        calls[0] += 1
        raise NoMoreAttempt()

    a = Attempter(_stop_early)
    with pytest.raises(NoMoreAttempt):
        a.execute(ConstantPause(0), max_attempts=10)
    assert calls[0] == 1


def test_execute_validates_max_attempts():
    a = Attempter(lambda: None)
    with pytest.raises(AssertionError):
        a.execute(ConstantPause(0), max_attempts=0)


def test_execute_sleeps_between_retries():
    calls = [0]

    def _fail_twice():
        calls[0] += 1
        if calls[0] < 3:
            raise ValueError("retry")
        return "done"

    sleeps = []
    with patch("soniclue_basekit.attempter.time.sleep", side_effect=sleeps.append):
        a = Attempter(_fail_twice)
        a.execute(ConstantPause(1.5), max_attempts=5)

    assert sleeps == [1.5, 1.5]


def test_execute_with_random_pause():
    a = Attempter(lambda: "value")
    result = a.execute(RandomPause(0, 0), max_attempts=1)
    assert result == "value"


def test_execute_with_exponential_backoff():
    calls = [0]

    def _fail_once():
        calls[0] += 1
        if calls[0] == 1:
            raise RuntimeError("first")
        return "recovered"

    sleeps = []
    with patch("soniclue_basekit.attempter.time.sleep", side_effect=sleeps.append):
        a = Attempter(_fail_once)
        result = a.execute(ExponentialBackoff(base=2.0, multiplier=3.0), max_attempts=3)

    assert result == "recovered"
    assert sleeps == [pytest.approx(2.0)]  # attempt=0 → base * multiplier^0 = 2.0
