from collections.abc import Callable
from datetime import datetime, timezone
from email import policy
from email.parser import BytesParser

import httpx
import pytest

from abuseipdb import AbuseCategory, AbuseIPDB
from abuseipdb.exceptions import (
    InvalidParameterError,
    MissingAPIKeyError,
    PaymentRequiredError,
    TooManyRequestsError,
    UnconventionalAPIError,
    UnprocessableContentError,
)
from abuseipdb.responses import (
    BlacklistPlaintextResponse,
    BlacklistResponse,
    BulkReportResponse,
    CheckBlockResponse,
    CheckResponse,
    ClearAddressResponse,
    ReportResponse,
    ReportsPaginatedResponse,
)


def minimal_payload(path: str) -> dict[str, object]:
    payloads: dict[str, dict[str, object]] = {
        "/v2/check": {
            "data": {
                "ipAddress": "8.8.8.8",
                "isPublic": True,
                "ipVersion": 4,
                "abuseConfidenceScore": 0,
                "countryCode": "US",
                "usageType": "Content Delivery Network",
                "isp": "Example",
                "hostnames": [],
                "isTor": False,
                "totalReports": 0,
                "numDistinctUsers": 0,
                "lastReportedAt": None,
            }
        },
        "/v2/report": {"data": {"ipAddress": "203.0.113.1", "abuseConfidenceScore": 1}},
        "/v2/reports": {
            "data": {
                "total": 0,
                "page": 1,
                "count": 0,
                "perPage": 25,
                "lastPage": 1,
                "nextPageUrl": None,
                "previousPageUrl": None,
                "results": [],
            }
        },
        "/v2/blacklist": {"meta": {"generatedAt": "2026-01-01T00:00:00Z"}, "data": []},
        "/v2/check-block": {
            "data": {
                "networkAddress": "127.0.0.0",
                "netmask": "255.255.255.0",
                "minAddress": "127.0.0.0",
                "maxAddress": "127.0.0.255",
                "numPossibleHosts": 256,
                "addressSpaceDesc": "Loopback",
                "reportedAddress": [],
            }
        },
        "/v2/bulk-report": {"data": {"savedReports": 1}},
        "/v2/clear-address": {"data": {"numReportsDeleted": 1}},
    }
    return payloads[path]


def recording_client(
    requests: list[httpx.Request],
    *,
    api_key: str | None = "explicit-key",
    handler: Callable[[httpx.Request], httpx.Response] | None = None,
) -> AbuseIPDB:
    def default_handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(200, json=minimal_payload(request.url.path))

    return AbuseIPDB(
        api_key=api_key,
        base_url="https://example.test/v2",
        timeout=7.5,
        transport=httpx.MockTransport(handler or default_handler),
    )


def form_values(request: httpx.Request) -> dict[str, str]:
    return dict(httpx.QueryParams(request.content.decode()))


def test_explicit_key_precedes_environment_and_transport_headers(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("ABUSEIPDB_API_KEY", "environment-key")
    requests: list[httpx.Request] = []
    client = recording_client(requests)
    result = client.check("8.8.8.8")
    client.close()
    request = requests[0]
    assert isinstance(result, CheckResponse)
    assert request.headers["Key"] == "explicit-key"
    assert request.headers["Accept"] == "application/json"
    assert request.headers["X-Request-Source"].startswith("Python_3.")
    assert request.headers["X-Request-Source"].endswith(";AbuseIPDB-Python_0.1.0;")
    assert str(request.url) == "https://example.test/v2/check?ipAddress=8.8.8.8&maxAgeInDays=30"


def test_environment_key_is_used(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("ABUSEIPDB_API_KEY", "environment-key")
    requests: list[httpx.Request] = []
    with recording_client(requests, api_key=None) as client:
        client.check("8.8.8.8")
    assert requests[0].headers["Key"] == "environment-key"


def test_environment_base_url_is_used_and_explicit_value_takes_precedence(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setenv("ABUSEIPDB_URL", "http://abuseipdb.localhost/api/v2")
    environment_requests: list[httpx.Request] = []

    def environment_handler(request: httpx.Request) -> httpx.Response:
        environment_requests.append(request)
        return httpx.Response(200, json=minimal_payload(request.url.path.replace("/api", "")))

    with AbuseIPDB(api_key="key", transport=httpx.MockTransport(environment_handler)) as client:
        client.check("8.8.8.8")

    explicit_requests: list[httpx.Request] = []
    with recording_client(explicit_requests) as client:
        client.check("8.8.8.8")

    assert environment_requests[0].url.host == "abuseipdb.localhost"
    assert environment_requests[0].url.path == "/api/v2/check"
    assert explicit_requests[0].url.host == "example.test"


def test_missing_or_blank_key_fails_before_network(monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.delenv("ABUSEIPDB_API_KEY", raising=False)
    requests: list[httpx.Request] = []
    with recording_client(requests, api_key="   ") as client:
        with pytest.raises(MissingAPIKeyError):
            client.check("8.8.8.8")
    assert requests == []


def test_check_serializes_verbose_only_when_true_and_supports_ipv6() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.check("2001:db8::1", max_age_in_days=365, verbose=True)
        client.check("8.8.8.8", verbose=False)
    assert dict(requests[0].url.params) == {
        "ipAddress": "2001:db8::1",
        "maxAgeInDays": "365",
        "verbose": "true",
    }
    assert "verbose" not in requests[1].url.params


@pytest.mark.parametrize("value", [0, 366])
@pytest.mark.parametrize("method", ["check", "reports", "check_block"])
def test_max_age_validation_rejects_outside_laravel_bounds(method: str, value: int) -> None:
    requests: list[httpx.Request] = []
    with (
        recording_client(requests) as client,
        pytest.raises(InvalidParameterError, match="max_age_in_days must be between 1 and 365"),
    ):
        if method == "check_block":
            client.check_block("127.0.0.0/24", max_age_in_days=value)
        else:
            getattr(client, method)("127.0.0.1", max_age_in_days=value)
    assert requests == []


@pytest.mark.parametrize("value", [1, 365])
def test_max_age_boundary_values_are_valid(value: int) -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.check("8.8.8.8", max_age_in_days=value)
    assert requests[0].url.params["maxAgeInDays"] == str(value)


def test_report_posts_form_and_serializes_categories_timestamp_and_optional_comment() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        result = client.report(
            "203.0.113.1",
            [AbuseCategory.BRUTE_FORCE, 22],
            comment="Repeated SSH attempts",
            timestamp=datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
        )
    assert isinstance(result, ReportResponse)
    assert requests[0].method == "POST"
    assert form_values(requests[0]) == {
        "ip": "203.0.113.1",
        "categories": "18,22",
        "comment": "Repeated SSH attempts",
        "timestamp": "2026-01-02T03:04:05+00:00",
    }


def test_report_accepts_single_category_and_omits_none_optionals() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.report("203.0.113.1", 21)
    assert form_values(requests[0]) == {"ip": "203.0.113.1", "categories": "21"}


@pytest.mark.parametrize("categories", [[], [0], [24], [18, 99]])
def test_report_rejects_empty_or_invalid_categories(categories: list[int]) -> None:
    with recording_client([]) as client, pytest.raises(InvalidParameterError):
        client.report("203.0.113.1", categories)


def test_report_rejects_non_datetime_timestamp() -> None:
    with recording_client([]) as client, pytest.raises(InvalidParameterError, match="timestamp"):
        client.report("203.0.113.1", 18, timestamp="2026-01-01")  # type: ignore[arg-type]


def test_reports_get_serializes_pagination_and_returns_typed_response() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        result = client.reports("2001:db8::1", max_age_in_days=90, page=2, per_page=100)
    assert isinstance(result, ReportsPaginatedResponse)
    assert requests[0].method == "GET"
    assert dict(requests[0].url.params) == {
        "ipAddress": "2001:db8::1",
        "maxAgeInDays": "90",
        "page": "2",
        "perPage": "100",
    }


@pytest.mark.parametrize(("page", "per_page"), [(0, 25), (1, 0), (1, 101)])
def test_reports_rejects_invalid_pagination(page: int, per_page: int) -> None:
    with recording_client([]) as client, pytest.raises(InvalidParameterError):
        client.reports("8.8.8.8", page=page, per_page=per_page)


def test_reports_pagination_boundaries_are_valid() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.reports("8.8.8.8", page=1, per_page=1)
        client.reports("8.8.8.8", page=1, per_page=100)
    assert len(requests) == 2


def test_blacklist_json_serializes_filters_as_api_csv() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        result = client.blacklist(
            confidence_minimum=25,
            limit=500,
            only_countries=["US", "CA"],
            except_countries=["GB"],
            ip_version=6,
        )
    assert isinstance(result, BlacklistResponse)
    assert dict(requests[0].url.params) == {
        "confidenceMinimum": "25",
        "limit": "500",
        "ipVersion": "6",
        "onlyCountries": "US,CA",
        "exceptCountries": "GB",
    }


def test_blacklist_plaintext_changes_accept_and_parsing() -> None:
    requests: list[httpx.Request] = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(request)
        return httpx.Response(
            200,
            text="1.2.3.4\n",
            headers={"X-Generated-At": "2026-01-01T00:00:00Z"},
        )

    with recording_client(requests, handler=handler) as client:
        result = client.blacklist(plaintext=True)
    assert isinstance(result, BlacklistPlaintextResponse)
    assert requests[0].headers["Accept"] == "text/plain"
    assert requests[0].url.params["plaintext"] == "true"
    assert result.addresses == ("1.2.3.4",)


@pytest.mark.parametrize("confidence", [24, 101])
def test_blacklist_rejects_confidence_outside_bounds(confidence: int) -> None:
    with recording_client([]) as client, pytest.raises(InvalidParameterError):
        client.blacklist(confidence_minimum=confidence)


@pytest.mark.parametrize("confidence", [25, 100])
def test_blacklist_confidence_boundaries_are_valid(confidence: int) -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.blacklist(confidence_minimum=confidence)
    assert requests[0].url.params["confidenceMinimum"] == str(confidence)


@pytest.mark.parametrize(
    ("kwargs", "message"),
    [
        ({"only_countries": ["USA"]}, "Country codes"),
        ({"except_countries": ["U"]}, "Country codes"),
        ({"ip_version": 5}, "ip_version"),
    ],
)
def test_blacklist_rejects_invalid_filters(kwargs: dict[str, object], message: str) -> None:
    with recording_client([]) as client, pytest.raises(InvalidParameterError, match=message):
        client.blacklist(**kwargs)  # type: ignore[arg-type]


def test_blacklist_omits_empty_optional_filters_and_accepts_ip_versions() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.blacklist(ip_version=4)
        client.blacklist(ip_version=6)
        client.blacklist()
    assert requests[0].url.params["ipVersion"] == "4"
    assert requests[1].url.params["ipVersion"] == "6"
    assert "ipVersion" not in requests[2].url.params
    assert "onlyCountries" not in requests[2].url.params


def test_check_block_get_and_clear_address_delete_use_documented_fields() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        block = client.check_block("2001:db8::/64")
        clear = client.clear_address("2001:db8::1")
    assert isinstance(block, CheckBlockResponse)
    assert isinstance(clear, ClearAddressResponse)
    assert requests[0].method == "GET"
    assert dict(requests[0].url.params) == {"network": "2001:db8::/64", "maxAgeInDays": "30"}
    assert requests[1].method == "DELETE"
    assert form_values(requests[1]) == {"ipAddress": "2001:db8::1"}


def test_bulk_report_multipart_preserves_required_name_filename_and_contents() -> None:
    requests: list[httpx.Request] = []
    csv = 'IP,Categories,ReportDate,Comment\n203.0.113.1,"18,22",,"scan"\n'
    with recording_client(requests) as client:
        result = client.bulk_report(csv)
    assert isinstance(result, BulkReportResponse)
    request = requests[0]
    assert request.method == "POST"
    content_type = request.headers["Content-Type"]
    message = BytesParser(policy=policy.default).parsebytes(
        f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode() + request.content
    )
    part = next(message.iter_parts())
    assert part.get_param("name", header="content-disposition") == "csv"
    assert part.get_filename() == "report.csv"
    assert part.get_payload(decode=True) == csv.encode()


def test_bulk_report_accepts_bytes() -> None:
    requests: list[httpx.Request] = []
    with recording_client(requests) as client:
        client.bulk_report(b"IP,Categories\n203.0.113.1,18\n")
    assert b"203.0.113.1" in requests[0].content


@pytest.mark.parametrize(
    ("status", "error_type"),
    [
        (402, PaymentRequiredError),
        (422, UnprocessableContentError),
        (429, TooManyRequestsError),
        (400, UnconventionalAPIError),
        (401, UnconventionalAPIError),
        (403, UnconventionalAPIError),
        (404, UnconventionalAPIError),
        (500, UnconventionalAPIError),
    ],
)
def test_http_status_maps_to_typed_error(status: int, error_type: type[Exception]) -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(status, json={"errors": [{"detail": "API detail", "status": status}]})

    with recording_client([], handler=handler) as client, pytest.raises(error_type) as caught:
        client.check("8.8.8.8")
    assert caught.value.status_code == status
    assert caught.value.detail == "API detail"
    assert f"Status code: {status}. Detail: API detail" in str(caught.value)


def test_malformed_non_json_error_remains_useful() -> None:
    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(503, text="upstream unavailable")

    with (
        recording_client([], handler=handler) as client,
        pytest.raises(UnconventionalAPIError) as caught,
    ):
        client.check("8.8.8.8")
    assert caught.value.detail is None
    assert str(caught.value) == "AbuseIPDB: An error occurred. Status code: 503."


def test_context_manager_closes_underlying_client() -> None:
    client = recording_client([])
    with client:
        pass
    with pytest.raises(RuntimeError, match="closed"):
        client.check("8.8.8.8")
