"""API contract tests for the report-content display routes.

Locks the public shape of report content so a future change can't silently
regress it (#3665):

* ``content`` is the *assembled* legacy view — answer + ``## Sources`` (from
  the ``research_resources`` table) + ``## Research Metrics`` (from
  ``research_meta``) — across every display/export route.
* ``GET /api/report/<id>`` exposes a ``sources`` field populated from
  ``research_resources``. Before the fix it read the dead
  ``all_links_of_system`` metadata key, which the post-refactor save path
  never writes, so it returned ``[]`` for every new research (Fix A).

These exercise the *real* assembly path (``assemble_full_report`` /
``get_research_source_links_batch``) against real seeded rows; only auth and
the per-user-DB plumbing are stubbed.
"""

from contextlib import contextmanager
from datetime import datetime, UTC
from unittest.mock import MagicMock, patch

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from local_deep_research.database.models import Base
from local_deep_research.database.models.chat import (
    ChatMessage,
    ChatMessageType,
    ChatRole,
    ChatSession,
)
from local_deep_research.database.models.research import (
    ResearchHistory,
    ResearchResource,
)

RESEARCH_ID = "contract-research-1"
# A second research with zero research_resources rows: the assembled view
# must omit the ``## Sources`` block entirely rather than emit an empty one
# (item 7 regression fence).
RESEARCH_ID_NO_SOURCES = "contract-research-nosrc"
# A chat session whose assistant message is the answer-only report_content —
# chat is the ONE consumer that must NOT receive the assembled view (item 8,
# architecture invariant of the chat-mode-v2 refactor).
CHAT_SESSION_ID = "contract-chat-1"
ANSWER_ONLY = (
    "X is explained here, citing [1](https://src1.example) "
    "and [2](https://src2.example)."
)
# Deliberately > the batch helper's default top-N (3): the report API must
# return *every* source (no cap), so a reintroduced default limit would fail
# test_api_report_sources_field_matches_research_resources_count.
N_SOURCES = 5


@pytest.fixture(autouse=True)
def mock_db_manager():
    """Satisfy the auth middleware/decorators without a real encrypted DB.

    Mirrors tests/test_followup_api.py: every module-level ``db_manager``
    binding used by the Flask before_request middleware and ``login_required``
    is mocked so an authenticated session passes. ``has_encryption=False``
    skips the encrypted-DB password check.
    """
    with (
        patch("local_deep_research.web.auth.decorators.db_manager") as mock_db,
        patch("local_deep_research.database.encrypted_db.db_manager"),
        patch("local_deep_research.web.auth.database_middleware.db_manager"),
        patch("local_deep_research.web.auth.session_cleanup.db_manager"),
        patch("local_deep_research.web.auth.queue_middleware.db_manager"),
    ):
        mock_db.connections = {"testuser": MagicMock()}
        mock_db.has_encryption = False
        mock_db.is_user_connected.return_value = True
        yield mock_db


@pytest.fixture
def mock_auth_client(app):
    """Test client authenticated via a mock session.

    Uses the shared ``app`` fixture (tests/conftest.py) but authenticates by
    seeding the session directly rather than a real register/login, because
    auth is stubbed via ``mock_db_manager`` and the per-user DB is patched to
    a seeded SQLite file (``seeded_db``). The real ``authenticated_client``
    fixture would create a real encrypted DB we couldn't seed sources into.
    """
    client = app.test_client()
    with client.session_transaction() as sess:
        sess["username"] = "testuser"
        sess["authenticated"] = True
    return client


@pytest.fixture
def seeded_db(tmp_path):
    """Real temp-file SQLite seeded with one completed research + sources.

    Patches ``get_user_db_session`` in both route modules to yield a fresh
    session bound to the seeded DB (a fresh session per request, like the
    real per-request session). A file DB (not ``:memory:``) so every
    ``SessionLocal()`` connection sees the same seeded rows.
    """
    engine = create_engine(f"sqlite:///{tmp_path / 'report_contract.db'}")
    Base.metadata.create_all(engine)
    SessionLocal = sessionmaker(bind=engine)

    seed = SessionLocal()
    seed.add(
        ResearchHistory(
            id=RESEARCH_ID,
            query="What is X?",
            mode="quick",
            status="completed",
            created_at="2026-04-25T12:00:00+00:00",
            completed_at="2026-04-25T12:05:00+00:00",
            report_content=(
                "X is explained here, citing [1](https://src1.example) "
                "and [2](https://src2.example)."
            ),
            research_meta={
                "iterations": 2,
                "generated_at": "2026-04-25T12:05:00+00:00",
            },
        )
    )
    for i in range(1, N_SOURCES + 1):
        url = f"https://src{i}.example"
        seed.add(
            ResearchResource(
                research_id=RESEARCH_ID,
                title=f"Source {i}",
                url=url,
                source_type="web",
                resource_metadata={
                    "original_data": {
                        "index": str(i),
                        "url": url,
                        "title": f"Source {i}",
                    }
                },
                created_at=datetime.now(UTC).isoformat(),
            )
        )

    # A completed research with NO research_resources rows (item 7).
    seed.add(
        ResearchHistory(
            id=RESEARCH_ID_NO_SOURCES,
            query="What is Y?",
            mode="quick",
            status="completed",
            created_at="2026-04-25T13:00:00+00:00",
            completed_at="2026-04-25T13:05:00+00:00",
            report_content="Y has no catalogued sources but still has an answer.",
            research_meta={
                "iterations": 1,
                "generated_at": "2026-04-25T13:05:00+00:00",
            },
        )
    )

    # A chat session with one assistant response message whose content is the
    # answer-only report_content — never the assembled view (item 8).
    seed.add(
        ChatSession(
            id=CHAT_SESSION_ID,
            title="Contract chat",
        )
    )
    seed.add(
        ChatMessage(
            id="contract-chat-msg-1",
            session_id=CHAT_SESSION_ID,
            research_id=RESEARCH_ID,
            role=ChatRole.ASSISTANT,
            message_type=ChatMessageType.RESPONSE,
            content=ANSWER_ONLY,
            sequence_number=1,
        )
    )
    seed.commit()
    seed.close()

    @contextmanager
    def _fake_user_db(username=None, password=None):
        db = SessionLocal()
        try:
            yield db
        finally:
            db.close()

    with (
        patch(
            "local_deep_research.web.routes.research_routes.get_user_db_session",
            _fake_user_db,
        ),
        patch(
            "local_deep_research.web.routes.history_routes.get_user_db_session",
            _fake_user_db,
        ),
        # ChatService resolves its session through the chat.service binding,
        # so the chat-route test (item 8) needs the same seeded DB.
        patch(
            "local_deep_research.chat.service.get_user_db_session",
            _fake_user_db,
        ),
    ):
        yield

    engine.dispose()


# ---------------------------------------------------------------------------
# Assembled-view shape across display routes (locks the legacy report shape)
# ---------------------------------------------------------------------------


def test_history_report_includes_sources_and_metrics(
    mock_auth_client, seeded_db
):
    resp = mock_auth_client.get(f"/history/report/{RESEARCH_ID}")
    assert resp.status_code == 200, resp.data
    content = resp.get_json()["content"]
    assert "## Sources\n" in content
    assert "## Research Metrics\n" in content
    # Sources block references the structured resources.
    assert "src1.example" in content


def test_history_markdown_includes_sources_and_metrics(
    mock_auth_client, seeded_db
):
    resp = mock_auth_client.get(f"/history/markdown/{RESEARCH_ID}")
    assert resp.status_code == 200, resp.data
    content = resp.get_json()["content"]
    assert "## Sources\n" in content
    assert "## Research Metrics\n" in content


def test_api_report_content_includes_sources_and_metrics(
    mock_auth_client, seeded_db
):
    resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
    assert resp.status_code == 200, resp.data
    content = resp.get_json()["content"]
    assert "## Sources\n" in content
    assert "## Research Metrics\n" in content


def test_export_latex_content_includes_sources_and_metrics(
    mock_auth_client, seeded_db
):
    # LaTeX is the registered plain-text export format (markdown is served by
    # /history/markdown; quarto is a .zip; pdf/odt are binary). LaTeX
    # conversion drops the markdown `##` prefix but keeps the header words and
    # body text. "Research Metrics" / "Search Iterations" exist ONLY in the
    # assembled view, never in the answer-only report_content, so finding them
    # proves the export path goes through assemble_full_report.
    resp = mock_auth_client.post(f"/api/v1/research/{RESEARCH_ID}/export/latex")
    assert resp.status_code == 200, resp.data
    body = resp.data.decode("utf-8")
    assert "Research Metrics" in body
    assert "Search Iterations" in body
    assert "Sources" in body
    assert "src1.example" in body


# ---------------------------------------------------------------------------
# Fix A regression fence: /api/report sources field from research_resources
# ---------------------------------------------------------------------------


def test_api_report_sources_field_populated_from_research_resources(
    mock_auth_client, seeded_db
):
    resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
    assert resp.status_code == 200, resp.data
    sources = resp.get_json()["sources"]
    assert isinstance(sources, list)
    assert sources, "sources must not be empty (Fix A regression)"
    for entry in sources:
        assert entry.get("url")
        assert entry.get("title")


def test_api_report_sources_field_matches_research_resources_count(
    mock_auth_client, seeded_db
):
    resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID}")
    assert resp.status_code == 200, resp.data
    sources = resp.get_json()["sources"]
    assert len(sources) == N_SOURCES


# ---------------------------------------------------------------------------
# Graceful empty-sources shape (item 7)
# ---------------------------------------------------------------------------


def test_response_with_no_sources_omits_sources_block_gracefully(
    mock_auth_client, seeded_db
):
    # A research with zero research_resources rows must still render a valid
    # report — answer (+ metrics) — with NO empty ``## Sources`` section, and
    # an empty ``sources`` list rather than an error.
    resp = mock_auth_client.get(f"/api/report/{RESEARCH_ID_NO_SOURCES}")
    assert resp.status_code == 200, resp.data
    payload = resp.get_json()
    content = payload["content"]
    assert content, "content must be non-empty even with no sources"
    assert "no catalogued sources" in content
    assert "## Sources\n" not in content
    assert payload["sources"] == []


# ---------------------------------------------------------------------------
# Chat is the one answer-only consumer (item 8) — architecture invariant
# ---------------------------------------------------------------------------


def test_chat_message_response_returns_answer_only(mock_auth_client, seeded_db):
    # The chat messages endpoint must return the stored ``report_content``
    # verbatim (answer-only). If a future change routed it through
    # assemble_full_report, a ``## Sources`` / ``## Research Metrics`` block
    # would leak in — this fences that off.
    resp = mock_auth_client.get(
        f"/api/chat/sessions/{CHAT_SESSION_ID}/messages"
    )
    assert resp.status_code == 200, resp.data
    payload = resp.get_json()
    assert payload["success"] is True
    responses = [
        m
        for m in payload["messages"]
        if m.get("message_type") == ChatMessageType.RESPONSE.value
    ]
    assert len(responses) == 1, payload["messages"]
    content = responses[0]["content"]
    assert content == ANSWER_ONLY
    assert "## Sources\n" not in content
    assert "## Research Metrics\n" not in content
