"""PAPER-39: probe-to-probe data threading.

Covers ``Probe.depends_on`` (builder + dataclass), ``Context.with_added``
+ ``resolve_template`` (Context module), and the Suite's topological
execution path (validation, happy paths, cascade-skip on failure).

These tests use inline stub transports so the suite layer is
exercised end-to-end without httpx / respx.
"""

from __future__ import annotations

import json

import pytest

from mgf.apiprobe.checks.contract import StatusUnexpected
from mgf.apiprobe.core.context import Context, resolve_template
from mgf.apiprobe.core.finding import Severity
from mgf.apiprobe.core.probe import Probe, ProbeDependency
from mgf.apiprobe.core.response import Response
from mgf.apiprobe.core.suite import Suite
from mgf.apiprobe.core.transport import Transport
from mgf.apiprobe.exceptions import SuiteLogicError, TransportError

# ---------------------------------------------------------------------------
# Stub transports
# ---------------------------------------------------------------------------


class ResponseMapTransport(Transport):
    """Returns per-probe canned responses keyed by probe_id.

    PAPER-39 tests need different bodies per probe so the
    dependent probe can extract from the upstream probe's response.
    """

    def __init__(
        self,
        responses: dict[str, Response],
        *,
        default: Response | None = None,
    ) -> None:
        self._responses = responses
        self._default = default
        self.calls: list[Probe] = []

    async def send(self, probe: Probe, ctx: Context) -> Response:
        self.calls.append(probe)
        if probe.probe_id in self._responses:
            return self._responses[probe.probe_id]
        if self._default is not None:
            return self._default
        raise TransportError(
            url=probe.absolute_url(),
            cause=f"no canned response for probe_id={probe.probe_id!r}",
        )


def _resp(body: object, *, status: int = 200) -> Response:
    body_bytes = json.dumps(body).encode("utf-8") if not isinstance(body, bytes) else body
    return Response(
        url="https://x.test/y",
        status=status,
        headers={"content-type": "application/json"},
        body=body_bytes,
        elapsed_ms=10.0,
    )


# ---------------------------------------------------------------------------
# Probe.depends_on builder
# ---------------------------------------------------------------------------


class TestProbeDependsOnBuilder:
    def test_builder_attaches_dependency(self) -> None:
        p = (
            Probe.get("/users/{user_id}/")
            .id("get-user")
            .depends_on("create-user", extract={"user_id": "$.id"})
        )
        assert len(p.depends_on_probes) == 1
        dep = p.depends_on_probes[0]
        assert dep.probe_id == "create-user"
        assert dep.extract == (("user_id", "$.id"),)

    def test_builder_accumulates_multiple_dependencies(self) -> None:
        p = (
            Probe.get("/{a}/{b}/")
            .id("two-deps")
            .depends_on("dep-a", extract={"a": "$.a"})
            .depends_on("dep-b", extract={"b": "$.b"})
        )
        ids = [d.probe_id for d in p.depends_on_probes]
        assert ids == ["dep-a", "dep-b"]

    def test_builder_returns_new_probe(self) -> None:
        # Immutability: builder returns a new Probe; original unchanged.
        p1 = Probe.get("/x/").id("p1")
        p2 = p1.depends_on("upstream", extract={"x": "$.x"})
        assert p1.depends_on_probes == ()
        assert len(p2.depends_on_probes) == 1


class TestProbeDependencyDataclass:
    def test_frozen(self) -> None:
        import dataclasses

        dep = ProbeDependency(probe_id="x", extract=(("v", "$.v"),))
        with pytest.raises(dataclasses.FrozenInstanceError):
            dep.probe_id = "y"  # type: ignore[misc]


# ---------------------------------------------------------------------------
# Context.with_added + resolve_template
# ---------------------------------------------------------------------------


class TestContextWithAdded:
    def test_adds_new_variable(self) -> None:
        c1 = Context.with_variables({"a": 1})
        c2 = c1.with_added("b", 2)
        assert c2.get("a") == 1
        assert c2.get("b") == 2
        # c1 unchanged.
        assert c1.get("b") is None

    def test_overrides_existing(self) -> None:
        c1 = Context.with_variables({"k": "old"})
        c2 = c1.with_added("k", "new")
        assert c2.get("k") == "new"
        assert c1.get("k") == "old"


class TestResolveTemplate:
    def test_basic_substitution(self) -> None:
        ctx = Context.with_variables({"slug": "general"})
        assert resolve_template("/rooms/{slug}/messages/", ctx) == ("/rooms/general/messages/")

    def test_no_placeholders_returns_unchanged(self) -> None:
        ctx = Context.with_variables({"slug": "x"})
        assert resolve_template("/no/template/here", ctx) == "/no/template/here"

    def test_literal_braces_via_doubling(self) -> None:
        ctx = Context.with_variables({})
        # `{{` and `}}` are literal braces per str.format semantics.
        assert resolve_template("Literal: {{x}}", ctx) == "Literal: {x}"

    def test_missing_variable_raises_keyerror(self) -> None:
        ctx = Context.with_variables({"a": "x"})
        with pytest.raises(KeyError) as exc_info:
            resolve_template("/missing/{undefined}/", ctx)
        msg = str(exc_info.value)
        assert "undefined" in msg
        assert "available variables" in msg.lower()


# ---------------------------------------------------------------------------
# Suite construction validation (PAPER-39 phase 4)
# ---------------------------------------------------------------------------


class TestSuiteValidation:
    def test_dangling_probe_id_in_depends_on_raises(self) -> None:
        a = Probe.get("/a").id("a")
        b = Probe.get("/b").id("b").depends_on("does-not-exist", extract={"x": "$.x"})
        with pytest.raises(SuiteLogicError, match="does-not-exist"):
            Suite(probes=[a, b], checks=[])

    def test_self_referential_cycle_raises(self) -> None:
        a = Probe.get("/a").id("a").depends_on("a", extract={"x": "$.x"})
        with pytest.raises(SuiteLogicError, match="cycle"):
            Suite(probes=[a], checks=[])

    def test_two_node_cycle_raises(self) -> None:
        a = Probe.get("/a").id("a").depends_on("b", extract={"x": "$.x"})
        b = Probe.get("/b").id("b").depends_on("a", extract={"y": "$.y"})
        with pytest.raises(SuiteLogicError, match="cycle"):
            Suite(probes=[a, b], checks=[])

    def test_no_deps_suite_constructs_cleanly(self) -> None:
        # Back-compat: existing suites with no deps must still construct
        # without raising.
        suite = Suite(
            probes=[Probe.get("/a").id("a"), Probe.get("/b").id("b")],
            checks=[],
        )
        # Topological order matches input order when no deps exist.
        assert [p.probe_id for p in suite._topological_order] == ["a", "b"]


# ---------------------------------------------------------------------------
# Suite topological run (PAPER-39 phase 3)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestSuiteTopologicalRun:
    async def test_linear_chain_passes_extracted_value(self) -> None:
        # Upstream returns {"slug": "general"}; downstream substitutes
        # {slug} in its path.
        create = (
            Probe.post("/rooms/").id("create-room").body({"name": "general"}).expect_status(201)
        )
        verify = (
            Probe.get("/rooms/{slug}/messages/")
            .id("verify-room")
            .depends_on("create-room", extract={"slug": "$.slug"})
            .expect_status(200)
        )
        transport = ResponseMapTransport(
            {
                "create-room": _resp({"slug": "general"}, status=201),
                "verify-room": _resp([], status=200),
            }
        )
        suite = Suite(probes=[create, verify], checks=[])
        report = await suite.run(transport=transport)

        # Both probes executed.
        assert len(transport.calls) == 2
        # The downstream probe's path was resolved.
        verify_call = next(c for c in transport.calls if c.probe_id == "verify-room")
        assert verify_call.path == "/rooms/general/messages/"
        # No fatal findings.
        assert all(f.severity != Severity.CRITICAL for f in report.findings)

    async def test_token_then_header_template(self) -> None:
        # The auth-bootstrap idiom from the recipe: extract a token,
        # substitute into Authorization header on subsequent probes.
        auth = (
            Probe.post("/auth/token/")
            .id("auth-bootstrap")
            .body({"username": "alice", "password": "x"})
            .expect_status(200)
        )
        me = (
            Probe.get("/me/")
            .id("profile")
            .depends_on("auth-bootstrap", extract={"tok": "$.token"})
            .header("Authorization", "Token {tok}")
            .expect_status(200)
        )
        transport = ResponseMapTransport(
            {
                "auth-bootstrap": _resp({"token": "abc-123"}, status=200),
                "profile": _resp({"username": "alice"}, status=200),
            }
        )
        suite = Suite(probes=[auth, me], checks=[])
        await suite.run(transport=transport)

        profile_call = next(c for c in transport.calls if c.probe_id == "profile")
        auth_value = dict(profile_call.headers)["Authorization"]
        assert auth_value == "Token abc-123"

    async def test_branching_one_upstream_two_downstreams(self) -> None:
        # One upstream feeds two downstreams.
        upstream = Probe.post("/seed/").id("seed").body({"name": "x"}).expect_status(201)
        a = Probe.get("/a/{slug}/").id("a").depends_on("seed", extract={"slug": "$.slug"})
        b = Probe.get("/b/{slug}/").id("b").depends_on("seed", extract={"slug": "$.slug"})
        transport = ResponseMapTransport(
            {
                "seed": _resp({"slug": "the-slug"}, status=201),
                "a": _resp([], status=200),
                "b": _resp([], status=200),
            }
        )
        suite = Suite(probes=[upstream, a, b], checks=[])
        await suite.run(transport=transport)

        a_call = next(c for c in transport.calls if c.probe_id == "a")
        b_call = next(c for c in transport.calls if c.probe_id == "b")
        assert a_call.path == "/a/the-slug/"
        assert b_call.path == "/b/the-slug/"

    async def test_query_param_template(self) -> None:
        # Templates also resolve in query-param values.
        upstream = Probe.get("/q/").id("q-up")
        downstream = (
            Probe.get("/lookup/")
            .id("lookup")
            .depends_on("q-up", extract={"q": "$.q"})
            .query("term", "{q}")
        )
        transport = ResponseMapTransport(
            {
                "q-up": _resp({"q": "search"}, status=200),
                "lookup": _resp([], status=200),
            }
        )
        suite = Suite(probes=[upstream, downstream], checks=[])
        await suite.run(transport=transport)

        lookup_call = next(c for c in transport.calls if c.probe_id == "lookup")
        assert dict(lookup_call.query_params)["term"] == "search"

    async def test_nested_jsonpath(self) -> None:
        # JSONPath can reach into nested objects.
        upstream = Probe.post("/users/").id("create")
        verify = (
            Probe.get("/users/{user_id}/")
            .id("verify")
            .depends_on("create", extract={"user_id": "$.user.id"})
        )
        transport = ResponseMapTransport(
            {
                "create": _resp({"user": {"id": "u-42"}}, status=201),
                "verify": _resp({}, status=200),
            }
        )
        suite = Suite(probes=[upstream, verify], checks=[])
        await suite.run(transport=transport)

        verify_call = next(c for c in transport.calls if c.probe_id == "verify")
        assert verify_call.path == "/users/u-42/"


# ---------------------------------------------------------------------------
# Suite topological run — failure paths
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestTopologicalFailurePaths:
    async def test_transport_error_cascades_skip_to_downstream(self) -> None:
        upstream = Probe.post("/auth/").id("auth")
        downstream = Probe.get("/me/").id("me").depends_on("auth", extract={"tok": "$.token"})

        # Upstream raises TransportError; downstream must be skipped.
        class HalfFailingTransport(Transport):
            async def send(self, probe: Probe, ctx: Context) -> Response:
                if probe.probe_id == "auth":
                    raise TransportError(url="https://x", cause="DNS NXDOMAIN")
                return _resp({}, status=200)

        suite = Suite(probes=[upstream, downstream], checks=[])
        report = await suite.run(transport=HalfFailingTransport())

        codes = [f.code for f in report.findings]
        # Upstream emits the transport finding.
        assert "transport.network.failure" in codes
        # Downstream emits the cascade-skip finding.
        assert "suite.deps.skipped_due_to_upstream" in codes
        skip = next(f for f in report.findings if f.code == "suite.deps.skipped_due_to_upstream")
        assert skip.evidence.get("failed_upstream") == "auth"

    async def test_extraction_no_match_emits_typed_finding(self) -> None:
        upstream = Probe.post("/auth/").id("auth")
        downstream = Probe.get("/me/").id("me").depends_on("auth", extract={"tok": "$.token"})
        # Upstream returns 200 but the body has no `token` key.
        transport = ResponseMapTransport(
            {
                "auth": _resp({"unrelated": "field"}, status=200),
                "me": _resp({}, status=200),
            }
        )
        suite = Suite(probes=[upstream, downstream], checks=[])
        report = await suite.run(transport=transport)

        ex_findings = [f for f in report.findings if f.code == "suite.deps.extraction_failed"]
        assert len(ex_findings) == 1
        assert ex_findings[0].evidence["cause"] == "jsonpath-no-match"

    async def test_extraction_non_json_body_emits_typed_finding(self) -> None:
        upstream = Probe.post("/auth/").id("auth")
        downstream = Probe.get("/me/").id("me").depends_on("auth", extract={"tok": "$.token"})
        # Upstream body isn't valid JSON.
        bad_resp = Response(
            url="https://x",
            status=200,
            headers={},
            body=b"not json at all",
            elapsed_ms=10.0,
        )
        transport = ResponseMapTransport(
            {
                "auth": bad_resp,
                "me": _resp({}, status=200),
            }
        )
        suite = Suite(probes=[upstream, downstream], checks=[])
        report = await suite.run(transport=transport)

        ex_findings = [f for f in report.findings if f.code == "suite.deps.extraction_failed"]
        assert len(ex_findings) == 1
        assert ex_findings[0].evidence["cause"] == "body-not-json"


# ---------------------------------------------------------------------------
# Back-compat: suites with no deps still use the parallel path
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestBackCompatNoDeps:
    async def test_no_deps_suite_runs_as_before(self) -> None:
        # A suite with no .depends_on() calls anywhere should produce
        # identical behaviour to pre-PAPER-39. The 806 baseline tests
        # in this repo exercise this path heavily; this is a sanity
        # check that the topological branch is genuinely opt-in.
        probes = [Probe.get(f"/p/{i}/").id(f"p{i}").expect_status(200) for i in range(3)]
        transport = ResponseMapTransport(
            {f"p{i}": _resp({}, status=200) for i in range(3)},
        )
        suite = Suite(probes=probes, checks=[StatusUnexpected()])
        report = await suite.run(transport=transport)

        assert len(transport.calls) == 3
        # No PAPER-39 findings emitted.
        for code in (
            "suite.deps.skipped_due_to_upstream",
            "suite.deps.extraction_failed",
            "suite.deps.template_unresolved",
        ):
            assert not any(f.code == code for f in report.findings)
