import datetime
import inspect
import logging
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import httpx
import pytest
from httpx import Headers, Response

from httpx_retries.retry import HTTPMethod, Retry


def test_retry_initialization() -> None:
    retry = Retry()
    assert retry.total == 10
    assert retry.backoff_factor == 0
    assert retry.respect_retry_after_header is True
    assert retry.max_backoff_wait == 120


def test_retry_custom_initialization() -> None:
    retry = Retry(
        total=5,
        backoff_factor=0.5,
        respect_retry_after_header=False,
        max_backoff_wait=30,
        allowed_methods=["GET", "POST"],
        status_forcelist=[500, 502],
    )
    assert retry.total == 5
    assert retry.backoff_factor == 0.5
    assert retry.respect_retry_after_header is False
    assert retry.max_backoff_wait == 30
    assert HTTPMethod.GET in retry.allowed_methods
    assert HTTPMethod.POST in retry.allowed_methods
    assert HTTPStatus.INTERNAL_SERVER_ERROR in retry.status_forcelist
    assert HTTPStatus.BAD_GATEWAY in retry.status_forcelist


def test_is_retryable_method() -> None:
    retry = Retry()
    assert retry.is_retryable_method("GET") is True
    assert retry.is_retryable_method("POST") is False


def test_is_retryable_status_code() -> None:
    retry = Retry()
    assert retry.is_retryable_status_code(429) is True
    assert retry.is_retryable_status_code(404) is False


def test_is_retryable_exception() -> None:
    retry = Retry()
    assert retry.is_retryable_exception(httpx.NetworkError("")) is True
    assert retry.is_retryable_exception(httpx.LocalProtocolError("")) is False


def test_is_retryable_exception_custom_exception() -> None:
    class MyExc(Exception):
        pass

    retry = Retry(retry_on_exceptions=(MyExc,))
    assert retry.is_retryable_exception(httpx.NetworkError("")) is False
    assert retry.is_retryable_exception(MyExc()) is True


def test_custom_retryable_methods_str() -> None:
    retry = Retry(allowed_methods=["POST"])
    assert retry.is_retryable_method("POST") is True
    assert retry.is_retryable_method("GET") is False


def test_custom_retryable_methods_enum() -> None:
    retry = Retry(allowed_methods=[HTTPMethod.POST])
    assert retry.is_retryable_method("POST") is True
    assert retry.is_retryable_method("GET") is False


def test_non_standard_method_not_retryable_by_default() -> None:
    retry = Retry()
    assert retry.is_retryable_method("PROPFIND") is False
    assert retry.is_retryable_method("propfind") is False


def test_non_standard_method_can_be_configured() -> None:
    retry = Retry(allowed_methods=["PROPFIND", "GET"])
    assert retry.is_retryable_method("PROPFIND") is True
    assert retry.is_retryable_method("propfind") is True
    assert retry.is_retryable_method("GET") is True
    assert retry.is_retryable_method("POST") is False


def test_custom_retry_status_codes() -> None:
    retry = Retry(status_forcelist=[500])
    assert retry.is_retryable_status_code(500) is True
    assert retry.is_retryable_status_code(502) is False


def test_custom_retry_status_codes_enum() -> None:
    retry = Retry(status_forcelist=[HTTPStatus.INTERNAL_SERVER_ERROR])
    assert retry.is_retryable_status_code(500) is True
    assert retry.is_retryable_status_code(502) is False


def test_custom_retry_status_codes_non_standard() -> None:
    retry = Retry(status_forcelist=[523, 599])
    assert retry.is_retryable_status_code(523) is True
    assert retry.is_retryable_status_code(599) is True
    assert retry.is_retryable_status_code(500) is False
    assert retry.is_retryable_status_code(502) is False


def test_is_exhausted() -> None:
    retry = Retry(total=3)
    assert retry.is_exhausted() is False

    retry = retry.increment().increment().increment()
    assert retry.is_exhausted() is True


def test_parse_retry_after_seconds() -> None:
    retry = Retry()
    assert retry.parse_retry_after("5") == 5.0


def test_parse_retry_after_http_date() -> None:
    retry = Retry()
    future_date = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=5)).strftime(
        "%a, %d %b %Y %H:%M:%S GMT"
    )
    result = retry.parse_retry_after(future_date)
    assert 3 < result < 7  # Allow some flexibility for test execution time


def test_parse_retry_after_http_date_past() -> None:
    retry = Retry()
    past_date = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=5)).strftime(
        "%a, %d %b %Y %H:%M:%S GMT"
    )
    result = retry.parse_retry_after(past_date)
    assert result == 0


def test_parse_retry_after_http_date_no_tz() -> None:
    retry = Retry()
    future_date = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=5)).strftime(
        "%a, %d %b %Y %H:%M:%S"
    )
    result = retry.parse_retry_after(future_date)
    assert 3 < result < 7


def test_parse_retry_after_http_date_no_tz_logs_warning(caplog: pytest.LogCaptureFixture) -> None:
    import logging

    retry = Retry()
    future_date = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=5)).strftime(
        "%a, %d %b %Y %H:%M:%S"
    )
    with caplog.at_level(logging.WARNING, logger="httpx_retries.retry"):
        retry.parse_retry_after(future_date)
    assert "assuming UTC" in caplog.text


def test_calculate_sleep_with_retry_after_over_max() -> None:
    retry = Retry(max_backoff_wait=5)
    headers = Headers({"Retry-After": "10"})
    assert retry._calculate_sleep(headers) == 5.0


def test_calculate_sleep_respect_retry_after_false() -> None:
    retry = Retry(respect_retry_after_header=False)
    headers = Headers({"Retry-After": "5"})
    sleep_time = retry._calculate_sleep(headers)
    assert sleep_time <= retry.max_backoff_wait


def test_parse_retry_after_invalid() -> None:
    retry = Retry()
    with pytest.raises(ValueError):
        retry.parse_retry_after("invalid")


@pytest.mark.parametrize("value", ["\u0665", "\u00b2"])
def test_parse_retry_after_rejects_non_ascii_digits(value: str) -> None:
    retry = Retry()
    with pytest.raises(ValueError, match=f"Invalid Retry-After header: {value}"):
        retry.parse_retry_after(value)


def test_calculate_sleep_with_retry_after() -> None:
    retry = Retry()
    headers = Headers({"Retry-After": "5"})
    assert retry._calculate_sleep(headers) == 5.0


def test_calculate_sleep_with_invalid_retry_after(caplog: pytest.LogCaptureFixture) -> None:
    retry = Retry()
    headers = Headers({"Retry-After": "invalid"})
    sleep_time = retry._calculate_sleep(headers)

    assert sleep_time <= retry.max_backoff_wait
    assert "Retry-After header is not a valid HTTP date" in caplog.text


def test_calculate_sleep_returns_immediately_on_first_attempt() -> None:
    retry = Retry()
    headers = Headers({})
    assert retry._calculate_sleep(headers) == 0


def test_sleep_respects_retry_after_header(mock_sleep: MagicMock) -> None:
    retry = Retry()
    response = Response(status_code=429, headers={"Retry-After": "5"})
    retry.sleep(response)
    assert mock_sleep.call_count == 1
    mock_sleep.assert_called_with(5.0)


def test_sleep_logs_sleep_time(mock_sleep: MagicMock, caplog: pytest.LogCaptureFixture) -> None:
    caplog.set_level(logging.DEBUG)
    retry = Retry()
    response = Response(status_code=429, headers={"Retry-After": "5"})
    retry.sleep(response)
    assert "sleep seconds=5.0" in caplog.text


@pytest.mark.asyncio
async def test_asleep_respects_retry_after_header(mock_asleep: AsyncMock) -> None:
    retry = Retry()
    response = Response(status_code=429, headers={"Retry-After": "5"})
    await retry.asleep(response)
    assert mock_asleep.call_count == 1
    mock_asleep.assert_called_with(5.0)


@pytest.mark.asyncio
async def test_asleep_logs_sleep_time(mock_asleep: AsyncMock, caplog: pytest.LogCaptureFixture) -> None:
    caplog.set_level(logging.DEBUG)
    retry = Retry()
    response = Response(status_code=429, headers={"Retry-After": "5"})
    await retry.asleep(response)
    assert "asleep seconds=5.0" in caplog.text


def test_calculate_sleep_returns_immediately_by_default() -> None:
    # If there is no backoff factor or retry after header, the sleep time should be 0
    retry = Retry(attempts_made=1)
    headers = Headers({})
    assert retry._calculate_sleep(headers) == 0


def test_calculate_sleep_with_backoff() -> None:
    retry = Retry(backoff_factor=2)
    headers = Headers({})
    attempts: list[Retry] = [retry]
    for _ in range(3):
        attempts.append(attempts[-1].increment())

    for i, retry_attempt in enumerate(attempts):
        sleep_time = retry_attempt._calculate_sleep(headers)
        # Backoff should be backoff_factor * (2 ** (attempts))
        # With full jitter, the actual sleep time will be between 0 and this value
        max_expected = 2 * (2 ** (i))
        assert sleep_time <= max_expected


def test_calculate_sleep_falls_back_to_backoff_if_retry_after_is_in_the_past() -> None:
    retry = Retry(backoff_factor=2, attempts_made=2)
    headers = Headers({"Retry-After": "0"})
    sleep_time = retry._calculate_sleep(headers)
    assert sleep_time != 0
    assert sleep_time <= 2 * (2 ** (2))


def test_calculate_sleep_max_backoff() -> None:
    retry = Retry(backoff_factor=2, max_backoff_wait=5)
    headers = Headers({})
    # After several attempts, backoff would exceed max_backoff_wait
    retry = retry.increment().increment().increment()
    sleep_time = retry._calculate_sleep(headers)
    assert sleep_time <= 5


def test_calculate_sleep_first_attempt() -> None:
    retry = Retry(backoff_factor=2)
    headers = Headers({})
    sleep_time = retry._calculate_sleep(headers)
    # First attempt should have no backoff
    assert sleep_time <= 2


def test_calculate_sleep_without_retry_after() -> None:
    retry = Retry()
    headers = Headers({})
    sleep_time = retry._calculate_sleep(headers)

    assert sleep_time <= retry.max_backoff_wait


def test_increment() -> None:
    retry = Retry(total=3)
    new_retry = retry.increment()

    assert new_retry != retry

    assert new_retry.attempts_made == retry.attempts_made + 1
    assert new_retry.total == retry.total
    assert new_retry.backoff_factor == retry.backoff_factor
    assert new_retry.respect_retry_after_header == retry.respect_retry_after_header
    assert new_retry.allowed_methods == retry.allowed_methods
    assert new_retry.status_forcelist == retry.status_forcelist


def test_increment_logs(caplog: pytest.LogCaptureFixture) -> None:
    caplog.set_level(logging.DEBUG)
    retry = Retry(total=3)
    new_retry = retry.increment()
    assert "increment retry=<Retry(total=3, attempts_made=0)> new_attempts_made=1" in caplog.text
    new_retry.increment()
    assert "increment retry=<Retry(total=3, attempts_made=1)> new_attempts_made=2" in caplog.text


def test_retry_validation_negative_total() -> None:
    with pytest.raises(ValueError, match="total must be non-negative"):
        Retry(total=-1)


def test_retry_validation_negative_backoff_factor() -> None:
    with pytest.raises(ValueError, match="backoff_factor must be non-negative"):
        Retry(backoff_factor=-0.5)


def test_retry_validation_zero_max_backoff_wait() -> None:
    with pytest.raises(ValueError, match="max_backoff_wait must be positive"):
        Retry(max_backoff_wait=0)


def test_retry_validation_negative_max_backoff_wait() -> None:
    with pytest.raises(ValueError, match="max_backoff_wait must be positive"):
        Retry(max_backoff_wait=-1)


def test_retry_validation_invalid_jitter() -> None:
    with pytest.raises(ValueError, match="backoff_jitter must be between 0 and 1"):
        Retry(backoff_jitter=1.5)


def test_retry_validation_negative_jitter() -> None:
    with pytest.raises(ValueError, match="backoff_jitter must be between 0 and 1"):
        Retry(backoff_jitter=-0.5)


def test_retry_validation_negative_attempts() -> None:
    with pytest.raises(ValueError, match="attempts_made must be non-negative"):
        Retry(attempts_made=-1)


def test_method_case_insensitive() -> None:
    retry = Retry(allowed_methods=["get", "POST"])
    assert retry.is_retryable_method("GET")
    assert retry.is_retryable_method("get")
    assert retry.is_retryable_method("POST")
    assert retry.is_retryable_method("post")


def test_backoff_with_no_jitter() -> None:
    retry = Retry(backoff_factor=1, backoff_jitter=0)
    retry = retry.increment()  # One attempt made

    # With no jitter, backoff should be exactly backoff_factor * (2 ** attempts_made)
    assert retry.backoff_strategy() == 2.0


def test_backoff_with_partial_jitter() -> None:
    retry = Retry(backoff_factor=1, backoff_jitter=0.5)
    retry = retry.increment()  # One attempt made

    # With 0.5 jitter, backoff should be between 1.0 and 2.0 times backoff_factor * (2 ** attempts_made)
    backoff = retry.backoff_strategy()
    assert 1.0 <= backoff <= 2.0


def test_zero_backoff_factor() -> None:
    retry = Retry(backoff_factor=0)
    retry = retry.increment()

    assert retry.backoff_strategy() == 0.0


def test_increment_preserves_jitter() -> None:
    retry = Retry(backoff_jitter=0.5)
    new_retry = retry.increment()

    assert new_retry.backoff_jitter == retry.backoff_jitter


def test_retry_after_header_invalid_format() -> None:
    retry = Retry()
    with pytest.raises(ValueError, match="Invalid Retry-After header: invalid date"):
        retry.parse_retry_after("invalid date")


def test_retry_after_header_logging(caplog: pytest.LogCaptureFixture) -> None:
    retry = Retry()
    headers = Headers({"Retry-After": "invalid date"})
    retry._calculate_sleep(headers)

    assert "Retry-After header is not a valid HTTP date: invalid date" in caplog.text


def test_retry_after_header_precedence() -> None:
    retry = Retry(backoff_factor=2)
    retry = retry.increment()  # One attempt made

    # Retry-After header should take precedence over backoff strategy
    headers = Headers({"Retry-After": "1"})
    sleep_time = retry._calculate_sleep(headers)

    assert sleep_time == 1.0


def test_retry_after_respects_max_wait() -> None:
    retry = Retry(max_backoff_wait=1)
    headers = Headers({"Retry-After": "10"})
    sleep_time = retry._calculate_sleep(headers)

    assert sleep_time == 1.0


def test_default_values() -> None:
    retry = Retry()
    assert retry.total == 10
    assert retry.backoff_factor == 0.0
    assert retry.max_backoff_wait == 120.0
    assert retry.backoff_jitter == 1.0


def test_is_retry() -> None:
    retry = Retry(total=3)
    assert retry.is_retry("GET", 429, False) is True
    assert retry.is_retry("POST", 429, False) is False
    assert retry.is_retry("GET", 404, False) is False
    assert retry.is_retry("GET", 429, True) is False

    exhausted_retry = Retry(total=0)
    assert exhausted_retry.is_retry("GET", 429, False) is False


def test_is_retry_custom_configuration() -> None:
    retry = Retry(total=3, allowed_methods=["POST"], status_forcelist=[404])
    assert retry.is_retry("POST", 404, False) is True
    assert retry.is_retry("GET", 404, False) is False
    assert retry.is_retry("POST", 429, False) is False


def test_total_timeout_default_is_none() -> None:
    retry = Retry()
    assert retry.total_timeout is None
    assert retry.elapsed_sleep == 0.0


def test_total_timeout_custom() -> None:
    retry = Retry(total_timeout=30.0)
    assert retry.total_timeout == 30.0


def test_retry_validation_negative_total_timeout() -> None:
    with pytest.raises(ValueError, match="total_timeout must be positive"):
        Retry(total_timeout=-1)


def test_retry_validation_zero_total_timeout() -> None:
    with pytest.raises(ValueError, match="total_timeout must be positive"):
        Retry(total_timeout=0)


def test_retry_validation_negative_elapsed_sleep() -> None:
    with pytest.raises(ValueError, match="elapsed_sleep must be non-negative"):
        Retry(elapsed_sleep=-0.1)


def test_calculate_sleep_capped_by_total_timeout() -> None:
    retry = Retry(total_timeout=5)
    headers = Headers({"Retry-After": "10"})
    assert retry._calculate_sleep(headers) == 5.0


def test_calculate_sleep_capped_by_remaining_total_timeout() -> None:
    retry = Retry(total_timeout=10)
    retry.elapsed_sleep = 7.0
    headers = Headers({"Retry-After": "10"})
    assert retry._calculate_sleep(headers) == 3.0


def test_calculate_sleep_zero_when_total_timeout_exhausted() -> None:
    retry = Retry(total_timeout=5)
    retry.elapsed_sleep = 5.0
    headers = Headers({"Retry-After": "10"})
    assert retry._calculate_sleep(headers) == 0.0


def test_calculate_sleep_no_cap_without_total_timeout() -> None:
    retry = Retry()
    headers = Headers({"Retry-After": "10"})
    assert retry._calculate_sleep(headers) == 10.0


def test_sleep_updates_elapsed_sleep(mock_sleep: MagicMock) -> None:
    retry = Retry()
    assert retry.elapsed_sleep == 0.0
    response = Response(status_code=429, headers={"Retry-After": "5"})
    retry.sleep(response)
    assert retry.elapsed_sleep == 5.0
    retry.sleep(response)
    assert retry.elapsed_sleep == 10.0


@pytest.mark.asyncio
async def test_asleep_updates_elapsed_sleep(mock_asleep: AsyncMock) -> None:
    retry = Retry()
    assert retry.elapsed_sleep == 0.0
    response = Response(status_code=429, headers={"Retry-After": "5"})
    await retry.asleep(response)
    assert retry.elapsed_sleep == 5.0
    await retry.asleep(response)
    assert retry.elapsed_sleep == 10.0


def test_increment_preserves_total_timeout() -> None:
    retry = Retry(total_timeout=30.0)
    new_retry = retry.increment()
    assert new_retry.total_timeout == 30.0


def test_increment_preserves_elapsed_sleep() -> None:
    retry = Retry(total_timeout=30.0)
    retry.elapsed_sleep = 12.5
    new_retry = retry.increment()
    assert new_retry.elapsed_sleep == 12.5


def test_is_exhausted_by_total_timeout() -> None:
    retry = Retry(total=10, total_timeout=5)
    retry.elapsed_sleep = 5.0
    assert retry.is_exhausted() is True


def test_is_exhausted_below_total_timeout() -> None:
    retry = Retry(total=10, total_timeout=5)
    retry.elapsed_sleep = 4.9
    assert retry.is_exhausted() is False


def test_copy_with_overrides_fields() -> None:
    retry = Retry(total=10, backoff_factor=0.5, total_timeout=30.0)
    copy = retry.copy_with(total=3, backoff_factor=1.0)
    assert copy.total == 3
    assert copy.backoff_factor == 1.0
    assert copy.total_timeout == 30.0  # unchanged


def test_copy_with_no_args_equals_original() -> None:
    retry = Retry(total=5, backoff_factor=0.2, max_backoff_wait=60.0)
    copy = retry.copy_with()
    assert copy.total == retry.total
    assert copy.backoff_factor == retry.backoff_factor
    assert copy.max_backoff_wait == retry.max_backoff_wait
    assert copy.allowed_methods == retry.allowed_methods
    assert copy.status_forcelist == retry.status_forcelist
    assert copy.retryable_exceptions == retry.retryable_exceptions


def test_copy_with_can_set_total_timeout_to_none() -> None:
    retry = Retry(total=5, total_timeout=10.0)
    copy = retry.copy_with(total_timeout=None)
    assert copy.total_timeout is None


def test_copy_with_preserves_subclass() -> None:
    class CustomRetry(Retry):
        pass

    retry = CustomRetry(total=5)
    copy = retry.copy_with(total=3)
    assert type(copy) is CustomRetry


def test_copy_with_and_init_have_same_parameters() -> None:
    init_params = set(inspect.signature(Retry.__init__).parameters) - {"self"}
    copy_with_params = set(inspect.signature(Retry.copy_with).parameters) - {"self"}
    assert init_params == copy_with_params


def test_copy_with_roundtrips_all_fields() -> None:
    init_params = set(inspect.signature(Retry.__init__).parameters) - {"self"}
    kwargs: dict[str, Any] = {
        "total": 3,
        "allowed_methods": {"GET", "POST"},
        "status_forcelist": {500, 503},
        "retry_on_exceptions": [httpx.TimeoutException],
        "backoff_factor": 0.5,
        "respect_retry_after_header": False,
        "max_backoff_wait": 60.0,
        "backoff_jitter": 0.5,
        "attempts_made": 2,
        "total_timeout": 30.0,
        "elapsed_sleep": 1.5,
        "validate_response": lambda _: None,
    }
    assert set(kwargs) == init_params

    original = Retry(**kwargs)
    copy = original.copy_with(**kwargs)

    for attr, value in vars(original).items():
        assert getattr(copy, attr) == value, f"{attr} mismatch"
