"""Tests for the disk cache and PyPI metadata fetcher."""

from __future__ import annotations

import logging
import os
import time
from pathlib import Path

import httpx
import pytest
import respx

from depwhy.fetcher.cache import DiskCache
from depwhy.fetcher.pypi import PyPIFetcher

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

SAMPLE_DATA = {"name": "httpx", "version": "0.27.0", "requires_dist": ["httpcore>=1.0.0"]}


def _make_cache(tmp_path: Path, ttl_hours: int = 24) -> DiskCache:
    """Create a DiskCache pointed at *tmp_path* for isolation."""
    return DiskCache(ttl_hours=ttl_hours, cache_dir=tmp_path)


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------


class TestDiskCacheMiss:
    """Cache miss returns None."""

    def test_miss_returns_none(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        assert cache.get("nonexistent", "1.0.0") is None


class TestDiskCacheHit:
    """Cache hit returns stored data."""

    def test_round_trip(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        cache.set("httpx", "0.27.0", SAMPLE_DATA)
        result = cache.get("httpx", "0.27.0")
        assert result == SAMPLE_DATA


class TestDiskCacheStale:
    """Stale cache entries are treated as misses."""

    def test_stale_returns_none(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path, ttl_hours=1)
        cache.set("httpx", "0.27.0", SAMPLE_DATA)

        # Backdate file mtime by 2 hours
        path = cache._path("httpx", "0.27.0")
        old_time = time.time() - 7200
        os.utime(path, (old_time, old_time))

        assert cache.get("httpx", "0.27.0") is None


class TestDiskCacheDifferentKeys:
    """Different package/version pairs are cached independently."""

    def test_different_keys(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        data_a = {"name": "a", "v": "1"}
        data_b = {"name": "b", "v": "2"}

        cache.set("pkg-a", "1.0.0", data_a)
        cache.set("pkg-b", "2.0.0", data_b)

        assert cache.get("pkg-a", "1.0.0") == data_a
        assert cache.get("pkg-b", "2.0.0") == data_b
        # Cross-lookup must miss
        assert cache.get("pkg-a", "2.0.0") is None


class TestDiskCacheUnwritable:
    """Unwritable cache directory logs a warning but does not raise."""

    def test_unwritable_set_logs_warning(
        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        # Point to a path that cannot be written to
        bad_dir = tmp_path / "readonly"
        bad_dir.mkdir()
        cache = DiskCache(cache_dir=bad_dir)

        # Make the directory unwritable
        bad_dir.chmod(0o444)
        try:
            with caplog.at_level(logging.WARNING):
                cache.set("httpx", "0.27.0", SAMPLE_DATA)
            assert "Cache write failed" in caplog.text
        finally:
            # Restore permissions so pytest can clean up
            bad_dir.chmod(0o755)

    def test_unwritable_init_logs_warning(
        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        # Use a file as the "parent" so mkdir will fail
        blocker = tmp_path / "blocker"
        blocker.write_text("x")
        bad_dir = blocker / "subdir"

        with caplog.at_level(logging.WARNING):
            DiskCache(cache_dir=bad_dir)
        assert "Cannot create cache directory" in caplog.text


class TestDiskCacheClear:
    """clear() removes all cached JSON files."""

    def test_clear_removes_files(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        cache.set("a", "1.0", {"x": 1})
        cache.set("b", "2.0", {"y": 2})

        # Verify files exist
        json_files = list(tmp_path.glob("*.json"))
        assert len(json_files) == 2

        cache.clear()

        json_files_after = list(tmp_path.glob("*.json"))
        assert len(json_files_after) == 0


class TestDiskCacheCorrupted:
    """Corrupted JSON is treated as a cache miss."""

    def test_corrupted_json_returns_none(
        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        cache = _make_cache(tmp_path)
        # Write valid entry, then corrupt it
        cache.set("httpx", "0.27.0", SAMPLE_DATA)
        path = cache._path("httpx", "0.27.0")
        path.write_text("{invalid json!!", encoding="utf-8")

        with caplog.at_level(logging.WARNING):
            result = cache.get("httpx", "0.27.0")

        assert result is None
        assert "Cache read failed" in caplog.text


# ---------------------------------------------------------------------------
# PyPI Fetcher Tests
# ---------------------------------------------------------------------------

PYPI_HTTPX_RESPONSE = {
    "info": {"name": "httpx", "version": "0.27.0", "summary": "A modern HTTP client"},
    "releases": {"0.27.0": [{"filename": "httpx-0.27.0.tar.gz"}]},
}


class TestPyPIFetcherSuccess:
    """Successful fetch returns parsed JSON metadata."""

    @respx.mock
    async def test_fetch_returns_parsed_json(self, tmp_path: Path) -> None:
        respx.get("https://pypi.org/pypi/httpx/json").mock(
            return_value=httpx.Response(200, json=PYPI_HTTPX_RESPONSE)
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert result["info"]["name"] == "httpx"


class TestPyPIFetcherCacheHit:
    """Cache hit skips the network call entirely."""

    @respx.mock
    async def test_cache_hit_skips_network(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            return_value=httpx.Response(200, json=PYPI_HTTPX_RESPONSE)
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            # First fetch — hits network
            result1 = await fetcher.fetch("httpx")
            assert route.call_count == 1

            # Second fetch — should come from cache
            result2 = await fetcher.fetch("httpx")
            assert route.call_count == 1  # No additional network call

        assert result1 == result2


class TestPyPIFetcher404:
    """404 returns empty dict without raising."""

    @respx.mock
    async def test_404_returns_empty_dict(self, tmp_path: Path) -> None:
        respx.get("https://pypi.org/pypi/nonexistent-pkg-xyz/json").mock(
            return_value=httpx.Response(404)
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("nonexistent-pkg-xyz")

        assert result == {}


class TestPyPIFetcher429Retry:
    """429 triggers retry with exponential backoff."""

    @respx.mock
    async def test_429_retries_then_succeeds(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.Response(429, headers={"Retry-After": "0"}),
                httpx.Response(200, json=PYPI_HTTPX_RESPONSE),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert route.call_count == 2

    @respx.mock
    async def test_429_respects_retry_after_header(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.Response(429, headers={"Retry-After": "0"}),
                httpx.Response(429, headers={"Retry-After": "0"}),
                httpx.Response(200, json=PYPI_HTTPX_RESPONSE),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert route.call_count == 3


class TestPyPIFetcher503Retry:
    """503 triggers retry with exponential backoff."""

    @respx.mock
    async def test_503_retries_then_succeeds(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.Response(503),
                httpx.Response(200, json=PYPI_HTTPX_RESPONSE),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert route.call_count == 2

    @respx.mock
    async def test_503_exhausts_retries_returns_empty(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.Response(503),
                httpx.Response(503),
                httpx.Response(503),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == {}
        assert route.call_count == 3


class TestPyPIFetcherFetchMany:
    """fetch_many fetches multiple packages concurrently."""

    @respx.mock
    async def test_fetch_many_returns_all(self, tmp_path: Path) -> None:
        pkg_a_data = {"info": {"name": "pkg-a"}, "releases": {}}
        pkg_b_data = {"info": {"name": "pkg-b"}, "releases": {}}

        respx.get("https://pypi.org/pypi/pkg-a/json").mock(
            return_value=httpx.Response(200, json=pkg_a_data)
        )
        respx.get("https://pypi.org/pypi/pkg-b/json").mock(
            return_value=httpx.Response(200, json=pkg_b_data)
        )

        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            results = await fetcher.fetch_many(["pkg-a", "pkg-b"])

        assert results["pkg-a"] == pkg_a_data
        assert results["pkg-b"] == pkg_b_data

    @respx.mock
    async def test_fetch_many_handles_mixed_results(self, tmp_path: Path) -> None:
        pkg_data = {"info": {"name": "real-pkg"}, "releases": {}}
        respx.get("https://pypi.org/pypi/real-pkg/json").mock(
            return_value=httpx.Response(200, json=pkg_data)
        )
        respx.get("https://pypi.org/pypi/fake-pkg/json").mock(return_value=httpx.Response(404))

        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            results = await fetcher.fetch_many(["real-pkg", "fake-pkg"])

        assert results["real-pkg"] == pkg_data
        assert results["fake-pkg"] == {}


class TestPyPIFetcherHTTPError:
    """Network-level errors are retried and handled gracefully."""

    @respx.mock
    async def test_connection_error_retries_then_returns_empty(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=httpx.ConnectError("connection refused")
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == {}
        assert route.call_count == 3

    @respx.mock
    async def test_connection_error_retries_then_succeeds(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.ConnectError("connection refused"),
                httpx.Response(200, json=PYPI_HTTPX_RESPONSE),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert route.call_count == 2


class TestPyPIFetcherInvalidRetryAfter:
    """Invalid Retry-After header falls back to exponential backoff."""

    @respx.mock
    async def test_invalid_retry_after_uses_backoff(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(
            side_effect=[
                httpx.Response(429, headers={"Retry-After": "not-a-number"}),
                httpx.Response(200, json=PYPI_HTTPX_RESPONSE),
            ]
        )
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == PYPI_HTTPX_RESPONSE
        assert route.call_count == 2


class TestPyPIFetcherUnexpectedStatus:
    """Unexpected HTTP status codes return empty dict without retry."""

    @respx.mock
    async def test_500_returns_empty_no_retry(self, tmp_path: Path) -> None:
        route = respx.get("https://pypi.org/pypi/httpx/json").mock(return_value=httpx.Response(500))
        cache = _make_cache(tmp_path)
        async with PyPIFetcher(cache) as fetcher:
            result = await fetcher.fetch("httpx")

        assert result == {}
        assert route.call_count == 1  # No retries for unexpected status


class TestPyPIFetcherContextManager:
    """Context manager properly closes the HTTP client."""

    async def test_context_manager_closes_client(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        fetcher = PyPIFetcher(cache)

        async with fetcher:
            assert not fetcher._client.is_closed

        assert fetcher._client.is_closed

    async def test_close_without_context_manager(self, tmp_path: Path) -> None:
        cache = _make_cache(tmp_path)
        fetcher = PyPIFetcher(cache)
        assert not fetcher._client.is_closed

        await fetcher.close()
        assert fetcher._client.is_closed
