"""
Streaming / push / anti-loop / task-store tests for the A2A plugin (v1.0).

Tests cover:
- v1.0 SSE StreamResponse format (member-name discrimination, no kind/final)
- message/stream and tasks/subscribe end-to-end against a live server
- Push notification HMAC signing
- Anti-loop ping-pong protection (TurnTracker + live rejection)
- Rate limiting (per-identity sliding window)
- Metrics collection (real latency)
- Task store (idempotent completion, watchers, orphan handling)
- Dynamic Agent Cards from the live tool registry
- Capability-based routing with fan-out (a2a_orchestrate)
- SSRF protection for push callback URLs
"""
from __future__ import annotations

import asyncio
import json
import socket
import time
import urllib.error
import urllib.request

import pytest

from plugins.platforms.a2a import protocol, security, tools


def _free_port() -> int:
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


def _make_live_adapter(monkeypatch, reply_fn=None):
    from plugins.platforms.a2a.adapter import A2AAdapter
    from gateway.config import PlatformConfig

    port = _free_port()
    monkeypatch.setenv("A2A_PORT", str(port))
    adapter = A2AAdapter(PlatformConfig(enabled=True))

    async def fake_handle_message(event):
        reply = "ECHO: " + event.text if reply_fn is None else reply_fn(event)
        if reply is not None:
            await adapter.send(event.source.chat_id, reply, metadata={"notify": True})

    adapter.handle_message = fake_handle_message  # type: ignore
    adapter._message_handler = object()
    return adapter, f"http://127.0.0.1:{port}"


def _post_sse(url, body):
    """POST a JSON-RPC request and return the parsed SSE stream as
    (data_payloads, event_names).  Unwraps the JSON-RPC envelope from
    each data frame so callers see bare StreamResponse objects."""
    req = urllib.request.Request(
        url, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"}, method="POST",
    )
    with urllib.request.urlopen(req, timeout=15) as r:
        raw = r.read().decode("utf-8")
    payloads, events = [], []
    for block in raw.split("\n\n"):
        for line in block.splitlines():
            if line.startswith("event: "):
                events.append(line[len("event:"):].strip())
            elif line.startswith("data: "):
                data = line[len("data: "):].strip()
                if data:
                    obj = json.loads(data)
                    # Unwrap JSON-RPC envelope: {"jsonrpc":"2.0","id":...,"result":{...}}
                    if isinstance(obj, dict) and "jsonrpc" in obj and "result" in obj:
                        payloads.append(obj["result"])
                    else:
                        payloads.append(obj)
            # SSE comment lines (": done") are ignored — not data frames.
    return payloads, events


def _post_json(url, body, headers=None):
    req = urllib.request.Request(
        url, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", **(headers or {})}, method="POST",
    )
    with urllib.request.urlopen(req, timeout=15) as r:
        return json.loads(r.read().decode())


def _send_body(text, ctx="", method="message/send"):
    return {
        "jsonrpc": "2.0", "id": "1", "method": method,
        "params": {"message": protocol.text_message(protocol.ROLE_USER, text, context_id=ctx)},
    }


# ═════════════════════════════════════════════════════════════════════════════
# v1.0 SSE StreamResponse format
# ═════════════════════════════════════════════════════════════════════════════


class TestStreamResponseFormat:
    def test_status_update_shape(self):
        ev = protocol.status_update("task-1", "ctx-1", protocol.STATE_WORKING)
        assert set(ev.keys()) == {"statusUpdate"}
        su = ev["statusUpdate"]
        assert su["taskId"] == "task-1"
        assert su["contextId"] == "ctx-1"
        assert su["status"]["state"] == "TASK_STATE_WORKING"
        assert "kind" not in su and "final" not in su

    def test_status_update_with_message(self):
        ev = protocol.status_update("t", "c", protocol.STATE_INPUT_REQUIRED, "which one?")
        msg = ev["statusUpdate"]["status"]["message"]
        assert msg["role"] == "ROLE_AGENT"
        assert protocol.extract_text(msg) == "which one?"

    def test_artifact_update_shape(self):
        ev = protocol.artifact_update("task-1", "ctx-1", "the result")
        assert set(ev.keys()) == {"artifactUpdate"}
        au = ev["artifactUpdate"]
        assert au["taskId"] == "task-1"
        part = au["artifact"]["parts"][0]
        assert part == {"text": "the result", "mediaType": "text/plain"}
        assert "kind" not in au and "final" not in au

    def test_sse_data_framing(self):
        chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}})
        assert chunk.startswith("data: ")
        assert chunk.endswith("\n\n")
        # No event-name line: v1.0 discriminates by member presence.
        assert "event:" not in chunk

    def test_sse_data_jsonrpc_envelope(self):
        """A2A v1.0 §9.4: SSE frames must be JSON-RPC-wrapped when req_id is
        provided.  Bare StreamResponse (REST binding) breaks a2a-sdk clients."""
        chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}}, req_id="42")
        assert chunk.startswith("data: ")
        obj = json.loads(chunk[len("data: "):].strip())
        assert obj["jsonrpc"] == "2.0"
        assert obj["id"] == "42"
        assert "result" in obj
        assert obj["result"]["statusUpdate"]["taskId"] == "t"

    def test_sse_data_no_envelope_without_req_id(self):
        """Without req_id, sse_data falls back to bare payload for legacy callers."""
        chunk = protocol.sse_data({"statusUpdate": {"taskId": "t"}})
        obj = json.loads(chunk[len("data: "):].strip())
        assert "jsonrpc" not in obj
        assert obj["statusUpdate"]["taskId"] == "t"

    def test_sse_done_marker(self):
        """v1.0 signals stream completion by closing the stream.  The done
        marker is an SSE comment (``: done``), not a parseable data frame —
        emitting ``data: {}`` breaks JSON-RPC clients that try to parse it."""
        done = protocol.sse_done()
        assert ": done" in done
        assert "data:" not in done  # no data frame for SDK to parse
        assert done.endswith("\n\n")


@pytest.mark.integration
class TestStreamingEndToEnd:
    def test_message_stream_v1_events(self, monkeypatch):
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        adapter, base = _make_live_adapter(monkeypatch)

        async def run():
            assert await adapter.connect() is True
            payloads, events = await asyncio.to_thread(
                _post_sse, base + "/", _send_body("stream me", method="message/stream"))

            # Discrimination is by member name; every payload is a StreamResponse.
            # v1.0 streaming begins with the current Task (or a direct Message),
            # followed by status/artifact updates until terminal closure.
            for p in payloads:
                assert set(p.keys()) <= {"task", "message", "statusUpdate", "artifactUpdate"}
                assert "kind" not in json.dumps(p)
            assert "task" in payloads[0]
            assert payloads[0]["task"]["status"]["state"] == "TASK_STATE_SUBMITTED"

            states = [p["statusUpdate"]["status"]["state"]
                      for p in payloads if "statusUpdate" in p]
            assert states[0] == "TASK_STATE_WORKING"
            assert "TASK_STATE_WORKING" in states
            assert states[-1] == "TASK_STATE_COMPLETED"
            # No v0.3 'final' flag anywhere; closure is the terminal signal.
            assert all("final" not in p.get("statusUpdate", {}) for p in payloads)

            artifacts = [p["artifactUpdate"] for p in payloads if "artifactUpdate" in p]
            assert len(artifacts) == 1
            assert "ECHO:" in protocol.extract_text(artifacts[0]["artifact"])

            assert events == []  # v1.0: stream closure is the terminal signal, no event frame
            await adapter.disconnect()

        asyncio.run(run())

    def test_tasks_subscribe_replays_terminal_state(self, monkeypatch):
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        adapter, base = _make_live_adapter(monkeypatch)

        async def run():
            assert await adapter.connect() is True
            resp = await asyncio.to_thread(_post_json, base + "/", _send_body("hello"))
            task = resp["result"]

            payloads, events = await asyncio.to_thread(_post_sse, base + "/", {
                "jsonrpc": "2.0", "id": "2", "method": "tasks/subscribe",
                "params": {"taskId": task["id"]},
            })
            states = [p["statusUpdate"]["status"]["state"]
                      for p in payloads if "statusUpdate" in p]
            assert "TASK_STATE_COMPLETED" in states
            artifacts = [p for p in payloads if "artifactUpdate" in p]
            assert artifacts and "ECHO:" in protocol.extract_text(
                artifacts[0]["artifactUpdate"]["artifact"])
            assert events == []  # v1.0: stream closure is the terminal signal, no event frame
            await adapter.disconnect()

        asyncio.run(run())

    def test_tasks_subscribe_unknown_task(self, monkeypatch):
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        adapter, base = _make_live_adapter(monkeypatch)

        async def run():
            assert await adapter.connect() is True
            resp = await asyncio.to_thread(_post_json, base + "/", {
                "jsonrpc": "2.0", "id": "2", "method": "tasks/subscribe",
                "params": {"taskId": "ghost"},
            })
            assert resp["error"]["code"] == protocol.ERR_TASK_NOT_FOUND
            await adapter.disconnect()

        asyncio.run(run())

    def test_agent_card_advertises_streaming(self):
        card = protocol.build_agent_card(
            name="test", url="http://localhost:9900/",
            description="test", streaming=True, push_notifications=True,
        )
        assert card["capabilities"]["streaming"] is True
        assert card["capabilities"]["pushNotifications"] is True


# ═════════════════════════════════════════════════════════════════════════════
# Push notification signing
# ═════════════════════════════════════════════════════════════════════════════


class TestPushSigning:
    def test_sign_push_payload_deterministic(self, monkeypatch):
        monkeypatch.setenv("A2A_PUSH_SECRET", "test-secret-123")
        payload = {"statusUpdate": {"taskId": "task-1"}}
        sig = security.sign_push_payload(payload)
        assert sig
        import hashlib
        import hmac as hmac_mod
        expected = hmac_mod.new(
            b"test-secret-123",
            json.dumps(payload, sort_keys=True, ensure_ascii=False).encode(),
            hashlib.sha256,
        ).hexdigest()
        assert sig == expected

    def test_no_secret_means_unsigned(self, monkeypatch):
        monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        assert security.sign_push_payload({"x": 1}) == ""

    def test_falls_back_to_bearer_token(self, monkeypatch):
        monkeypatch.delenv("A2A_PUSH_SECRET", raising=False)
        monkeypatch.setenv("A2A_BEARER_TOKEN", "bearer-as-push-secret")
        assert security.sign_push_payload({"x": 1})


# ═════════════════════════════════════════════════════════════════════════════
# Anti-loop ping-pong protection
# ═════════════════════════════════════════════════════════════════════════════


class TestAntiLoopProtection:
    def test_track_turn_increments(self):
        turns = protocol.TurnTracker()
        assert turns.track("c1") == 1
        assert turns.track("c1") == 2
        assert turns.track("c1") == 3
        assert turns.track("c2") == 1  # separate context

    def test_reset_turns_clears(self):
        turns = protocol.TurnTracker()
        for _ in range(5):
            turns.track("c1")
        turns.reset("c1")
        assert turns.track("c1") == 1

    def test_max_pingpong_turns_default(self, monkeypatch):
        monkeypatch.delenv("A2A_MAX_PINGPONG_TURNS", raising=False)
        assert protocol.max_pingpong_turns() == 5

    def test_max_pingpong_turns_env_override(self, monkeypatch):
        monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "10")
        assert protocol.max_pingpong_turns() == 10
        monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "50")
        assert protocol.max_pingpong_turns() == 20  # hard cap
        monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "0")
        assert protocol.max_pingpong_turns() == 1  # min 1

    @pytest.mark.integration
    def test_loop_rejected_live(self, monkeypatch):
        """The turn past the limit is REJECTED (v1.0 state), not failed."""
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        monkeypatch.setenv("A2A_MAX_PINGPONG_TURNS", "2")
        adapter, base = _make_live_adapter(monkeypatch)

        async def run():
            assert await adapter.connect() is True
            states = []
            for _ in range(3):
                resp = await asyncio.to_thread(
                    _post_json, base + "/", _send_body("ping", ctx="ctx-pingpong"))
                states.append(resp["result"]["status"]["state"])
            assert states[0] == "TASK_STATE_COMPLETED"
            assert states[1] == "TASK_STATE_COMPLETED"
            assert states[2] == "TASK_STATE_REJECTED"
            await adapter.disconnect()

        asyncio.run(run())


# ═════════════════════════════════════════════════════════════════════════════
# Rate limiting
# ═════════════════════════════════════════════════════════════════════════════


class TestRateLimiting:
    def test_allows_under_limit(self, monkeypatch):
        monkeypatch.setenv("A2A_RATE_LIMIT", "10")
        rl = protocol.RateLimiter()
        for _ in range(10):
            assert rl.allow("peer-1") is True

    def test_blocks_over_limit(self, monkeypatch):
        monkeypatch.setenv("A2A_RATE_LIMIT", "3")
        rl = protocol.RateLimiter()
        assert rl.allow("peer-2") is True
        assert rl.allow("peer-2") is True
        assert rl.allow("peer-2") is True
        assert rl.allow("peer-2") is False  # 4th blocked

    def test_separate_per_identity(self, monkeypatch):
        monkeypatch.setenv("A2A_RATE_LIMIT", "2")
        rl = protocol.RateLimiter()
        assert rl.allow("peer-a") is True
        assert rl.allow("peer-a") is True
        assert rl.allow("peer-a") is False
        assert rl.allow("peer-b") is True  # different bucket
        assert rl.allow("peer-b") is True

    @pytest.mark.integration
    def test_rate_limit_live_returns_429(self, monkeypatch):
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        monkeypatch.setenv("A2A_RATE_LIMIT", "2")
        adapter, base = _make_live_adapter(monkeypatch)

        async def run():
            assert await adapter.connect() is True

            def _burst():
                codes = []
                for _ in range(3):
                    try:
                        _post_json(base + "/", _send_body("hi"))
                        codes.append(200)
                    except urllib.error.HTTPError as e:
                        codes.append(e.code)
                        err = json.loads(e.read().decode())
                        assert err["error"]["code"] == protocol.ERR_RATE_LIMITED
                return codes

            codes = await asyncio.to_thread(_burst)
            assert codes[:2] == [200, 200]
            assert codes[2] == 429
            await adapter.disconnect()

        asyncio.run(run())


# ═════════════════════════════════════════════════════════════════════════════
# Metrics
# ═════════════════════════════════════════════════════════════════════════════


class TestMetrics:
    def test_metrics_snapshot_has_fields(self):
        m = protocol.metrics.snapshot()
        for field in ("uptime_seconds", "inbound_total", "outbound_total",
                      "streams_started", "push_sent", "push_failed",
                      "tasks_completed", "tasks_failed", "anti_loop_triggers",
                      "rate_limit_triggers", "avg_latency_ms"):
            assert field in m

    def test_record_latency_updates_average(self):
        m = protocol.Metrics()
        m.record_latency(0.1)
        m.record_latency(0.3)
        assert 0.19 <= m.avg_latency() <= 0.21

    @pytest.mark.integration
    def test_latency_is_actually_recorded_live(self, monkeypatch):
        """The avg latency metric must be fed by real elapsed time, not a
        hardcoded 0."""
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)

        def slow_reply(event):
            time.sleep(0.05)
            return "done"

        adapter, base = _make_live_adapter(monkeypatch, reply_fn=slow_reply)

        async def run():
            assert await adapter.connect() is True
            before = len(protocol.metrics._latencies)
            await asyncio.to_thread(_post_json, base + "/", _send_body("time me"))
            new = list(protocol.metrics._latencies)[before:]
            assert new and new[-1] >= 0.05
            await adapter.disconnect()

        asyncio.run(run())


# ═════════════════════════════════════════════════════════════════════════════
# Task store
# ═════════════════════════════════════════════════════════════════════════════


class TestTaskStore:
    def test_create_and_get(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "peer-1")
        rec = store.get("t1")
        assert rec["state"] == protocol.STATE_SUBMITTED
        assert rec["context_id"] == "c1"
        assert rec["peer"] == "peer-1"

    def test_complete_keeps_task_queryable(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        store.complete("t1", protocol.STATE_COMPLETED, "the reply")
        rec = store.get("t1")
        assert rec is not None
        assert rec["state"] == protocol.STATE_COMPLETED
        assert rec["reply"] == "the reply"

    def test_complete_is_idempotent(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        assert store.complete("t1", protocol.STATE_COMPLETED, "first") is not None
        # Second terminal transition is refused (prevents double-counting).
        assert store.complete("t1", protocol.STATE_FAILED, "second") is None
        assert store.get("t1")["state"] == protocol.STATE_COMPLETED
        assert store.complete("ghost", protocol.STATE_FAILED) is None

    def test_watch_resolves_on_complete(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        fut = store.watch("t1")
        assert not fut.done()
        store.complete("t1", protocol.STATE_COMPLETED, "answer")
        assert fut.result(timeout=0) == (protocol.STATE_COMPLETED, "answer")

    def test_watch_terminal_resolves_immediately(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        store.complete("t1", protocol.STATE_FAILED, "err")
        fut = store.watch("t1")
        assert fut.result(timeout=0) == (protocol.STATE_FAILED, "err")
        assert store.watch("ghost") is None

    def test_fail_orphans(self):
        store = protocol.TaskStore()
        store.create("t-old", "c1", "p")
        store.create("t-new", "c1", "p")
        store._tasks["t-old"]["created_at"] = time.time() - 600
        failed = store.fail_orphans(timeout_seconds=300)
        assert failed == ["t-old"]
        assert store.get("t-old")["state"] == protocol.STATE_FAILED
        assert store.get("t-new")["state"] == protocol.STATE_SUBMITTED
        # Second sweep does nothing (already terminal).
        assert store.fail_orphans(timeout_seconds=300) == []

    def test_list_newest_first_with_filters(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        store.create("t2", "c2", "p")
        store.create("t3", "c1", "p")
        store.complete("t1", protocol.STATE_COMPLETED)
        recs, _ = store.list(context_id="c1")
        assert [r["task_id"] for r in recs] == ["t3", "t1"]
        recs, _ = store.list(state=protocol.STATE_SUBMITTED)
        assert {r["task_id"] for r in recs} == {"t2", "t3"}

    def test_push_config_lifecycle(self):
        store = protocol.TaskStore()
        store.create("t1", "c1", "p")
        cfg = store.set_push_config("t1", "https://example.com/hook")
        assert cfg["configId"].startswith("cfg-")
        assert cfg["createdAt"]
        assert store.pop_push_url("t1") == "https://example.com/hook"
        assert store.pop_push_url("t1") == ""  # consumed
        assert store.set_push_config("ghost", "https://x/") is None


# ═════════════════════════════════════════════════════════════════════════════
# Dynamic Agent Cards
# ═════════════════════════════════════════════════════════════════════════════


class TestDynamicAgentCards:
    def test_skills_reflect_live_tool_registry(self, monkeypatch):
        """The Agent Card is built from the real tool registry at serve time."""
        from tools.registry import registry
        from gateway.config import PlatformConfig
        from plugins.platforms.a2a.adapter import A2AAdapter

        monkeypatch.setattr(registry, "get_registered_toolset_names",
                            lambda: ["webz", "termz"])
        monkeypatch.setattr(registry, "get_tool_names_for_toolset",
                            lambda ts: {"webz": ["web_search"], "termz": ["terminal"]}[ts])

        adapter = A2AAdapter(PlatformConfig(enabled=True))
        card = adapter._build_card()
        by_name = {s["name"]: s for s in card["skills"]}
        assert set(by_name) == {"webz", "termz"}
        assert "web_search" in by_name["webz"]["tags"]

    def test_advertised_toolsets_restrict_card(self, monkeypatch):
        from tools.registry import registry
        from gateway.config import PlatformConfig
        from plugins.platforms.a2a.adapter import A2AAdapter

        monkeypatch.setattr(registry, "get_registered_toolset_names",
                            lambda: ["webz", "termz", "secretz"])
        monkeypatch.setattr(registry, "get_tool_names_for_toolset", lambda ts: [])
        monkeypatch.setenv("A2A_ADVERTISED_TOOLSETS", "webz")

        adapter = A2AAdapter(PlatformConfig(enabled=True))
        card = adapter._build_card()
        assert [s["name"] for s in card["skills"]] == ["webz"]


# ═════════════════════════════════════════════════════════════════════════════
# Capability-based routing (a2a_orchestrate)
# ═════════════════════════════════════════════════════════════════════════════


_TWO_PEERS = {
    "a2a_agents": {
        "researcher": {"url": "http://localhost:9991", "capabilities": ["research"]},
        "coder": {"url": "http://localhost:9992", "capabilities": ["code"]},
        "generalist": {"url": "http://localhost:9993", "capabilities": ["research", "code"]},
    }
}


class TestA2AOrchestrate:
    def test_requires_capability_and_message(self):
        assert "capability" in tools.a2a_orchestrate({"message": "do something"})
        assert "message" in tools.a2a_orchestrate({"capability": "research"})

    def test_no_matching_peers(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: {})
        result = tools.a2a_orchestrate({"capability": "research", "message": "search X"})
        assert "no configured peers" in result

    def test_match_peers_by_capability(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        matches = tools._match_peers_by_capability("research")
        assert {m[0] for m in matches} == {"researcher", "generalist"}
        assert len(tools._match_peers_by_capability("*")) == 3

    def test_all_mode_returns_every_reply(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, f"reply from {name}"))
        out = tools.a2a_orchestrate({"capability": "research", "message": "go"})
        assert "reply from researcher" in out
        assert "reply from generalist" in out

    def test_best_mode_picks_longest_success(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        replies = {
            "researcher": "short",
            "generalist": "a much longer and more detailed reply",
        }
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, replies[name]))
        out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
        assert out.startswith("[best: generalist]")

    def test_best_mode_ignores_error_replies(self, monkeypatch):
        """A long error must not beat a short success (old max() heuristic bug)."""
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        replies = {
            "researcher": "ok",
            "generalist": "Error: " + "x" * 500,
        }
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, replies[name]))
        out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
        assert out.startswith("[best: researcher]")
        assert "ok" in out

    def test_best_mode_all_errors_reports_failure(self, monkeypatch):
        """All-error edge: report the failures instead of returning one error
        with a misleading [best: ...] header."""
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, "Error: connection refused"))
        out = tools.a2a_orchestrate({"capability": "research", "message": "go", "mode": "best"})
        assert out.startswith("All peers failed:")
        assert "[best:" not in out

    def test_first_mode_all_errors_reports_failure(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, "Error: nope"))
        out = tools.a2a_orchestrate({"capability": "code", "message": "go", "mode": "first"})
        assert out.startswith("All peers failed:")

    def test_first_mode_returns_a_success(self, monkeypatch):
        monkeypatch.setattr(tools, "_load_config", lambda: _TWO_PEERS)
        monkeypatch.setattr(tools, "_call_peer_sync",
                            lambda name, entry, msg, ctx="": (name, f"win {name}"))
        out = tools.a2a_orchestrate({"capability": "code", "message": "go", "mode": "first"})
        assert out.startswith("[first: ")
        assert "win" in out


# ═════════════════════════════════════════════════════════════════════════════
# SSRF protection for push callbacks
# ═════════════════════════════════════════════════════════════════════════════


class TestSSRFProtection:
    def test_safe_public_urls_allowed(self):
        assert security.is_safe_callback_url("https://example.com/webhook") is True
        assert security.is_safe_callback_url("http://example.com/webhook") is True

    def test_localhost_blocked_in_remote_mode(self, monkeypatch):
        monkeypatch.setenv("A2A_BEARER_TOKEN", "tok")  # remote mode
        assert security.is_safe_callback_url("http://127.0.0.1:8080/hook") is False
        assert security.is_safe_callback_url("http://localhost:8080/hook") is False

    def test_localhost_allowed_in_local_mode(self, monkeypatch):
        monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False)
        monkeypatch.delenv("A2A_PEER_TOKENS", raising=False)
        assert security.is_safe_callback_url("http://127.0.0.1:8080/hook") is True
        assert security.is_safe_callback_url("http://localhost:8080/hook") is True

    def test_aws_metadata_blocked(self, monkeypatch):
        monkeypatch.setenv("A2A_BEARER_TOKEN", "tok")
        assert security.is_safe_callback_url("http://169.254.169.254/latest/meta-data/") is False

    def test_private_ranges_blocked(self, monkeypatch):
        monkeypatch.setenv("A2A_BEARER_TOKEN", "tok")
        assert security.is_safe_callback_url("http://10.0.0.1/hook") is False
        assert security.is_safe_callback_url("http://192.168.1.1/hook") is False
        assert security.is_safe_callback_url("http://172.16.0.1/hook") is False

    def test_non_http_schemes_blocked(self):
        assert security.is_safe_callback_url("file:///etc/passwd") is False
        assert security.is_safe_callback_url("ftp://example.com/file") is False

    def test_empty_url_blocked(self):
        assert security.is_safe_callback_url("") is False
        assert security.is_safe_callback_url(None) is False
