"""Tests for ``mgf.apiprobe.core.probe``."""

from __future__ import annotations

import dataclasses

import pytest

from mgf.apiprobe.core.auth import AuthCredentials
from mgf.apiprobe.core.probe import Probe
from mgf.apiprobe.core.tag import Tag
from mgf.apiprobe.exceptions import ApiprobeConfigError


class TestConstructors:
    def test_get(self) -> None:
        p = Probe.get("/users")
        assert p.method == "GET"
        assert p.path == "/users"

    def test_post(self) -> None:
        assert Probe.post("/users").method == "POST"

    def test_put(self) -> None:
        assert Probe.put("/users/1").method == "PUT"

    def test_patch(self) -> None:
        assert Probe.patch("/users/1").method == "PATCH"

    def test_delete(self) -> None:
        assert Probe.delete("/users/1").method == "DELETE"

    def test_empty_path_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="must not be empty"):
            Probe.get("")


class TestImmutability:
    def test_builder_returns_new_probe(self) -> None:
        p = Probe.get("/users")
        p2 = p.id("list-users")
        assert p2.probe_id == "list-users"
        # Original unchanged. PAPER-42: Probe.get now seeds probe_id
        # with an auto-derived default (`GET_users`) so reports stay
        # readable when the consumer doesn't call .id(); the
        # immutability invariant is unaffected — `.id()` still
        # returns a new Probe and leaves the original alone.
        assert p.probe_id == "GET_users"

    def test_frozen(self) -> None:
        p = Probe.get("/x")
        with pytest.raises(dataclasses.FrozenInstanceError):
            p.method = "POST"  # type: ignore[misc]


class TestIdentityBuilders:
    def test_id(self) -> None:
        assert Probe.get("/x").id("my-probe").probe_id == "my-probe"

    # ── PAPER-42: auto-derived default probe_id ──────────────────────────

    def test_get_auto_derives_probe_id(self) -> None:
        # Reports reference probes by id; if .id() wasn't called, the
        # auto-derived default keeps the report readable instead of
        # showing an opaque label or empty string.
        assert Probe.get("/api/v1/me/profile/").probe_id == "GET_api_v1_me_profile"

    def test_post_auto_derives_probe_id(self) -> None:
        assert Probe.post("/users/").probe_id == "POST_users"

    def test_put_patch_delete_auto_derive(self) -> None:
        assert Probe.put("/users/42").probe_id == "PUT_users_42"
        assert Probe.patch("/users/42").probe_id == "PATCH_users_42"
        assert Probe.delete("/users/42").probe_id == "DELETE_users_42"

    def test_root_path_auto_derives_to_method_root(self) -> None:
        # `_root` suffix avoids a bare trailing underscore on `/`.
        assert Probe.get("/").probe_id == "GET_root"

    def test_path_with_unsafe_chars_cleaned(self) -> None:
        # %-encoded and other punctuation collapse to a single
        # underscore each, making the id ASCII-safe and grep-able.
        p = Probe.get("/a%20b/").probe_id
        assert p == "GET_a_20b"

    def test_explicit_id_overrides_default(self) -> None:
        p = Probe.get("/users/").id("list-users")
        assert p.probe_id == "list-users"

    def test_base(self) -> None:
        p = Probe.get("/users").base("https://api.example.com/v1")
        assert p.base_url == "https://api.example.com/v1"

    def test_with_description(self) -> None:
        p = Probe.get("/x").with_description("List users")
        assert p.description == "List users"

    def test_with_tags_strings(self) -> None:
        p = Probe.get("/x").with_tags("read", "public")
        assert p.tags == ("read", "public")

    def test_with_tags_enum(self) -> None:
        p = Probe.get("/x").with_tags(Tag.READ, Tag.PUBLIC)
        assert p.tags == ("read", "public")

    def test_with_tags_appends(self) -> None:
        p = Probe.get("/x").with_tags("a").with_tags("b", "c")
        assert p.tags == ("a", "b", "c")

    def test_with_tags_iter_helper(self) -> None:
        p = Probe.get("/x").with_tags_iter([Tag.READ, "extra"])
        assert p.tags == ("read", "extra")


class TestRequestBuilders:
    def test_header_appends(self) -> None:
        p = Probe.get("/x").header("Accept", "application/json")
        assert p.headers == (("Accept", "application/json"),)

    def test_header_duplicates_preserved(self) -> None:
        # HTTP allows duplicate headers; preserve them.
        p = Probe.get("/x").header("X-Custom", "a").header("X-Custom", "b")
        assert p.headers == (("X-Custom", "a"), ("X-Custom", "b"))

    def test_query_string_value(self) -> None:
        p = Probe.get("/x").query("limit", "50")
        assert p.query_params == (("limit", "50"),)

    def test_query_int_value_coerced(self) -> None:
        p = Probe.get("/x").query("limit", 50)
        assert p.query_params == (("limit", "50"),)

    def test_body_post(self) -> None:
        p = Probe.post("/users").body({"email": "a@b.c"})
        assert p.json_body == {"email": "a@b.c"}

    def test_body_replaces(self) -> None:
        p = Probe.post("/x").body({"a": 1}).body({"b": 2})
        assert p.json_body == {"b": 2}

    def test_body_on_get_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="should not carry"):
            Probe.get("/x").body({"a": 1})

    def test_body_on_delete_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="should not carry"):
            Probe.delete("/x").body({"a": 1})

    def test_body_none_on_get_allowed(self) -> None:
        # Setting None back to None is fine; "no body" is the default.
        p = Probe.get("/x").body(None)
        assert p.json_body is None


class TestExpectations:
    def test_expect_status_single(self) -> None:
        p = Probe.get("/x").expect_status(200)
        assert p.expected_statuses == (200,)

    def test_expect_status_variadic(self) -> None:
        p = Probe.get("/x").expect_status(200, 304)
        assert p.expected_statuses == (200, 304)

    def test_expect_status_chained(self) -> None:
        p = Probe.get("/x").expect_status(200).expect_status(304)
        assert p.expected_statuses == (200, 304)

    @pytest.mark.parametrize("bad", [99, 600, 1000, -1])
    def test_expect_status_invalid_rejected(self, bad: int) -> None:
        with pytest.raises(ApiprobeConfigError, match="not a valid HTTP status"):
            Probe.get("/x").expect_status(bad)

    def test_expect_header(self) -> None:
        p = Probe.get("/x").expect_header("X-Request-Id")
        assert p.expected_headers == ("X-Request-Id",)

    def test_expect_header_chained(self) -> None:
        p = Probe.get("/x").expect_header("X-A").expect_header("X-B")
        assert p.expected_headers == ("X-A", "X-B")

    def test_expect_header_empty_rejected(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="must not be empty"):
            Probe.get("/x").expect_header("")


class TestTimeout:
    def test_with_timeout(self) -> None:
        p = Probe.get("/x").with_timeout(5.0)
        assert p.timeout_seconds == 5.0

    @pytest.mark.parametrize("bad", [0, -1.0])
    def test_timeout_must_be_positive(self, bad: float) -> None:
        with pytest.raises(ApiprobeConfigError, match="positive"):
            Probe.get("/x").with_timeout(bad)


class TestAuth:
    def test_with_auth_stores_flow(self) -> None:
        class FakeBearer:
            name = "fake"

            def prepare(self) -> AuthCredentials:
                return AuthCredentials()

        flow = FakeBearer()
        p = Probe.get("/x").with_auth(flow)
        assert p.auth is flow


class TestExpectSchema:
    def test_with_pydantic_model(self) -> None:
        from pydantic import BaseModel

        from mgf.apiprobe.schema.pydantic import PydanticSchemaSource

        class _M(BaseModel):
            x: int

        p = Probe.get("/x").expect_schema(_M)
        assert isinstance(p.expected_schema, PydanticSchemaSource)
        assert p.expected_schema.model is _M

    def test_with_schema_source_directly(self) -> None:
        from pydantic import BaseModel

        from mgf.apiprobe.schema.pydantic import PydanticSchemaSource

        class _M(BaseModel):
            x: int

        source = PydanticSchemaSource(_M)
        p = Probe.get("/x").expect_schema(source)
        assert p.expected_schema is source

    def test_rejects_non_schema_non_basemodel(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="SchemaSource or pydantic"):
            Probe.get("/x").expect_schema("not a schema")  # type: ignore[arg-type]

    def test_rejects_non_basemodel_class(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="SchemaSource or pydantic"):
            Probe.get("/x").expect_schema(dict)  # type: ignore[arg-type]


class TestExpectLatencyMs:
    def test_sets_threshold(self) -> None:
        p = Probe.get("/x").expect_latency_ms(500)
        assert p.expected_latency_ms == 500

    @pytest.mark.parametrize("bad", [0, -1.0])
    def test_rejects_non_positive(self, bad: float) -> None:
        with pytest.raises(ApiprobeConfigError, match="positive"):
            Probe.get("/x").expect_latency_ms(bad)


class TestAbsoluteUrl:
    def test_absolute_path_without_base(self) -> None:
        p = Probe.get("https://example.com/users")
        assert p.absolute_url() == "https://example.com/users"

    def test_relative_path_with_base(self) -> None:
        p = Probe.get("/users").base("https://example.com/v1")
        assert p.absolute_url() == "https://example.com/v1/users"

    def test_relative_path_with_base_trailing_slash(self) -> None:
        p = Probe.get("/users").base("https://example.com/v1/")
        assert p.absolute_url() == "https://example.com/v1/users"

    def test_relative_path_no_leading_slash(self) -> None:
        p = Probe.get("users").base("https://example.com/v1/")
        assert p.absolute_url() == "https://example.com/v1/users"

    def test_relative_without_base_raises(self) -> None:
        with pytest.raises(ApiprobeConfigError, match="relative but base_url"):
            Probe.get("/users").absolute_url()

    def test_absolute_path_ignores_base(self) -> None:
        p = Probe.get("https://other.com/users").base("https://example.com")
        assert p.absolute_url() == "https://other.com/users"


class TestFluentChainSmoke:
    def test_full_chain(self) -> None:
        # End-to-end smoke that the README-style chain compiles + fields populate.
        p = (
            Probe.get("/users")
            .id("list-users")
            .base("https://api.example.com/v1")
            .header("Accept", "application/json")
            .query("limit", 50)
            .expect_status(200)
            .expect_header("X-Request-Id")
            .with_tags(Tag.READ, Tag.PUBLIC)
            .with_description("List the first page of users")
            .with_timeout(5.0)
        )
        assert p.method == "GET"
        assert p.probe_id == "list-users"
        assert p.absolute_url() == "https://api.example.com/v1/users"
        assert p.headers == (("Accept", "application/json"),)
        assert p.query_params == (("limit", "50"),)
        assert p.expected_statuses == (200,)
        assert p.expected_headers == ("X-Request-Id",)
        assert p.tags == ("read", "public")
        assert p.description == "List the first page of users"
        assert p.timeout_seconds == 5.0
