"""Caller-level fallback suppression — the branches that actually stop the leak.

Review round 3 deleted BOTH production branches these cover and every existing
test still passed (36 and 38 respectively). The suites exercised
`_approval_send_outcome` and `RelayAdapter` but never drove the real callers,
so nothing observed whether a text send followed a decline — which is the
entire security property.

Every case here pairs a DECLINE with an ordinary-FAILURE control. Without the
control a test cannot distinguish "suppressed the fallback" from "never had a
fallback": a caller that always stays silent would pass the decline case.
"""

from __future__ import annotations

import asyncio
from types import SimpleNamespace
from typing import Any, Dict, List, Optional

import pytest

from gateway.platforms.base import SendResult
from gateway.relay.egress import EGRESS_DECLINE_CODE

CODE_ONLY_DECLINE: Dict[str, Any] = {"success": False, "code": EGRESS_DECLINE_CODE}


# ── exec approval ───────────────────────────────────────────────────────────


class _Adapter:
    """Records every plain text send the caller attempts."""

    typed_command_prefix = "/"

    def __init__(self, approval_result: SendResult) -> None:
        self._approval_result = approval_result
        self.text_sends: List[str] = []

    def pause_typing_for_chat(self, chat_id: str) -> None:
        return None

    def resume_typing_for_chat(self, chat_id: str) -> None:
        return None

    async def send_exec_approval(self, *a: Any, **k: Any) -> SendResult:
        return self._approval_result

    async def send(self, chat_id: str, message: str, **k: Any) -> SendResult:
        self.text_sends.append(message)
        return SendResult(success=True, message_id="m1")


def _runner(adapter: _Adapter):
    """A TurnRunner driven through its real _approval_notify_sync."""
    from gateway.run_turn_runner import TurnRunner

    runner = object.__new__(TurnRunner)
    ctx = SimpleNamespace(
        _status_adapter=adapter,
        _status_chat_id="C1",
        _status_thread_metadata={},
        _session_key="sk1",
        session_key="sk1",
        source=SimpleNamespace(chat_id="C1", platform="discord", session_key="sk1"),
    )
    runner._ctx = ctx

    class _Fut:
        def __init__(self, result): self._r = result
        def result(self, timeout=None): return self._r

    runner._schedule = lambda coro, _label: _Fut(asyncio.run(coro))
    runner._close_native_stream_boundary = lambda _why: None
    return runner


APPROVAL = {"command": "rm -rf /", "description": "danger", "pattern_key": "k"}


def test_exec_approval_decline_does_not_send_the_text_fallback():
    from gateway.run_turn_runner import _ExecApprovalDeclined

    adapter = _Adapter(
        SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
    )
    runner = _runner(adapter)

    # Raises (not returns) so `_await_gateway_decision`'s notify-failure path
    # drops the CENTRAL approval entry and unblocks the waiting tool.
    with pytest.raises(_ExecApprovalDeclined):
        runner._approval_notify_sync(dict(APPROVAL))

    assert adapter.text_sends == []


def test_exec_approval_ORDINARY_failure_still_falls_back_to_text():
    """The control. An ordinary failure MUST still reach the user."""
    adapter = _Adapter(SendResult(success=False, error="slack 500"))
    runner = _runner(adapter)

    runner._approval_notify_sync(dict(APPROVAL))

    assert len(adapter.text_sends) == 1
    assert "rm -rf /" in adapter.text_sends[0]


# ── slash confirm ───────────────────────────────────────────────────────────


class _ConfirmAdapter:
    def __init__(self, result: SendResult) -> None:
        self._result = result

    async def send_slash_confirm(self, **k: Any) -> SendResult:
        return self._result


def _busy(adapter: _ConfirmAdapter):
    from gateway.run_busy import GatewayBusySessionMixin

    busy = object.__new__(GatewayBusySessionMixin)
    busy._delivery_adapter_for = lambda _s: adapter
    busy._thread_metadata_for_source = lambda _s, _a: {}
    busy._reply_anchor_for_event = lambda _e: None
    busy._session_key_for_source = lambda _s: "sk1"
    return busy


def _event():
    return SimpleNamespace(
        source=SimpleNamespace(chat_id="C1", platform="discord", user_id="u1")
    )


def _run_confirm(busy) -> Optional[str]:
    return asyncio.run(
        busy._request_slash_confirm(
            event=_event(),
            command="/wipe",
            title="Confirm",
            message="really wipe?",
            handler=lambda choice: "done",
        )
    )


def test_slash_confirm_decline_returns_none_and_clears_state():
    from tools import slash_confirm as mod

    adapter = _ConfirmAdapter(
        SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
    )
    reply = _run_confirm(_busy(adapter))

    # None = "no text ack": returning the message would post the prompt as
    # plain text into the chat the connector just refused.
    assert reply is None
    # And the registration must not survive a card that never rendered.
    assert mod.get_pending("sk1") in (None, {}, [])


def test_slash_confirm_ORDINARY_failure_returns_the_text_fallback():
    """The control. A broken card lane must still ask the user."""
    adapter = _ConfirmAdapter(SendResult(success=False, error="discord 500"))
    reply = _run_confirm(_busy(adapter))

    assert reply == "really wipe?"


# ── task-card progress ──────────────────────────────────────────────────────
#
# Round 4 finding: the round-3 fix added the production branch AND a test, but
# the test stopped at RelayAdapter — it proved raw_response is carried and
# never called the caller that owns the security property. Deleting the real
# branch left 30 tests green. Same lesson as #7, one lane over: a component in
# the path proves nothing until a case drives THE CALLER.


class _CardAdapter:
    def __init__(self, progress_result: SendResult) -> None:
        self._progress_result = progress_result
        self.fallbacks: List[str] = []

    async def send_native_task_card_progress(self, **k: Any) -> SendResult:
        return self._progress_result


def _card_runner(adapter: _CardAdapter):
    from gateway.run_turn_runner import TurnRunner

    runner = object.__new__(TurnRunner)
    runner._ctx = SimpleNamespace(
        source=SimpleNamespace(chat_id="C1", platform="slack"),
        _progress_reply_to=None,
        _progress_metadata={},
    )

    async def _fallback(st):
        adapter.fallbacks.append("fallback")

    runner._task_card_send_or_edit_fallback = _fallback
    return runner


def _card_state(adapter: _CardAdapter):
    return SimpleNamespace(
        tasks=[{"text": "step"}],
        native_failed=False,
        publication_suppressed=False,
        visible_tasks=lambda: [{"text": "step"}],
        fallback_text=lambda: "step",
        adapter=adapter,
    )


def test_declined_task_card_progress_does_not_send_the_text_fallback():
    adapter = _CardAdapter(
        SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
    )
    runner = _card_runner(adapter)
    st = _card_state(adapter)

    asyncio.run(runner._task_card_publish(st))

    assert adapter.fallbacks == []
    assert st.native_failed is True


def test_ORDINARY_task_card_failure_still_sends_the_text_fallback():
    """Control: a genuinely broken card lane must still reach the user."""
    adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
    runner = _card_runner(adapter)
    st = _card_state(adapter)

    asyncio.run(runner._task_card_publish(st))

    assert adapter.fallbacks == ["fallback"]
    assert st.native_failed is True


def test_task_card_decline_suppression_persists_across_updates():
    """R5-4: my round-4 fix suppressed exactly ONE update.

    It set `native_failed`, which the entry gate already uses for an ordinary
    broken lane — so the NEXT progress event skipped the decline branch and
    went straight to the text fallback. Measured: [] then ['send'].
    A refusal does not expire after one tick.
    """
    adapter = _CardAdapter(
        SendResult(success=False, error="declined", raw_response=CODE_ONLY_DECLINE)
    )
    runner = _card_runner(adapter)
    st = _card_state(adapter)

    asyncio.run(runner._task_card_publish(st))
    asyncio.run(runner._task_card_publish(st))
    asyncio.run(runner._task_card_publish(st))

    assert adapter.fallbacks == []


def test_ORDINARY_failure_still_falls_back_on_every_later_update():
    """Control: a broken card lane must keep reaching the user each update."""
    adapter = _CardAdapter(SendResult(success=False, error="slack 500"))
    runner = _card_runner(adapter)
    st = _card_state(adapter)

    asyncio.run(runner._task_card_publish(st))
    asyncio.run(runner._task_card_publish(st))

    assert len(adapter.fallbacks) == 2


# ── the edit lane (round 6): one dropped raw_response, three leaks ──────────


class _EditAdapter:
    """Records every op; the edit is refused with a code-only decline."""

    def __init__(self) -> None:
        self.ops: List[str] = []

    @staticmethod
    def extract_media(text):
        return [], text

    async def edit_message(self, **k: Any) -> SendResult:
        self.ops.append("edit")
        return SendResult(
            success=False, error="declined", raw_response=CODE_ONLY_DECLINE
        )

    async def send(self, *a: Any, **k: Any) -> SendResult:
        self.ops.append("send")
        return SendResult(success=True, message_id="m1")


def test_queued_reconcile_decline_does_not_fall_back_to_a_send():
    """R6-3: `_deliver_queued_first_response` re-sent the WHOLE response.

    Its reconcile-by-edit falls through to `adapter.send` on any failure, so a
    refused edit delivered the full text to the refused chat. Measured:
    [('edit', 'SECRET'), ('send', 'SECRET')].
    """
    from gateway.run_notifications import GatewayNotificationsMixin

    adapter = _EditAdapter()
    mixin = object.__new__(GatewayNotificationsMixin)
    source = SimpleNamespace(chat_id="C1", platform="discord")
    consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)

    asyncio.run(
        mixin._deliver_queued_first_response(
            "SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
        )
    )

    assert adapter.ops == ["edit"]


def test_queued_reconcile_ORDINARY_edit_failure_still_sends():
    """Control: a genuinely un-editable message must still be delivered."""
    from gateway.run_notifications import GatewayNotificationsMixin

    class _Ordinary(_EditAdapter):
        async def edit_message(self, **k: Any) -> SendResult:
            self.ops.append("edit")
            return SendResult(success=False, error="message too old")

    adapter = _Ordinary()
    mixin = object.__new__(GatewayNotificationsMixin)
    source = SimpleNamespace(chat_id="C1", platform="discord")
    consumer = SimpleNamespace(message_id="m0", _turn_split_delivery=False)

    asyncio.run(
        mixin._deliver_queued_first_response(
            "SECRET", source, adapter, stream_consumer=consumer, deliver_media=False
        )
    )

    assert adapter.ops == ["edit", "send"]


def test_task_card_fallback_edit_decline_does_not_send_progress_text():
    """R6-4: R5-4 covered the native card, not the editable-text fallback."""
    adapter = _EditAdapter()
    runner = object.__new__(__import__("gateway.run_turn_runner", fromlist=["x"]).TurnRunner)
    runner._ctx = SimpleNamespace(
        source=SimpleNamespace(chat_id="C1", platform="slack"),
        _progress_metadata={},
    )
    sent = []
    runner._send_progress_text = lambda st, text: sent.append(text)
    st = SimpleNamespace(
        fallback_msg_id="m0",
        fallback_text=lambda: "task text",
        adapter=adapter,
        publication_suppressed=False,
    )

    asyncio.run(runner._task_card_send_or_edit_fallback(st))

    assert adapter.ops == ["edit"]
    assert sent == []
    assert st.publication_suppressed is True
