"""Integration tests for the egress policy at the news-subscription call site.

The helper ``_validate_subscription_policy`` is unit-tested elsewhere
(tests/security/test_egress_policy.py N14/N15 and
tests/news/test_subscription_policy.py). This module instead drives the REAL
call-site functions ``create_subscription`` / ``update_subscription`` in
``news/api.py`` and asserts the policy decision actually:

  * blocks persistence (no ``session.add`` / ``session.commit``) when the
    chosen search engine or LLM provider violates the user's egress scope,
  * lets a coherent config through to persistence,
  * still blocks a forbidden engine when the configured primary is a stray
    removed meta-engine name (STRICT + "auto") rather than silently skipping
    the check, and
  * skips the (best-effort) pre-check when the settings backend or the request
    context needed to resolve the user is unavailable — the execution-time
    factory PEP remains the backstop.

Only unavoidable heavy deps are mocked (per-user DB session, settings manager,
scheduler notification, flask request context). The egress policy evaluation
itself runs for real.
"""

from __future__ import annotations

import contextlib
from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.news import api as news_api
from local_deep_research.news.exceptions import (
    SubscriptionCreationException,
    SubscriptionUpdateException,
)

# Patch targets — the call site imports these lazily inside the functions, so
# we patch them at their definition module.
DB_SESSION_PATH = (
    "local_deep_research.database.session_context.get_user_db_session"
)
SM_PATH = "local_deep_research.utilities.db_utils.get_settings_manager"
NOTIFY_PATH = (
    "local_deep_research.news.api._notify_scheduler_about_subscription_change"
)

# Egress snapshots used to drive the real policy evaluation.
STRICT_ARXIV = {"policy.egress_scope": "strict", "search.tool": "arxiv"}
REQUIRE_LOCAL = {
    "policy.egress_scope": "both",
    "llm.require_local_endpoint": True,
}
# STRICT scope with a stray removed meta-engine primary: the context still
# builds as STRICT, so every engine except the (nonexistent) primary is denied.
STRAY_META_PRIMARY = {"policy.egress_scope": "strict", "search.tool": "auto"}


def _settings_manager(snapshot, primary="arxiv"):
    """Fake SettingsManager exposing the snapshot + search.tool primary."""
    sm = MagicMock()
    sm.get_settings_snapshot.return_value = snapshot
    sm.get_setting.side_effect = lambda key, default=None: (
        primary if key == "search.tool" else default
    )
    return sm


@contextlib.contextmanager
def _db_session():
    """Patch get_user_db_session to yield a mock session as a context manager."""
    session = MagicMock()
    with patch(DB_SESSION_PATH) as gud:
        gud.return_value.__enter__.return_value = session
        gud.return_value.__exit__.return_value = False
        yield session


def _existing_subscription():
    """A persisted subscription with engine/provider unset by default so that
    only the fields an update sets participate in the policy check."""
    sub = MagicMock()
    sub.search_engine = None
    sub.model_provider = None
    return sub


# ---------------------------------------------------------------------------
# create_subscription — engine gate
# ---------------------------------------------------------------------------


def test_create_blocked_engine_is_not_persisted():
    """STRICT + primary=arxiv: a non-primary engine (pubmed) is rejected at
    create time and never written to the DB."""
    sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
    ):
        with pytest.raises(SubscriptionCreationException) as exc:
            news_api.create_subscription(
                user_id="alice",
                query="AI",
                search_engine="pubmed",
                refresh_minutes=60,
            )
    assert "pubmed" in exc.value.message
    assert exc.value.error_code == "SUBSCRIPTION_CREATE_FAILED"
    session.add.assert_not_called()
    session.commit.assert_not_called()


def test_create_allows_primary_engine():
    """The user's own primary engine passes the gate and is persisted."""
    sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
    ):
        result = news_api.create_subscription(
            user_id="alice",
            query="AI",
            search_engine="arxiv",
            refresh_minutes=60,
        )
    assert result["status"] == "success"
    session.add.assert_called_once()
    session.commit.assert_called_once()


# ---------------------------------------------------------------------------
# create_subscription — LLM provider gate
# ---------------------------------------------------------------------------


def test_create_blocked_cloud_provider_is_not_persisted():
    """require_local_endpoint=True: a cloud provider (openai) is rejected and
    not persisted."""
    sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
    ):
        with pytest.raises(SubscriptionCreationException) as exc:
            news_api.create_subscription(
                user_id="alice",
                query="AI",
                model_provider="openai",
                refresh_minutes=60,
            )
    assert "provider" in exc.value.message.lower()
    session.add.assert_not_called()


def test_create_allows_local_provider():
    """A localhost-default provider (ollama) passes under require_local."""
    sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
    ):
        result = news_api.create_subscription(
            user_id="alice",
            query="AI",
            model_provider="ollama",
            refresh_minutes=60,
        )
    assert result["status"] == "success"
    session.add.assert_called_once()


# ---------------------------------------------------------------------------
# create_subscription — stray removed meta primary still blocks (fail closed)
# ---------------------------------------------------------------------------


def test_create_blocks_engine_under_stray_meta_primary():
    """STRICT + a stray removed meta-engine primary ("auto") no longer raises
    at context construction; the STRICT identity check must still reject a
    non-primary engine at create time and block persistence — never a silent
    allow."""
    sm = _settings_manager(STRAY_META_PRIMARY, primary="auto")
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
    ):
        with pytest.raises(SubscriptionCreationException) as exc:
            news_api.create_subscription(
                user_id="alice",
                query="AI",
                search_engine="pubmed",
                refresh_minutes=60,
            )
    assert "strict_not_primary" in exc.value.message
    session.add.assert_not_called()


# ---------------------------------------------------------------------------
# create_subscription — best-effort skip when backend is unavailable
# ---------------------------------------------------------------------------


def test_create_skips_precheck_without_settings_backend():
    """If the settings backend is unavailable the pre-check is skipped
    (best-effort) and a config that WOULD be forbidden is still persisted — the
    execution-time factory PEP is the backstop. Mirror of the blocked-engine
    test with the backend removed."""
    with (
        _db_session() as session,
        patch(SM_PATH, side_effect=RuntimeError("no settings DB")),
        patch(NOTIFY_PATH),
    ):
        result = news_api.create_subscription(
            user_id="alice",
            query="AI",
            search_engine="pubmed",  # would be denied if backend were present
            refresh_minutes=60,
        )
    assert result["status"] == "success"
    session.add.assert_called_once()


# ---------------------------------------------------------------------------
# update_subscription — engine / provider gate
# ---------------------------------------------------------------------------


def test_update_blocked_engine_is_not_committed():
    """Updating an existing subscription to a forbidden engine is rejected and
    the transaction is not committed."""
    sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
    sub = _existing_subscription()
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
        patch("flask.has_request_context", return_value=True),
        patch("flask.session", {"username": "alice"}),
    ):
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        with pytest.raises(SubscriptionUpdateException) as exc:
            news_api.update_subscription("sub-1", {"search_engine": "pubmed"})
    assert exc.value.error_code == "SUBSCRIPTION_UPDATE_FAILED"
    session.commit.assert_not_called()


def test_update_allows_coherent_engine():
    """Updating to the user's primary engine is accepted and committed."""
    sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
    sub = _existing_subscription()
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
        patch("flask.has_request_context", return_value=True),
        patch("flask.session", {"username": "alice"}),
    ):
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        result = news_api.update_subscription(
            "sub-1", {"search_engine": "arxiv"}
        )
    assert result["status"] == "success"
    session.commit.assert_called_once()


def test_update_blocked_cloud_provider_is_not_committed():
    """Updating to a cloud provider under require_local is rejected and not
    committed."""
    sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
    sub = _existing_subscription()
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm),
        patch(NOTIFY_PATH),
        patch("flask.has_request_context", return_value=True),
        patch("flask.session", {"username": "alice"}),
    ):
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        with pytest.raises(SubscriptionUpdateException):
            news_api.update_subscription("sub-1", {"model_provider": "openai"})
    session.commit.assert_not_called()


# ---------------------------------------------------------------------------
# update_subscription — pre-check guards (engine/provider touched + user known)
# ---------------------------------------------------------------------------


def test_update_skips_policy_when_no_engine_or_provider_touched():
    """The pre-check only runs when the update touches the engine or provider.
    A name-only update must NOT consult the settings backend at all (proves the
    guard, not just that nothing happened to be wrong)."""
    sub = _existing_subscription()
    with (
        _db_session() as session,
        patch(SM_PATH) as sm_patch,
        patch(NOTIFY_PATH),
        patch("flask.has_request_context", return_value=True),
        patch("flask.session", {"username": "alice"}),
    ):
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        result = news_api.update_subscription("sub-1", {"name": "Renamed"})
    assert result["status"] == "success"
    sm_patch.assert_not_called()
    session.commit.assert_called_once()


def test_update_skips_policy_without_request_context():
    """Without a request context the user can't be resolved, so the pre-check is
    skipped (best-effort) — a forbidden engine still persists and the execution
    PEP backstops. Counterpart to test_update_blocked_engine_is_not_committed,
    which has the request context."""
    sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
    sub = _existing_subscription()
    with (
        _db_session() as session,
        patch(SM_PATH, return_value=sm) as sm_patch,
        patch(NOTIFY_PATH),
        patch("flask.has_request_context", return_value=False),
    ):
        session.query.return_value.filter_by.return_value.first.return_value = (
            sub
        )
        result = news_api.update_subscription(
            "sub-1", {"search_engine": "pubmed"}
        )
    assert result["status"] == "success"
    session.commit.assert_called_once()
    sm_patch.assert_not_called()
