"""
Tests for services/matrix-bridge-dagi/app/ingress.py (M1.4 — egress + audit)

Strategy:
  - mock MatrixClient sync_poll / send_text
  - mock httpx client for router invoke and audit write
  - verify: gateway invoked, send_text called with correct args
  - verify: dedupe prevents double-invoke
  - verify: audit events fire (received, replied, error)
  - verify: empty reply skips send_text (no spam)
"""

import asyncio
import sys
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest

_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
if str(_BRIDGE) not in sys.path:
    sys.path.insert(0, str(_BRIDGE))

from app.ingress import MatrixIngressLoop, _invoke_router, _write_audit  # noqa: E402
from app.room_mapping import parse_room_map  # noqa: E402


def run(coro):
    return asyncio.run(coro)


ALLOWED = frozenset({"sofiia"})
ROOM_ID = "!QwHczWXgefDHBEVkTH:daarion.space"
ROOM_MAP_STR = f"sofiia:{ROOM_ID}"
ROUTER_URL = "http://dagi-router-node1:8000"
HS_URL = "http://dagi-synapse-node1:8008"
CONSOLE_URL = "http://dagi-sofiia-console-node1:8002"
INTERNAL_TOKEN = "test_internal_token_xyz"
TOKEN = "syt_fake_token"
BOT_USER = "@dagi_bridge:daarion.space"
USER = "@user:daarion.space"

MSG_EVENT = {
    "type": "m.room.message",
    "event_id": "$event1:server",
    "sender": USER,
    "content": {"msgtype": "m.text", "body": "Привіт Sofiia!"},
    "origin_server_ts": 1000,
}


import sys
_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
if str(_BRIDGE) not in sys.path:
    sys.path.insert(0, str(_BRIDGE))

from app.rate_limit import InMemoryRateLimiter  # noqa: E402


def _make_loop(**kwargs) -> MatrixIngressLoop:
    room_map = parse_room_map(ROOM_MAP_STR, ALLOWED)
    defaults = dict(
        matrix_homeserver_url=HS_URL,
        matrix_access_token=TOKEN,
        matrix_user_id=BOT_USER,
        router_url=ROUTER_URL,
        node_id="NODA1",
        room_map=room_map,
        sofiia_console_url=CONSOLE_URL,
        sofiia_internal_token=INTERNAL_TOKEN,
    )
    defaults.update(kwargs)
    return MatrixIngressLoop(**defaults)


def _fake_sync(events: list) -> dict:
    return {
        "next_batch": "s_next",
        "rooms": {"join": {ROOM_ID: {"timeline": {"events": events}}}},
    }


def _ok_response(text: str = "Привіт! Я тут.") -> MagicMock:
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {"response": text, "model": "test", "tokens_used": 100}
    resp.raise_for_status = MagicMock()
    return resp


def _audit_response() -> MagicMock:
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {"ok": True}
    resp.raise_for_status = MagicMock()
    return resp


def _send_text_response() -> MagicMock:
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {"event_id": "$reply:server"}
    resp.raise_for_status = MagicMock()
    return resp


# ── _invoke_router ─────────────────────────────────────────────────────────────

def test_invoke_router_correct_endpoint_and_field():
    async def _inner():
        captured = {}

        async def fake_post(url, *, json=None, timeout=None):
            captured["url"] = url
            captured["payload"] = json
            return _ok_response("pong!")

        client = MagicMock()
        client.post = fake_post
        result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "ping", "session-1")

        assert "/v1/agents/sofiia/infer" in captured["url"]
        assert captured["payload"]["prompt"] == "ping"
        assert captured["payload"]["session_id"] == "session-1"
        assert result == "pong!"
    run(_inner())


def test_invoke_router_fallback_fields():
    """Should pick up text/content/message if response key missing."""
    async def _inner():
        async def fake_post(url, *, json=None, timeout=None):
            resp = MagicMock()
            resp.status_code = 200
            resp.json.return_value = {"text": "hello from text field"}
            resp.raise_for_status = MagicMock()
            return resp

        client = MagicMock()
        client.post = fake_post
        result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "hi", "s1")
        assert result == "hello from text field"
    run(_inner())


# ── _write_audit ───────────────────────────────────────────────────────────────

def test_write_audit_fires_to_console():
    async def _inner():
        captured = {}

        async def fake_post(url, *, json=None, headers=None, timeout=None):
            captured["url"] = url
            captured["headers"] = headers
            captured["json"] = json
            return _audit_response()

        client = MagicMock()
        client.post = fake_post

        await _write_audit(
            client, CONSOLE_URL, INTERNAL_TOKEN,
            event="matrix.message.received",
            agent_id="sofiia", node_id="NODA1",
            room_id=ROOM_ID, event_id="$e1",
            status="ok",
        )
        assert "/api/audit/internal" in captured["url"]
        assert captured["headers"]["X-Internal-Service-Token"] == INTERNAL_TOKEN
        assert captured["json"]["event"] == "matrix.message.received"
    run(_inner())


def test_write_audit_no_op_when_no_token():
    async def _inner():
        called = [False]

        async def fake_post(*args, **kwargs):
            called[0] = True
            return _audit_response()

        client = MagicMock()
        client.post = fake_post
        # Empty token — should not call
        await _write_audit(client, CONSOLE_URL, "", "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1")
        assert not called[0]
    run(_inner())


def test_write_audit_never_raises():
    async def _inner():
        async def fake_post(*args, **kwargs):
            raise ConnectionError("audit server down")

        client = MagicMock()
        client.post = fake_post
        # Should not raise
        await _write_audit(client, CONSOLE_URL, INTERNAL_TOKEN, "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1")
    run(_inner())


# ── Full loop: ingress + egress + audit ────────────────────────────────────────

def test_loop_full_cycle_invoke_and_reply():
    """One message → router invoked → send_text called with reply."""
    async def _inner():
        router_calls: List[Dict] = []
        send_calls: List[Dict] = []
        audit_calls: List[Dict] = []

        stop = asyncio.Event()
        loop = _make_loop()

        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                router_calls.append({"url": url, "json": json})
                return _ok_response("Привіт! Я готова допомогти.")
            elif "/audit/internal" in url:
                audit_calls.append({"url": url, "json": json})
                return _audit_response()
            return _audit_response()

        async def fake_send_text(room_id, text, txn_id):
            send_calls.append({"room_id": room_id, "text": text, "txn_id": txn_id})
            return {"event_id": "$reply_event"}

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = fake_send_text

            # Patch MatrixClient.make_txn_id as static method
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        # Router invoked once
        assert len(router_calls) == 1
        assert "sofiia" in router_calls[0]["url"]
        assert router_calls[0]["json"]["prompt"] == "Привіт Sofiia!"

        # Reply sent once
        assert len(send_calls) == 1
        assert send_calls[0]["room_id"] == ROOM_ID
        assert send_calls[0]["text"] == "Привіт! Я готова допомогти."

        # Audit events: at least received + replied
        audit_events = [a["json"]["event"] for a in audit_calls]
        assert "matrix.message.received" in audit_events
        assert "matrix.agent.replied" in audit_events

    run(_inner())


def test_loop_deduplication_no_double_invoke():
    """Same event_id in two syncs → router called exactly once."""
    async def _inner():
        router_calls = [0]
        seen = set()

        stop = asyncio.Event()
        loop = _make_loop()

        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 2:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message"
                    and e.get("sender") != BOT_USER
                    and e.get("event_id") not in seen]

        def fake_mark_seen(eid):
            seen.add(eid)

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                router_calls[0] += 1
                return _ok_response("response")
            return _audit_response()

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = fake_mark_seen
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        assert router_calls[0] == 1

    run(_inner())


def test_loop_empty_reply_skips_send():
    """Empty reply from router → send_text NOT called."""
    async def _inner():
        send_called = [False]
        stop = asyncio.Event()
        loop = _make_loop()

        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                resp = MagicMock()
                resp.status_code = 200
                resp.json.return_value = {"response": ""}  # empty
                resp.raise_for_status = MagicMock()
                return resp
            return _audit_response()

        async def fake_send_text(room_id, text, txn_id):
            send_called[0] = True
            return {"event_id": "$r"}

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = fake_send_text
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        assert not send_called[0]

    run(_inner())


def test_loop_metric_callbacks_fire():
    """on_message_received and on_message_replied should be called."""
    async def _inner():
        received = []
        replied = []

        stop = asyncio.Event()
        loop = _make_loop(
            on_message_received=lambda r, a: received.append((r, a)),
            on_message_replied=lambda r, a, s: replied.append((r, a, s)),
        )

        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                return _ok_response("test reply")
            return _audit_response()

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        assert len(received) == 1
        assert received[0] == (ROOM_ID, "sofiia")
        assert len(replied) == 1
        assert replied[0][2] == "ok"

    run(_inner())


# ── H1: Rate limit integration ────────────────────────────────────────────────

def test_rate_limiter_blocks_invoke():
    """When room rate limit exceeded, router must NOT be invoked."""
    async def _inner():
        router_calls = [0]
        rate_limited = []

        rl = InMemoryRateLimiter(room_rpm=1, sender_rpm=100)

        stop = asyncio.Event()
        loop = _make_loop(
            rate_limiter=rl,
            on_rate_limited=lambda r, a, lt: rate_limited.append(lt),
        )

        # Two events from same room
        event2 = {**MSG_EVENT, "event_id": "$event2:server"}
        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT, event2])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                router_calls[0] += 1
                return _ok_response("reply")
            return _audit_response()

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        # First message passes, second blocked
        assert router_calls[0] == 1
        assert len(rate_limited) == 1
        assert rate_limited[0] == "room"

    run(_inner())


def test_rate_limiter_audit_event_on_block():
    """Blocked message must produce matrix.rate_limited audit event."""
    async def _inner():
        audit_events = []

        rl = InMemoryRateLimiter(room_rpm=1, sender_rpm=100)
        stop = asyncio.Event()
        loop = _make_loop(rate_limiter=rl)

        event2 = {**MSG_EVENT, "event_id": "$event2:server"}
        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT, event2])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/audit/internal" in url:
                audit_events.append(json.get("event") if json else "unknown")
            if "/infer" in url:
                return _ok_response("reply")
            return _audit_response()

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        assert "matrix.rate_limited" in audit_events

    run(_inner())


def test_latency_callbacks_fire():
    """on_invoke_latency and on_send_latency must be called with agent_id and float."""
    async def _inner():
        invoke_latencies = []
        send_latencies = []

        stop = asyncio.Event()
        loop = _make_loop(
            on_invoke_latency=lambda a, d: invoke_latencies.append((a, d)),
            on_send_latency=lambda a, d: send_latencies.append((a, d)),
        )

        call_count = [0]

        async def fake_sync_poll(**kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                stop.set()
                return {"next_batch": "end", "rooms": {}}
            return _fake_sync([MSG_EVENT])

        def fake_extract(sync_resp, room_id):
            events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
            return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]

        async def fake_http_post(url, *, json=None, headers=None, timeout=None):
            if "/infer" in url:
                return _ok_response("test reply")
            return _audit_response()

        with patch("app.ingress.MatrixClient") as MockClient:
            mock_mc = AsyncMock()
            mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
            mock_mc.__aexit__ = AsyncMock(return_value=False)
            mock_mc.sync_poll = fake_sync_poll
            mock_mc.join_room = AsyncMock()
            mock_mc.mark_seen = MagicMock()
            mock_mc.extract_room_messages = fake_extract
            mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"})
            MockClient.return_value = mock_mc
            MockClient.make_txn_id = lambda r, e: f"txn_{e}"

            with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
                mock_http = AsyncMock()
                mock_http.__aenter__ = AsyncMock(return_value=mock_http)
                mock_http.__aexit__ = AsyncMock(return_value=False)
                mock_http.post = fake_http_post
                MockHTTP.return_value = mock_http

                await asyncio.wait_for(loop.run(stop), timeout=3.0)

        assert len(invoke_latencies) == 1
        assert invoke_latencies[0][0] == "sofiia"
        assert isinstance(invoke_latencies[0][1], float)
        assert invoke_latencies[0][1] >= 0

        assert len(send_latencies) == 1
        assert send_latencies[0][0] == "sofiia"
        assert isinstance(send_latencies[0][1], float)

    run(_inner())
