"""Safe HTTP client tests using in-memory fake aiohttp responses."""

from __future__ import annotations

import asyncio
import socket
from datetime import UTC, datetime, timedelta
from email.utils import format_datetime
from pathlib import Path

import pytest

from app.errors import (
    AccessDeniedError,
    ContentTooLargeError,
    DomainNotAllowedError,
    DownloadCancelledError,
    InvalidMediaError,
    NetworkError,
    NotFoundError,
    PermanentSiteError,
    RateLimitedError,
)
from app.services.http_client import (
    DownloadMetadata,
    SafeHttpClient,
    _decode_html,
    parse_retry_after,
)
from app.services.url_validator import NetworkGuard

PUBLIC_V4 = "93.184.216.34"


class FakeContent:
    def __init__(self, body: bytes, *, chunk_size: int | None = None) -> None:
        self.body = body
        self.position = 0
        self.chunk_size = chunk_size

    async def read(self, size: int = -1) -> bytes:
        if self.position >= len(self.body):
            return b""
        limit = size if size and size > 0 else len(self.body)
        if self.chunk_size:
            limit = min(limit, self.chunk_size)
        chunk = self.body[self.position : self.position + limit]
        self.position += len(chunk)
        return chunk


class FakeResponse:
    def __init__(
        self,
        status: int = 200,
        *,
        body: bytes = b"",
        headers: dict[str, str] | None = None,
        charset: str | None = "utf-8",
        chunk_size: int | None = None,
    ) -> None:
        self.status = status
        self.headers = headers or {}
        self.content = FakeContent(body, chunk_size=chunk_size)
        self.charset = charset
        self.closed = False

    def close(self) -> None:
        self.closed = True


class FakeSession:
    def __init__(self, responses: list[FakeResponse]) -> None:
        self.responses = list(responses)
        self.requests: list[tuple[str, str, dict[str, object]]] = []
        self.closed = False

    async def request(self, method: str, url: str, **kwargs: object) -> FakeResponse:
        self.requests.append((method, url, kwargs))
        if not self.responses:
            raise AssertionError(f"unexpected request: {method} {url}")
        return self.responses.pop(0)


def _guard() -> NetworkGuard:
    async def resolver(host: str, port: int):
        return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_V4, port))]

    return NetworkGuard(["example.com"], resolver=resolver)


def _client(
    responses: list[FakeResponse],
    *,
    max_redirects: int = 5,
    timeout: int = 5,
) -> tuple[SafeHttpClient, FakeSession]:
    client = SafeHttpClient(_guard(), timeout=timeout, max_redirects=max_redirects)
    session = FakeSession(responses)
    client._session = session  # type: ignore[assignment]
    return client, session


def _html(body: str = "<html><body>ok</body></html>", **kwargs) -> FakeResponse:
    return FakeResponse(200, body=body.encode(), headers={"Content-Type": "text/html"}, **kwargs)


def test_parse_retry_after_numeric_and_dates() -> None:
    assert parse_retry_after("7") == 7.0
    assert parse_retry_after(" 2.5 ") == 2.5
    assert parse_retry_after("-4") == 0.0
    assert parse_retry_after(None) is None
    assert parse_retry_after("not-a-date") is None
    future = format_datetime(datetime.now(UTC) + timedelta(seconds=60), usegmt=True)
    delay = parse_retry_after(future)
    assert delay is not None
    assert 50 <= delay <= 70
    past = format_datetime(datetime.now(UTC) - timedelta(seconds=60), usegmt=True)
    assert parse_retry_after(past) == 0.0


def test_decode_html_charsets() -> None:
    assert _decode_html(b"<html>ok</html>", "utf-8") == "<html>ok</html>"
    assert _decode_html("سلام".encode(), "utf-8") == "سلام"
    assert _decode_html("سلام".encode("windows-1256"), "windows-1256") == "سلام"
    assert _decode_html("سلام".encode(), "not-a-charset") == "سلام"
    assert "caf" in _decode_html(b"<html>caf\xe9</html>", None)


@pytest.mark.asyncio
async def test_redirects_are_followed_and_revalidated() -> None:
    first = FakeResponse(302, headers={"Location": "https://example.com/final"})
    second = _html()
    client, session = _client([first, second])
    page = await client.fetch_html("https://example.com/start", max_bytes=100_000)
    assert page.final_url == "https://example.com/final"
    assert page.html == "<html><body>ok</body></html>"
    assert page.requested_url == "https://example.com/start"
    assert len(session.requests) == 2
    assert session.requests[0][2]["allow_redirects"] is False
    assert first.closed is True


@pytest.mark.asyncio
async def test_redirect_to_disallowed_domain_is_rejected_before_request() -> None:
    redirect = FakeResponse(302, headers={"Location": "https://evil.test/steal"})
    client, session = _client([redirect])
    with pytest.raises(DomainNotAllowedError):
        await client.fetch_html("https://example.com/start", max_bytes=100_000)
    assert len(session.requests) == 1


@pytest.mark.asyncio
async def test_redirect_without_location_is_an_error() -> None:
    client, _ = _client([FakeResponse(302)])
    with pytest.raises(PermanentSiteError):
        await client.fetch_html("https://example.com/start", max_bytes=100_000)


@pytest.mark.asyncio
async def test_too_many_redirects_is_an_error() -> None:
    redirect = FakeResponse(302, headers={"Location": "/again"})
    client, _ = _client([redirect], max_redirects=0)
    with pytest.raises(PermanentSiteError):
        await client.fetch_html("https://example.com/start", max_bytes=100_000)


@pytest.mark.asyncio
async def test_fetch_html_success_and_content_type() -> None:
    client, _ = _client([_html("<html>\u0633\u0644\u0627\u0645</html>")])
    page = await client.fetch_html("https://example.com/page", max_bytes=100_000)
    assert page.status == 200
    assert page.content_type == "text/html"
    assert "\u0633\u0644\u0627\u0645" in page.html
    assert page.size > 0


@pytest.mark.asyncio
async def test_fetch_html_accepts_text_plain_html() -> None:
    response = FakeResponse(
        200,
        body=b"<!doctype html><html><body>ok</body></html>",
        headers={"Content-Type": "text/plain; charset=utf-8"},
    )
    client, _ = _client([response])
    page = await client.fetch_html("https://example.com/page", max_bytes=100_000)
    assert page.content_type == "text/plain"


@pytest.mark.asyncio
async def test_fetch_html_rejects_plain_text_and_binary() -> None:
    for headers in (
        {"Content-Type": "text/plain"},
        {"Content-Type": "application/json"},
        {"Content-Type": "image/png"},
    ):
        response = FakeResponse(200, body=b"not html at all", headers=headers)
        client, _ = _client([response])
        with pytest.raises(PermanentSiteError):
            await client.fetch_html("https://example.com/page", max_bytes=100_000)


@pytest.mark.asyncio
async def test_fetch_html_size_and_empty_checks() -> None:
    big = FakeResponse(
        200,
        body=b"x" * 100,
        headers={"Content-Type": "text/html", "Content-Length": "100"},
    )
    client, _ = _client([big])
    with pytest.raises(ContentTooLargeError):
        await client.fetch_html("https://example.com/page", max_bytes=10)

    chunked = _html("x" * 100)
    client, _ = _client([chunked])
    with pytest.raises(ContentTooLargeError):
        await client.fetch_html("https://example.com/page", max_bytes=10)

    client, _ = _client([FakeResponse(200, body=b"", headers={"Content-Type": "text/html"})])
    with pytest.raises(PermanentSiteError):
        await client.fetch_html("https://example.com/page", max_bytes=100)


@pytest.mark.asyncio
@pytest.mark.parametrize(
    ("status", "error", "retry_after"),
    [
        (429, RateLimitedError, 12.0),
        (401, AccessDeniedError, None),
        (403, AccessDeniedError, None),
        (404, NotFoundError, None),
        (410, NotFoundError, None),
        (500, NetworkError, None),
        (503, NetworkError, None),
        (418, PermanentSiteError, None),
    ],
)
async def test_fetch_html_http_statuses(
    status: int, error: type[Exception], retry_after: float | None
) -> None:
    response = FakeResponse(
        status,
        body=b"<html>error</html>",
        headers={"Content-Type": "text/html", "Retry-After": "12"},
    )
    client, _ = _client([response])
    with pytest.raises(error) as caught:
        await client.fetch_html("https://example.com/page", max_bytes=100_000)
    if isinstance(caught.value, RateLimitedError):
        assert caught.value.retry_after == retry_after


@pytest.mark.asyncio
async def test_fetch_bytes_and_prefix_validation() -> None:
    client, _ = _client([FakeResponse(200, body=b"\x89PNG\r\n\x1a\nrest")])
    raw = await client.fetch_bytes(
        "https://example.com/image.png",
        max_bytes=100,
        allowed_prefixes=(b"\x89PNG\r\n\x1a\n",),
    )
    assert raw.startswith(b"\x89PNG")

    client, _ = _client([FakeResponse(200, body=b"<html></html>")])
    with pytest.raises(InvalidMediaError):
        await client.fetch_bytes(
            "https://example.com/image.png",
            max_bytes=100,
            allowed_prefixes=(b"\x89PNG\r\n\x1a\n",),
        )

    client, _ = _client([FakeResponse(200, body=b"x" * 100)])
    with pytest.raises(ContentTooLargeError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)

    client, _ = _client([FakeResponse(200, body=b"")])
    with pytest.raises(ContentTooLargeError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)


@pytest.mark.asyncio
async def test_fetch_bytes_status_errors() -> None:
    client, _ = _client([FakeResponse(404)])
    with pytest.raises(NotFoundError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)
    client, _ = _client([FakeResponse(500)])
    with pytest.raises(NetworkError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)
    client, _ = _client([FakeResponse(403)])
    with pytest.raises(AccessDeniedError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)
    client, _ = _client([FakeResponse(429, headers={"Retry-After": "3"})])
    with pytest.raises(RateLimitedError):
        await client.fetch_bytes("https://example.com/x", max_bytes=10)


@pytest.mark.asyncio
async def test_stream_download_writes_and_reports_metadata(tmp_path: Path) -> None:
    body = b"\x00\x00\x00\x18ftypisom" + b"\x01" * 100
    response = FakeResponse(
        200,
        body=body,
        headers={
            "Content-Type": "video/mp4",
            "Content-Length": str(len(body)),
            "ETag": '"v1"',
            "Last-Modified": "Wed, 21 Oct 2026 07:28:00 GMT",
        },
    )
    client, _ = _client([response])
    destination = tmp_path / "video.part"
    metadata: list[DownloadMetadata] = []
    result = await client.stream_download(
        "https://example.com/video.mp4",
        destination,
        max_bytes=1000,
        on_metadata=metadata.append,
    )
    assert destination.read_bytes() == body
    assert result.size == len(body)
    assert result.total_size == len(body)
    assert result.resumed is False
    assert result.content_type == "video/mp4"
    assert result.etag == '"v1"'
    assert result.last_modified is not None
    assert metadata and metadata[0].total_size == len(body)
    assert response.closed is True


@pytest.mark.asyncio
async def test_stream_download_rejects_declared_large_file(tmp_path: Path) -> None:
    response = FakeResponse(
        200,
        body=b"x" * 20,
        headers={"Content-Type": "video/mp4", "Content-Length": "1000"},
    )
    client, _ = _client([response])
    destination = tmp_path / "video.part"
    with pytest.raises(ContentTooLargeError):
        await client.stream_download(
            "https://example.com/video.mp4", destination, max_bytes=100
        )
    assert not destination.exists()


@pytest.mark.asyncio
async def test_stream_download_rejects_html_content_type(tmp_path: Path) -> None:
    response = FakeResponse(
        200,
        body=b"<html><body>player</body></html>",
        headers={"Content-Type": "text/html; charset=utf-8"},
    )
    client, _ = _client([response])
    with pytest.raises(InvalidMediaError):
        await client.stream_download(
            "https://example.com/video.mp4", tmp_path / "v.part", max_bytes=1000
        )


@pytest.mark.asyncio
async def test_stream_download_resume_with_content_range(tmp_path: Path) -> None:
    destination = tmp_path / "video.part"
    destination.write_bytes(b"A" * 10)
    response = FakeResponse(
        206,
        body=b"B" * 10,
        headers={
            "Content-Type": "video/mp4",
            "Content-Range": "bytes 10-19/20",
            "Content-Length": "10",
            "ETag": '"v1"',
        },
    )
    client, session = _client([response])
    result = await client.stream_download(
        "https://example.com/video.mp4",
        destination,
        max_bytes=100,
        resume_from=10,
        if_range='"v1"',
    )
    assert result.resumed is True
    assert result.size == 20
    assert result.total_size == 20
    assert destination.read_bytes() == b"A" * 10 + b"B" * 10
    sent_headers = session.requests[0][2]["headers"]
    assert sent_headers["Range"] == "bytes=10-"
    assert sent_headers["If-Range"] == '"v1"'


@pytest.mark.asyncio
async def test_stream_download_full_response_after_range_truncates(tmp_path: Path) -> None:
    destination = tmp_path / "video.part"
    destination.write_bytes(b"old")
    body = b"full-new-body"
    response = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body))},
    )
    client, _ = _client([response])
    result = await client.stream_download(
        "https://example.com/video.mp4",
        destination,
        max_bytes=1000,
        resume_from=3,
        if_range='"v1"',
    )
    assert result.resumed is False
    assert result.size == len(body)
    assert destination.read_bytes() == body


@pytest.mark.asyncio
async def test_stream_download_restarts_after_416(tmp_path: Path) -> None:
    first = FakeResponse(416)
    body = b"complete-body"
    second = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body))},
    )
    client, session = _client([first, second])
    destination = tmp_path / "video.part"
    destination.write_bytes(b"partial")
    result = await client.stream_download(
        "https://example.com/video.mp4",
        destination,
        max_bytes=1000,
        resume_from=7,
        if_range='"v1"',
    )
    assert result.size == len(body)
    assert destination.read_bytes() == body
    assert len(session.requests) == 2
    assert first.closed is True


@pytest.mark.asyncio
async def test_stream_download_restarts_on_wrong_content_range(tmp_path: Path) -> None:
    wrong = FakeResponse(
        206,
        body=b"wrong",
        headers={
            "Content-Type": "video/mp4",
            "Content-Range": "bytes 0-4/10",
            "Content-Length": "5",
        },
    )
    body = b"fullbody"
    full = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body))},
    )
    client, session = _client([wrong, full])
    destination = tmp_path / "video.part"
    destination.write_bytes(b"0123456789")
    result = await client.stream_download(
        "https://example.com/video.mp4",
        destination,
        max_bytes=100,
        resume_from=10,
        if_range='"v1"',
    )
    assert len(session.requests) == 2
    assert result.resumed is False
    assert destination.read_bytes() == body


@pytest.mark.asyncio
async def test_stream_download_cancellation(tmp_path: Path) -> None:
    response = FakeResponse(
        200,
        body=b"x" * 100,
        headers={"Content-Type": "video/mp4", "Content-Length": "100"},
    )
    client, _ = _client([response])
    cancel_event = asyncio.Event()
    cancel_event.set()
    with pytest.raises(DownloadCancelledError):
        await client.stream_download(
            "https://example.com/video.mp4",
            tmp_path / "v.part",
            max_bytes=1000,
            cancel_event=cancel_event,
        )
    assert response.closed is True


@pytest.mark.asyncio
async def test_stream_download_http_errors(tmp_path: Path) -> None:
    client, _ = _client([FakeResponse(429, headers={"Retry-After": "4"})])
    with pytest.raises(RateLimitedError):
        await client.stream_download(
            "https://example.com/video.mp4", tmp_path / "v.part", max_bytes=100
        )
    client, _ = _client([FakeResponse(404)])
    with pytest.raises(NotFoundError):
        await client.stream_download(
            "https://example.com/video.mp4", tmp_path / "v.part", max_bytes=100
        )
    client, _ = _client([FakeResponse(500)])
    with pytest.raises(NetworkError):
        await client.stream_download(
            "https://example.com/video.mp4", tmp_path / "v.part", max_bytes=100
        )
    client, _ = _client([FakeResponse(418)])
    with pytest.raises(PermanentSiteError):
        await client.stream_download(
            "https://example.com/video.mp4", tmp_path / "v.part", max_bytes=100
        )

@pytest.mark.asyncio
async def test_probe_media_reads_headers_and_signature_only() -> None:
    body = b"\x00\x00\x00\x18ftypisom" + b"\x01" * 200
    response = FakeResponse(
        206,
        body=body,
        headers={
            "Content-Type": "video/mp4",
            "Content-Range": f"bytes 0-{len(body) - 1}/5000",
            "Content-Length": str(len(body)),
        },
    )
    client, session = _client([response])

    info = await client.probe_media("https://example.com/video.mp4", max_bytes=10_000)

    assert info.total_size == 5000
    assert info.mime_type == "video/mp4"
    assert info.extension == ".mp4"
    assert info.final_url == "https://example.com/video.mp4"
    assert response.closed is True
    assert session.requests[0][2]["headers"]["Range"] == "bytes=0-65535"


@pytest.mark.asyncio
async def test_probe_media_rejects_non_video_and_oversized_responses() -> None:
    html = FakeResponse(
        200,
        body=b"<html>no</html>",
        headers={"Content-Type": "text/html", "Content-Length": "14"},
    )
    client, _ = _client([html])
    with pytest.raises(InvalidMediaError):
        await client.probe_media("https://example.com/video.mp4", max_bytes=100)

    executable = FakeResponse(
        200,
        body=b"MZ" + b"\x00" * 100,
        headers={"Content-Type": "application/octet-stream", "Content-Length": "102"},
    )
    client, _ = _client([executable])
    with pytest.raises(InvalidMediaError):
        await client.probe_media("https://example.com/video.mp4", max_bytes=1000)

    oversized = FakeResponse(
        200,
        body=b"\x00\x00\x00\x18ftypisom" + b"\x00" * 10,
        headers={"Content-Type": "video/mp4", "Content-Length": "2000"},
    )
    client, _ = _client([oversized])
    with pytest.raises(ContentTooLargeError):
        await client.probe_media("https://example.com/video.mp4", max_bytes=1000)


@pytest.mark.asyncio
async def test_probe_media_rejects_partial_ranges_not_starting_at_zero() -> None:
    response = FakeResponse(
        206,
        body=b"\x00\x00\x00\x18ftypisom" + b"\x00" * 20,
        headers={
            "Content-Type": "video/mp4",
            "Content-Range": "bytes 10-30/100",
        },
    )
    client, _ = _client([response])
    with pytest.raises(PermanentSiteError):
        await client.probe_media("https://example.com/video.mp4", max_bytes=1000)


@pytest.mark.asyncio
async def test_iter_media_yields_validated_bytes_and_enforces_cap() -> None:
    body = b"\x00\x00\x00\x18ftypisom" + b"\x02" * 5000
    response = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body))},
        chunk_size=64,
    )
    client, _ = _client([response])

    chunks = [chunk async for chunk in client.iter_media(
        "https://example.com/video.mp4", max_bytes=10_000
    )]

    assert b"".join(chunks) == body
    assert response.closed is True
    assert len(chunks) > 1

    response = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body))},
    )
    client, _ = _client([response])
    with pytest.raises(ContentTooLargeError):
        _ = [chunk async for chunk in client.iter_media(
            "https://example.com/video.mp4", max_bytes=300
        )]
    assert response.closed is True


@pytest.mark.asyncio
async def test_iter_media_rejects_short_stream_and_cancellation() -> None:
    body = b"\x00\x00\x00\x18ftypisom" + b"\x03" * 40
    response = FakeResponse(
        200,
        body=body,
        headers={"Content-Type": "video/mp4", "Content-Length": "1000"},
        chunk_size=16,
    )
    client, _ = _client([response])
    with pytest.raises(NetworkError, match="ended early"):
        _ = [chunk async for chunk in client.iter_media(
            "https://example.com/video.mp4", max_bytes=10_000
        )]

    response = FakeResponse(
        200,
        body=body * 10,
        headers={"Content-Type": "video/mp4", "Content-Length": str(len(body) * 10)},
        chunk_size=16,
    )
    client, _ = _client([response])
    cancel_event = asyncio.Event()
    stream = client.iter_media(
        "https://example.com/video.mp4",
        max_bytes=10_000,
        cancel_event=cancel_event,
    )
    first = await anext(stream)
    assert first
    cancel_event.set()
    with pytest.raises(DownloadCancelledError):
        await anext(stream)
    assert response.closed is True
