"""
Comprehensive tests for local_deep_research.web.api – REST API endpoints.

Covers all blueprint routes, the api_access_control decorator (auth, API-disabled,
rate-limiting), and the _serialize_results helper.
"""

import time
from contextlib import contextmanager
from unittest.mock import MagicMock, patch

import pytest
from flask import Flask

from local_deep_research.web.api import api_blueprint
from local_deep_research.security.rate_limiter import limiter


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _auth_session(client, username="testuser"):
    """Inject an authenticated session."""
    with client.session_transaction() as sess:
        sess["username"] = username


@contextmanager
def _mock_access_control(*, api_enabled=True, rate_limit=60):
    """Context manager that patches get_user_db_session + get_settings_manager
    so that the api_access_control decorator lets requests through (or blocks
    them if api_enabled=False).

    The rate_limit parameter controls the per-user API rate limit cached on
    ``g._api_rate_limit`` by the ``api_access_control`` decorator.  Setting it
    here ensures that the real ``_get_user_api_rate_limit`` function reads the
    correct value from the cache, which keeps Flask-Limiter's enforcement
    consistent even under xdist test-parallelism where the global ``limiter``
    singleton may carry stale state from other test modules.
    """
    with patch("local_deep_research.web.api.get_user_db_session") as mock_ctx:
        mock_session = MagicMock()
        mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_session)
        mock_ctx.return_value.__exit__ = MagicMock(return_value=None)

        with patch(
            "local_deep_research.web.api.get_settings_manager"
        ) as mock_sm:
            mock_manager = MagicMock()
            mock_manager.get_setting.side_effect = lambda key, default: {
                "app.enable_api": api_enabled,
                "app.api_rate_limit": rate_limit,
            }.get(key, default)
            mock_sm.return_value = mock_manager

            yield mock_ctx, mock_sm


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(autouse=True)
def _reset_limiter(app):
    """Reset Flask-Limiter storage before and after each test.

    Resetting *before* the test prevents stale counters left by other test
    modules that share the global ``limiter`` singleton (common under xdist).
    """
    with app.app_context():
        try:
            limiter.reset()
        except Exception:
            pass
    yield
    with app.app_context():
        try:
            limiter.reset()
        except Exception:
            pass


@pytest.fixture
def app():
    flask_app = Flask(__name__)
    flask_app.config["SECRET_KEY"] = "test-secret"
    flask_app.config["TESTING"] = True
    flask_app.config["WTF_CSRF_ENABLED"] = False
    flask_app.config["RATELIMIT_ENABLED"] = True
    flask_app.config["RATELIMIT_STRATEGY"] = "moving-window"
    flask_app.register_blueprint(api_blueprint)
    limiter.init_app(flask_app)
    return flask_app


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def authed_client(client):
    _auth_session(client)
    return client


# ===================================================================
# /api/v1/health
# ===================================================================


class TestHealthCheck:
    """GET /api/v1/health – no auth required."""

    def test_returns_ok(self, client):
        resp = client.get("/api/v1/health")
        assert resp.status_code == 200
        data = resp.get_json()
        assert data["status"] == "ok"
        assert data["message"] == "API is running"

    def test_timestamp_is_recent(self, client):
        resp = client.get("/api/v1/health")
        ts = resp.get_json()["timestamp"]
        assert abs(ts - time.time()) < 5

    def test_unauthenticated_access_allowed(self, client):
        """Health check must be accessible without a session."""
        resp = client.get("/api/v1/health")
        assert resp.status_code == 200


# ===================================================================
# /api/v1/  (api_documentation)
# ===================================================================


class TestApiDocumentation:
    """GET /api/v1/ – requires auth + api_access_control."""

    def test_unauthenticated_returns_401(self, client):
        resp = client.get("/api/v1/")
        assert resp.status_code == 401
        assert "authentication" in resp.get_json()["error"].lower()

    def test_returns_api_docs(self, authed_client):
        with _mock_access_control():
            resp = authed_client.get("/api/v1/")
            assert resp.status_code == 200
            data = resp.get_json()
            assert data["api_version"] == "v1"
            assert data["description"] == "REST API for Local Deep Research"
            assert len(data["endpoints"]) == 3

    def test_lists_all_endpoint_paths(self, authed_client):
        with _mock_access_control():
            resp = authed_client.get("/api/v1/")
            paths = [ep["path"] for ep in resp.get_json()["endpoints"]]
            assert "/api/v1/quick_summary" in paths
            assert "/api/v1/generate_report" in paths
            assert "/api/v1/analyze_documents" in paths

    def test_endpoint_entries_have_method_and_description(self, authed_client):
        with _mock_access_control():
            resp = authed_client.get("/api/v1/")
            for ep in resp.get_json()["endpoints"]:
                assert "method" in ep
                assert "description" in ep
                assert "parameters" in ep


# ===================================================================
# api_access_control decorator
# ===================================================================


class TestApiAccessControl:
    """Tests exercising the api_access_control decorator paths."""

    def test_no_session_returns_401(self, client):
        resp = client.get("/api/v1/")
        assert resp.status_code == 401

    def test_api_disabled_returns_403(self, authed_client):
        with _mock_access_control(api_enabled=False):
            resp = authed_client.get("/api/v1/")
            assert resp.status_code == 403
            assert "disabled" in resp.get_json()["error"].lower()

    def test_rate_limit_exceeded_returns_429(self, authed_client):
        with _mock_access_control(rate_limit=2):
            # First two should pass
            resp1 = authed_client.get("/api/v1/")
            assert resp1.status_code == 200
            resp2 = authed_client.get("/api/v1/")
            assert resp2.status_code == 200
            # Third should be rate-limited
            resp3 = authed_client.get("/api/v1/")
            assert resp3.status_code == 429

    def test_rate_limit_429_returns_json_with_custom_handler(self, app):
        """429 response has JSON body with 'error' and 'message' keys
        when the custom handler from app_factory is registered."""
        from flask import jsonify

        # Register the same custom 429 handler as app_factory.py
        @app.errorhandler(429)
        def ratelimit_handler(e):
            return (
                jsonify(
                    error="Too many requests",
                    message="Too many attempts. Please try again later.",
                ),
                429,
            )

        with app.test_client() as client:
            _auth_session(client)
            with _mock_access_control(rate_limit=1):
                # First request passes
                resp1 = client.get("/api/v1/")
                assert resp1.status_code == 200
                # Second hits the limit
                resp2 = client.get("/api/v1/")
                assert resp2.status_code == 429
                body = resp2.get_json()
                assert body is not None, "429 response should be JSON"
                assert body["error"] == "Too many requests"
                assert "message" in body

    def test_rate_limit_headers_present_on_success(self, authed_client):
        """Successful responses include X-RateLimit headers."""
        with _mock_access_control(rate_limit=10):
            resp = authed_client.get("/api/v1/")
            assert resp.status_code == 200
            # Flask-Limiter adds these headers when headers_enabled=True
            assert "X-RateLimit-Limit" in resp.headers
            assert "X-RateLimit-Remaining" in resp.headers
            assert "X-RateLimit-Reset" in resp.headers

    def test_rate_limit_remaining_header_decrements(self, authed_client):
        """X-RateLimit-Remaining decrements with each request."""
        with _mock_access_control(rate_limit=5):
            resp1 = authed_client.get("/api/v1/")
            assert resp1.status_code == 200
            remaining1 = int(resp1.headers["X-RateLimit-Remaining"])

            resp2 = authed_client.get("/api/v1/")
            assert resp2.status_code == 200
            remaining2 = int(resp2.headers["X-RateLimit-Remaining"])

            assert remaining2 == remaining1 - 1

    def test_different_users_have_independent_buckets(self, app):
        """User A hitting their limit does not affect User B."""
        with _mock_access_control(rate_limit=2):
            # User A exhausts their limit
            client_a = app.test_client()
            _auth_session(client_a, username="alice")
            assert client_a.get("/api/v1/").status_code == 200
            assert client_a.get("/api/v1/").status_code == 200
            assert client_a.get("/api/v1/").status_code == 429

            # User B is unaffected
            client_b = app.test_client()
            _auth_session(client_b, username="bob")
            assert client_b.get("/api/v1/").status_code == 200
            assert client_b.get("/api/v1/").status_code == 200

    def test_rate_limit_zero_means_no_limiting(self, authed_client):
        """rate_limit=0 (falsy) should exempt from rate limiting."""
        with _mock_access_control(rate_limit=0):
            for _ in range(5):
                resp = authed_client.get("/api/v1/")
                assert resp.status_code == 200

    def test_g_current_user_fallback(self, app):
        """When g.current_user is set, it should be used instead of session."""
        with app.test_request_context():
            from flask import g

            g.current_user = "guser"

            with _mock_access_control():
                with app.test_client() as c:
                    with c.session_transaction() as sess:
                        sess["username"] = "guser"
                    resp = c.get("/api/v1/")
                    assert resp.status_code == 200

    def test_db_session_none_still_allows(self, authed_client):
        """When get_user_db_session returns None, api_enabled stays True."""
        with patch(
            "local_deep_research.web.api.get_user_db_session"
        ) as mock_ctx:
            mock_ctx.return_value.__enter__ = MagicMock(return_value=None)
            mock_ctx.return_value.__exit__ = MagicMock(return_value=None)

            resp = authed_client.get("/api/v1/")
            assert resp.status_code == 200


# ===================================================================
# /api/v1/quick_summary_test
# ===================================================================


class TestQuickSummaryTest:
    """POST /api/v1/quick_summary_test"""

    def test_unauthenticated_returns_401(self, client):
        resp = client.post("/api/v1/quick_summary_test", json={"query": "hi"})
        assert resp.status_code == 401

    def test_no_json_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary_test",
                content_type="application/json",
            )
            assert resp.status_code == 400

    def test_missing_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary_test", json={"not_query": "x"}
            )
            assert resp.status_code == 400
            assert "query" in resp.get_json()["error"].lower()

    def test_empty_dict_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post("/api/v1/quick_summary_test", json={})
            assert resp.status_code == 400

    def test_successful_summary(self, authed_client):
        mock_result = {"summary": "test summary", "sources": []}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/quick_summary_test",
                    json={"query": "test query"},
                )
                assert resp.status_code == 200
                assert resp.get_json() == mock_result

    def test_internal_error_returns_500(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                side_effect=RuntimeError("boom"),
            ):
                resp = authed_client.post(
                    "/api/v1/quick_summary_test",
                    json={"query": "test query"},
                )
                assert resp.status_code == 500
                assert "internal error" in resp.get_json()["error"].lower()

    def test_non_dict_body_returns_400(self, authed_client):
        """Sending a JSON list should trigger require_json_body guard."""
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary_test",
                data="[1,2,3]",
                content_type="application/json",
            )
            assert resp.status_code == 400


# ===================================================================
# /api/v1/quick_summary
# ===================================================================


class TestQuickSummary:
    """POST /api/v1/quick_summary"""

    def test_unauthenticated_returns_401(self, client):
        resp = client.post("/api/v1/quick_summary", json={"query": "hi"})
        assert resp.status_code == 401

    def test_no_json_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", content_type="application/json"
            )
            assert resp.status_code == 400

    def test_missing_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", json={"search_tool": "auto"}
            )
            assert resp.status_code == 400

    def test_non_string_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", json={"query": 42}
            )
            assert resp.status_code == 400
            assert "string" in resp.get_json()["error"].lower()

    def test_null_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", json={"query": None}
            )
            assert resp.status_code == 400

    def test_list_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", json={"query": ["a", "b"]}
            )
            assert resp.status_code == 400

    def test_dict_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary", json={"query": {"nested": True}}
            )
            assert resp.status_code == 400

    def test_successful_quick_summary(self, authed_client):
        mock_result = {"findings": [], "summary": "done"}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                return_value=mock_result,
            ):
                with patch(
                    "local_deep_research.web.api.get_user_db_session"
                ) as inner_ctx:
                    inner_ctx.return_value.__enter__ = MagicMock(
                        return_value=None
                    )
                    inner_ctx.return_value.__exit__ = MagicMock(
                        return_value=None
                    )
                    resp = authed_client.post(
                        "/api/v1/quick_summary", json={"query": "test"}
                    )
                    assert resp.status_code == 200

    def test_timeout_returns_504(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                side_effect=TimeoutError("slow"),
            ):
                with patch(
                    "local_deep_research.web.api.get_user_db_session"
                ) as inner_ctx:
                    inner_ctx.return_value.__enter__ = MagicMock(
                        return_value=None
                    )
                    inner_ctx.return_value.__exit__ = MagicMock(
                        return_value=None
                    )
                    resp = authed_client.post(
                        "/api/v1/quick_summary", json={"query": "slow query"}
                    )
                    assert resp.status_code == 504
                    assert "timed out" in resp.get_json()["error"].lower()

    def test_generic_error_returns_500(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                side_effect=ValueError("bad"),
            ):
                with patch(
                    "local_deep_research.web.api.get_user_db_session"
                ) as inner_ctx:
                    inner_ctx.return_value.__enter__ = MagicMock(
                        return_value=None
                    )
                    inner_ctx.return_value.__exit__ = MagicMock(
                        return_value=None
                    )
                    resp = authed_client.post(
                        "/api/v1/quick_summary", json={"query": "fail"}
                    )
                    assert resp.status_code == 500
                    assert "internal error" in resp.get_json()["error"].lower()

    def test_optional_params_forwarded(self, authed_client):
        """Extra params like search_tool, iterations, temperature should
        be forwarded to the research function."""
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.quick_summary",
                return_value={"findings": []},
            ) as mock_qs:
                with patch(
                    "local_deep_research.web.api.get_user_db_session"
                ) as inner_ctx:
                    inner_ctx.return_value.__enter__ = MagicMock(
                        return_value=None
                    )
                    inner_ctx.return_value.__exit__ = MagicMock(
                        return_value=None
                    )
                    resp = authed_client.post(
                        "/api/v1/quick_summary",
                        json={
                            "query": "test",
                            "search_tool": "wikipedia",
                            "iterations": 3,
                            "temperature": 0.5,
                        },
                    )
                    assert resp.status_code == 200
                    call_kwargs = mock_qs.call_args
                    assert call_kwargs[0][0] == "test"
                    # The explicit values should override defaults
                    assert call_kwargs[1]["search_tool"] == "wikipedia"
                    assert call_kwargs[1]["iterations"] == 3
                    assert call_kwargs[1]["temperature"] == 0.5

    def test_settings_snapshot_loaded(self, authed_client):
        """When the user has a valid db session, settings_snapshot is populated."""
        mock_all_settings = {
            "some.key": {"value": 42},
            "another.key": "raw_value",
        }
        mock_sm_instance = MagicMock()
        mock_sm_instance.get_all_settings.return_value = mock_all_settings
        # Also handle the api_access_control decorator calls
        mock_sm_instance.get_setting.side_effect = lambda key, default: {
            "app.enable_api": True,
        }.get(key, default)

        # The decorator uses module-level imports while the function body
        # re-imports from the original modules. Patch both paths.
        with (
            patch(
                "local_deep_research.web.api.get_user_db_session"
            ) as mock_ctx,
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                mock_ctx,
            ),
            patch(
                "local_deep_research.web.api.get_settings_manager",
                return_value=mock_sm_instance,
            ),
            patch(
                "local_deep_research.utilities.db_utils.get_settings_manager",
                return_value=mock_sm_instance,
            ),
            patch(
                "local_deep_research.api.research_functions.quick_summary",
                return_value={"findings": []},
            ) as mock_qs,
        ):
            mock_db = MagicMock()
            mock_ctx.return_value.__enter__ = MagicMock(return_value=mock_db)
            mock_ctx.return_value.__exit__ = MagicMock(return_value=None)

            resp = authed_client.post(
                "/api/v1/quick_summary",
                json={"query": "test"},
            )
            assert resp.status_code == 200
            snapshot = mock_qs.call_args[1].get("settings_snapshot")
            assert snapshot is not None
            assert snapshot["some.key"] == 42
            assert snapshot["another.key"] == "raw_value"

    def test_settings_snapshot_failure_uses_empty(self, authed_client):
        """If loading settings snapshot fails, an empty dict is used
        and the request still succeeds."""
        # The function body re-imports get_user_db_session from
        # database.session_context, so patch that to raise.
        failing_ctx = MagicMock()
        failing_ctx.return_value.__enter__ = MagicMock(
            side_effect=RuntimeError("db fail")
        )
        failing_ctx.return_value.__exit__ = MagicMock(return_value=None)

        with _mock_access_control():
            with (
                patch(
                    "local_deep_research.api.research_functions.quick_summary",
                    return_value={"findings": []},
                ) as mock_qs,
                patch(
                    "local_deep_research.database.session_context.get_user_db_session",
                    failing_ctx,
                ),
            ):
                resp = authed_client.post(
                    "/api/v1/quick_summary",
                    json={"query": "test"},
                )
                assert resp.status_code == 200
                assert mock_qs.call_args[1]["settings_snapshot"] == {}

    def test_non_dict_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/quick_summary",
                data="[1]",
                content_type="application/json",
            )
            assert resp.status_code == 400


# ===================================================================
# /api/v1/generate_report
# ===================================================================


class TestGenerateReport:
    """POST /api/v1/generate_report"""

    def test_unauthenticated_returns_401(self, client):
        resp = client.post("/api/v1/generate_report", json={"query": "hi"})
        assert resp.status_code == 401

    def test_no_json_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/generate_report", content_type="application/json"
            )
            assert resp.status_code == 400

    def test_missing_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/generate_report", json={"temperature": 0.5}
            )
            assert resp.status_code == 400

    def test_successful_report(self, authed_client):
        mock_result = {"content": "short report", "title": "Report"}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "test"}
                )
                assert resp.status_code == 200
                data = resp.get_json()
                assert data["content"] == "short report"
                assert data["title"] == "Report"

    def test_large_report_is_truncated(self, authed_client):
        long_content = "x" * 15000
        mock_result = {"content": long_content, "title": "Big Report"}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "big"}
                )
                assert resp.status_code == 200
                data = resp.get_json()
                assert data["content_truncated"] is True
                assert len(data["content"]) < 15000
                assert data["content"].endswith("... [Content truncated]")

    def test_content_exactly_10000_not_truncated(self, authed_client):
        content = "a" * 10000
        mock_result = {"content": content}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "q"}
                )
                data = resp.get_json()
                assert "content_truncated" not in data
                assert len(data["content"]) == 10000

    def test_timeout_returns_504(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                side_effect=TimeoutError("slow"),
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "slow"}
                )
                assert resp.status_code == 504

    def test_generic_error_returns_500(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                side_effect=RuntimeError("boom"),
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "fail"}
                )
                assert resp.status_code == 500

    def test_optional_params_forwarded(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value={"content": "ok"},
            ) as mock_gr:
                resp = authed_client.post(
                    "/api/v1/generate_report",
                    json={
                        "query": "test",
                        "output_file": "/tmp/out.md",
                        "searches_per_section": 5,
                        "model_name": "gpt-4",
                        "temperature": 0.3,
                    },
                )
                assert resp.status_code == 200
                kw = mock_gr.call_args[1]
                assert kw["output_file"] == "/tmp/out.md"
                assert kw["model_name"] == "gpt-4"
                assert kw["temperature"] == 0.3
                assert kw["searches_per_section"] == 5

    def test_default_params_applied(self, authed_client):
        """searches_per_section and temperature get defaults if omitted."""
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value={"content": "ok"},
            ) as mock_gr:
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "test"}
                )
                assert resp.status_code == 200
                kw = mock_gr.call_args[1]
                assert kw["searches_per_section"] == 1
                assert kw["temperature"] == 0.7

    def test_non_dict_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/generate_report",
                data='"just a string"',
                content_type="application/json",
            )
            assert resp.status_code == 400

    def test_result_none_content_not_truncated(self, authed_client):
        """If content is not a string (e.g. None), truncation is skipped."""
        mock_result = {"content": None, "title": "T"}
        with _mock_access_control():
            with patch(
                "local_deep_research.api.research_functions.generate_report",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/generate_report", json={"query": "q"}
                )
                assert resp.status_code == 200
                assert resp.get_json()["content"] is None


# ===================================================================
# /api/v1/analyze_documents
# ===================================================================


class TestAnalyzeDocuments:
    """POST /api/v1/analyze_documents"""

    def test_unauthenticated_returns_401(self, client):
        resp = client.post(
            "/api/v1/analyze_documents",
            json={"query": "q", "collection_name": "c"},
        )
        assert resp.status_code == 401

    def test_no_json_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/analyze_documents", content_type="application/json"
            )
            assert resp.status_code == 400

    def test_missing_query_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/analyze_documents", json={"collection_name": "c"}
            )
            assert resp.status_code == 400

    def test_missing_collection_name_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/analyze_documents", json={"query": "q"}
            )
            assert resp.status_code == 400

    def test_missing_both_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/analyze_documents", json={"temperature": 0.5}
            )
            assert resp.status_code == 400
            assert "both" in resp.get_json()["error"].lower()

    def test_successful_analyze(self, authed_client):
        mock_result = {"analysis": "done", "documents": []}
        with _mock_access_control():
            with patch(
                "local_deep_research.web.api.analyze_documents",
                return_value=mock_result,
            ):
                resp = authed_client.post(
                    "/api/v1/analyze_documents",
                    json={"query": "neural nets", "collection_name": "papers"},
                )
                assert resp.status_code == 200
                assert resp.get_json() == mock_result

    def test_extra_params_forwarded(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.web.api.analyze_documents",
                return_value={},
            ) as mock_ad:
                resp = authed_client.post(
                    "/api/v1/analyze_documents",
                    json={
                        "query": "q",
                        "collection_name": "c",
                        "max_results": 10,
                        "temperature": 0.3,
                        "force_reindex": True,
                    },
                )
                assert resp.status_code == 200
                kw = mock_ad.call_args[1]
                assert kw["max_results"] == 10
                assert kw["temperature"] == 0.3
                assert kw["force_reindex"] is True

    def test_error_returns_500(self, authed_client):
        with _mock_access_control():
            with patch(
                "local_deep_research.web.api.analyze_documents",
                side_effect=RuntimeError("boom"),
            ):
                resp = authed_client.post(
                    "/api/v1/analyze_documents",
                    json={"query": "q", "collection_name": "c"},
                )
                assert resp.status_code == 500
                assert "internal error" in resp.get_json()["error"].lower()

    def test_non_dict_body_returns_400(self, authed_client):
        with _mock_access_control():
            resp = authed_client.post(
                "/api/v1/analyze_documents",
                data="42",
                content_type="application/json",
            )
            assert resp.status_code == 400


# ===================================================================
# _serialize_results helper
# ===================================================================


class TestSerializeResults:
    """Unit tests for the _serialize_results helper."""

    def test_converts_documents(self, app):
        from local_deep_research.web.api import _serialize_results

        mock_doc = MagicMock()
        mock_doc.metadata = {"source": "wiki"}
        mock_doc.page_content = "some text"

        results = {
            "findings": [{"documents": [mock_doc], "query": "test"}],
            "summary": "ok",
        }

        with app.app_context():
            resp = _serialize_results(results)
            data = resp.get_json()
            assert data["summary"] == "ok"
            doc = data["findings"][0]["documents"][0]
            assert doc["metadata"] == {"source": "wiki"}
            assert doc["content"] == "some text"

    def test_no_findings_key(self, app):
        from local_deep_research.web.api import _serialize_results

        results = {"summary": "nothing"}
        with app.app_context():
            resp = _serialize_results(results)
            assert resp.get_json() == {"summary": "nothing"}

    def test_empty_findings(self, app):
        from local_deep_research.web.api import _serialize_results

        results = {"findings": []}
        with app.app_context():
            resp = _serialize_results(results)
            assert resp.get_json()["findings"] == []

    def test_finding_without_documents(self, app):
        from local_deep_research.web.api import _serialize_results

        results = {"findings": [{"query": "test"}]}
        with app.app_context():
            resp = _serialize_results(results)
            assert resp.get_json()["findings"][0]["query"] == "test"

    def test_does_not_mutate_original(self, app):
        from local_deep_research.web.api import _serialize_results

        mock_doc = MagicMock()
        mock_doc.metadata = {"a": 1}
        mock_doc.page_content = "text"

        original_findings = [{"documents": [mock_doc]}]
        results = {"findings": original_findings}
        with app.app_context():
            _serialize_results(results)
        # Original list should still contain the mock object
        assert original_findings[0]["documents"][0] is not None


# ===================================================================
# HTTP method enforcement
# ===================================================================


class TestHttpMethods:
    """Verify endpoints reject wrong HTTP methods."""

    def test_health_rejects_post(self, client):
        resp = client.post("/api/v1/health")
        assert resp.status_code == 405

    def test_docs_rejects_post(self, authed_client):
        resp = authed_client.post("/api/v1/")
        assert resp.status_code == 405

    def test_quick_summary_rejects_get(self, authed_client):
        resp = authed_client.get("/api/v1/quick_summary")
        assert resp.status_code == 405

    def test_generate_report_rejects_get(self, authed_client):
        resp = authed_client.get("/api/v1/generate_report")
        assert resp.status_code == 405

    def test_analyze_documents_rejects_get(self, authed_client):
        resp = authed_client.get("/api/v1/analyze_documents")
        assert resp.status_code == 405

    def test_quick_summary_test_rejects_get(self, authed_client):
        resp = authed_client.get("/api/v1/quick_summary_test")
        assert resp.status_code == 405
