"""Fail-closed ownership + session-scoped delegation lifecycle (#55578).

Covers the two hardening rules layered on top of the origin-routing salvage:

1. ``_session_owns_notification_event`` — positive-proof ownership. An
   async-delegation completion may only be injected into a session that
   PROVABLY commissioned it (origin UI id, or session-key/lineage match).
   Orphans are never adopted by a foreign chat.

2. ``interrupt_for_session`` — a session's in-flight async delegations end
   with the session. ``_finalize_session`` interrupts delegations owned by
   the closing session (by origin UI id always; by durable key only when the
   TUI owns the lifecycle).
"""

import threading
from unittest.mock import MagicMock, patch

import pytest

import tools.async_delegation as ad
from tui_gateway.server import (
    _finalize_session,
    _session_owns_notification_event,
)


@pytest.fixture(autouse=True)
def _reset_async_delegation():
    ad._reset_for_tests()
    yield
    ad._reset_for_tests()


class TestSessionOwnsNotificationEvent:
    def _session(self, key="sess_key_1"):
        return {"session_key": key, "_finalized": False}

    def test_origin_ui_match_owns(self):
        evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "other"}
        assert _session_owns_notification_event("tab1", self._session(), evt) is True

    def test_session_key_match_owns(self):
        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "sess_key_1"}
        assert _session_owns_notification_event("tabX", self._session("sess_key_1"), evt) is True

    def test_orphan_is_not_owned(self):
        """No origin match, no key match, owner gone → NOT ours (fail closed)."""
        evt = {"type": "async_delegation", "origin_ui_session_id": "dead_tab", "session_key": "gone_key"}
        assert _session_owns_notification_event("tab1", self._session(), evt) is False

    def test_empty_key_and_origin_not_owned(self):
        """A delegation event with no return address at all is never adopted."""
        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": ""}
        assert _session_owns_notification_event("tab1", self._session(), evt) is False

    def test_finalized_session_owns_nothing(self):
        evt = {"type": "async_delegation", "origin_ui_session_id": "tab1", "session_key": "sess_key_1"}
        sess = self._session()
        sess["_finalized"] = True
        assert _session_owns_notification_event("tab1", sess, evt) is False

    def test_compression_chain_resolution_owns(self):
        evt = {"type": "async_delegation", "origin_ui_session_id": "", "session_key": "parent_key"}
        db = MagicMock()
        db.resolve_resume_session_id.return_value = "child_key"
        with patch("tui_gateway.server._get_db", return_value=db):
            assert _session_owns_notification_event("tabX", self._session("child_key"), evt) is True


class TestInterruptForSession:
    def _seed_record(self, delegation_id, session_key="", origin_ui_session_id="", status="running"):
        fn = MagicMock()
        with ad._records_lock:
            ad._records[delegation_id] = {
                "delegation_id": delegation_id,
                "status": status,
                "session_key": session_key,
                "origin_ui_session_id": origin_ui_session_id,
                "interrupt_fn": fn,
            }
        return fn

    def test_interrupts_only_matching_session(self):
        mine = self._seed_record("d1", session_key="sess_A")
        other = self._seed_record("d2", session_key="sess_B")
        n = ad.interrupt_for_session(session_key="sess_A")
        assert n == 1
        mine.assert_called_once()
        other.assert_not_called()

    def test_matches_by_origin_ui_session_id(self):
        mine = self._seed_record("d1", origin_ui_session_id="tab1")
        other = self._seed_record("d2", origin_ui_session_id="tab2")
        n = ad.interrupt_for_session(origin_ui_session_id="tab1")
        assert n == 1
        mine.assert_called_once()
        other.assert_not_called()

    def test_no_selector_is_noop(self):
        fn = self._seed_record("d1", session_key="sess_A")
        assert ad.interrupt_for_session() == 0
        fn.assert_not_called()

    def test_completed_records_untouched(self):
        fn = self._seed_record("d1", session_key="sess_A", status="completed")
        assert ad.interrupt_for_session(session_key="sess_A") == 0
        fn.assert_not_called()


class TestFinalizeInterruptsOwnDelegations:
    def _make_session(self, session_key="sess_A", sid="tab1"):
        agent = MagicMock()
        agent.session_id = session_key
        agent._session_messages = None
        agent.model = "m"
        agent.platform = "tui"
        return {
            "agent": agent,
            "history": [{"role": "user", "content": "x"}],
            "history_lock": threading.Lock(),
            "session_key": session_key,
            "_finalized": False,
            "_sid": sid,
        }

    @patch("tui_gateway.server._get_db")
    def test_finalize_interrupts_sessions_delegations(self, mock_get_db):
        mock_db = MagicMock()
        mock_db.get_session.return_value = {"source": "tui"}
        mock_get_db.return_value = mock_db

        with patch("tools.async_delegation.interrupt_for_session") as mock_int:
            _finalize_session(self._make_session(), end_reason="tui_close")

        mock_int.assert_called_once()
        kwargs = mock_int.call_args.kwargs
        assert kwargs["session_key"] == "sess_A"
        assert kwargs["origin_ui_session_id"] == "tab1"

    @patch("tui_gateway.server._get_db")
    def test_viewer_of_gateway_session_only_interrupts_by_origin(self, mock_get_db):
        """Closing a TUI viewer tab on a live gateway session must not kill
        the gateway's own background work — key-based interrupt is skipped,
        origin-id interrupt (this tab's own dispatches) still applies."""
        mock_db = MagicMock()
        mock_db.get_session.return_value = {"source": "telegram"}
        mock_get_db.return_value = mock_db

        with patch("tools.async_delegation.interrupt_for_session") as mock_int:
            _finalize_session(
                self._make_session(session_key="agent:main:telegram:dm:123", sid="tab9"),
                end_reason="ws_orphan_reap",
            )

        kwargs = mock_int.call_args.kwargs
        assert kwargs["session_key"] == ""
        assert kwargs["origin_ui_session_id"] == "tab9"
