"""Tests for ``AsyncHttpClient.download()`` — H-NEW-1 resumable +
integrity-verified download (v0.4.0).

Uses respx to mock httpx — no real network calls.
"""

from __future__ import annotations

import hashlib
from typing import TYPE_CHECKING

import httpx
import pytest
import respx

from mgf.http import AsyncHttpClient, HttpTransportError
from mgf.http._download import DownloadResult, IntegrityError

if TYPE_CHECKING:
    from pathlib import Path


_TEST_PAYLOAD = b"hello world, the quick brown fox jumps over the lazy dog" * 100
_TEST_SHA256 = hashlib.sha256(_TEST_PAYLOAD).hexdigest()
_TEST_SHA1 = hashlib.sha1(_TEST_PAYLOAD).hexdigest()  # noqa: S324 — explicit per-test


@pytest.mark.asyncio
class TestDownloadHappyPath:
    @respx.mock
    async def test_fresh_download(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        dest = tmp_path / "out.bin"
        async with AsyncHttpClient() as client:
            result = await client.download("https://x.test/file.bin", dest)

        assert dest.exists()
        assert dest.read_bytes() == _TEST_PAYLOAD
        assert isinstance(result, DownloadResult)
        assert result.dest == dest
        assert result.bytes_downloaded == len(_TEST_PAYLOAD)
        assert result.sha256 == _TEST_SHA256
        assert result.sha1 is None  # not requested
        assert result.resumed_from == 0

    @respx.mock
    async def test_sha256_verification(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_sha256=_TEST_SHA256,
            )

        assert result.sha256 == _TEST_SHA256

    @respx.mock
    async def test_sha1_verification(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_sha1=_TEST_SHA1,
            )

        assert result.sha1 == _TEST_SHA1

    @respx.mock
    async def test_size_verification(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_size=len(_TEST_PAYLOAD),
            )

        assert result.bytes_downloaded == len(_TEST_PAYLOAD)

    @respx.mock
    async def test_progress_callback(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        calls: list[tuple[int, int | None]] = []
        async with AsyncHttpClient() as client:
            await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_size=len(_TEST_PAYLOAD),
                on_progress=lambda done, total: calls.append((done, total)),
            )

        # First call is (0, total) before any bytes; last call ≥ total.
        assert calls[0] == (0, len(_TEST_PAYLOAD))
        assert calls[-1][0] >= len(_TEST_PAYLOAD)


@pytest.mark.asyncio
class TestIntegrityFailures:
    @respx.mock
    async def test_sha256_mismatch_raises(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            with pytest.raises(IntegrityError) as exc_info:
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "out.bin",
                    expected_sha256="0" * 64,
                )

        assert exc_info.value.algorithm == "sha256"
        assert exc_info.value.cause_summary == "integrity-mismatch"
        # Partial file preserved — consumer can inspect / retry / delete.
        assert (tmp_path / "out.bin.partial").exists()
        # Final dest NOT present (atomic write skipped).
        assert not (tmp_path / "out.bin").exists()

    @respx.mock
    async def test_size_mismatch_raises(self, tmp_path: Path) -> None:
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            with pytest.raises(IntegrityError) as exc_info:
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "out.bin",
                    expected_size=999,
                )

        assert exc_info.value.algorithm == "size"

    async def test_integrity_error_is_http_transport_error(self) -> None:
        # Subclass relationship — consumers wrapping in
        # `except HttpTransportError` catch IntegrityError too.
        err = IntegrityError(
            url="https://x",
            algorithm="sha256",
            expected="a",
            actual="b",
        )
        assert isinstance(err, HttpTransportError)


@pytest.mark.asyncio
class TestResume:
    @respx.mock
    async def test_resume_continues_from_partial(self, tmp_path: Path) -> None:
        # Simulate a prior partial download (half the payload).
        half = len(_TEST_PAYLOAD) // 2
        partial = tmp_path / "out.bin.partial"
        partial.write_bytes(_TEST_PAYLOAD[:half])

        # Mock server replies with Range — 206 + the rest.
        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(206, content=_TEST_PAYLOAD[half:]),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_sha256=_TEST_SHA256,
                resume=True,
            )

        # Resumed from half; final hash matches.
        assert result.resumed_from == half
        assert result.bytes_downloaded == len(_TEST_PAYLOAD)
        assert result.sha256 == _TEST_SHA256
        # Dest file has the FULL payload (partial + rest).
        assert (tmp_path / "out.bin").read_bytes() == _TEST_PAYLOAD

    @respx.mock
    async def test_resume_false_truncates_partial(self, tmp_path: Path) -> None:
        partial = tmp_path / "out.bin.partial"
        partial.write_bytes(b"stale-content-should-be-discarded")

        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_sha256=_TEST_SHA256,
                resume=False,
            )

        assert result.resumed_from == 0
        assert result.sha256 == _TEST_SHA256

    @respx.mock
    async def test_server_ignores_range_returns_200(self, tmp_path: Path) -> None:
        # Server returns 200 (not 206) despite the Range header —
        # interpreted as "server replayed from zero". Library
        # discards partial, restarts.
        partial = tmp_path / "out.bin.partial"
        partial.write_bytes(b"old-data")

        respx.get("https://x.test/file.bin").mock(
            return_value=httpx.Response(200, content=_TEST_PAYLOAD),
        )

        async with AsyncHttpClient() as client:
            result = await client.download(
                "https://x.test/file.bin",
                tmp_path / "out.bin",
                expected_sha256=_TEST_SHA256,
                resume=True,
            )

        # Final hash matches even though the partial had wrong bytes —
        # library correctly reset on the 200 response.
        assert result.sha256 == _TEST_SHA256


@pytest.mark.asyncio
class TestPreconditions:
    async def test_missing_parent_directory(self, tmp_path: Path) -> None:
        async with AsyncHttpClient() as client:
            with pytest.raises(FileNotFoundError, match="does not exist"):
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "no-such-dir" / "out.bin",
                )

    async def test_invalid_sha256_length(self, tmp_path: Path) -> None:
        async with AsyncHttpClient() as client:
            with pytest.raises(ValueError, match=r"64-char"):
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "out.bin",
                    expected_sha256="too-short",
                )

    async def test_invalid_sha1_length(self, tmp_path: Path) -> None:
        async with AsyncHttpClient() as client:
            with pytest.raises(ValueError, match=r"40-char"):
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "out.bin",
                    expected_sha1="too-short",
                )

    async def test_explicit_range_header_rejected(self, tmp_path: Path) -> None:
        async with AsyncHttpClient() as client:
            with pytest.raises(ValueError, match="library-managed"):
                await client.download(
                    "https://x.test/file.bin",
                    tmp_path / "out.bin",
                    headers={"Range": "bytes=0-100"},
                )
