"""
Tests for scheduler, folder, and history endpoints in news/flask_api.py.

Covers:
- get_scheduler_status  (user-scoped and with jobs)
- start_scheduler       (already running)
- stop_scheduler        (not running, no instance)
- check_subscriptions_now (not initialized)
- trigger_cleanup       (not running)
- create_folder         (already exists)
- update_folder         (not found)
- delete_folder         (not found)
- get_search_history    (no user -> empty list)
- add_search_history    (missing query -> 400)
- clear_search_history  (no user -> success)
- check_overdue         (empty list, mixed success/failure)
"""

import sys
import types
import pytest
from contextlib import contextmanager
from unittest.mock import patch, MagicMock
from flask import Flask


# ---------------------------------------------------------------------------
# Module-injection helpers
#
# Several endpoints do local imports of heavy modules (spacy, LibraryRAGService)
# via:
#   from .subscription_manager.scheduler import get_background_job_scheduler
#   from .core.utils import get_local_date_string
#
# We inject lightweight fake modules into sys.modules so these imports succeed
# without pulling the real (slow/heavy) dependencies.
# ---------------------------------------------------------------------------


def _ensure_parent_packages(module_name):
    """Ensure every parent package stub is present in sys.modules."""
    parts = module_name.split(".")
    for i in range(1, len(parts)):
        pkg_name = ".".join(parts[:i])
        if pkg_name not in sys.modules:
            sys.modules[pkg_name] = types.ModuleType(pkg_name)


@contextmanager
def _fake_scheduler_module(mock_scheduler):
    """Inject a fake subscription_manager.scheduler so get_background_job_scheduler
    returns mock_scheduler without importing the real (spacy-heavy) module."""
    module_name = "local_deep_research.scheduler.background"
    orig = sys.modules.get(module_name)
    _ensure_parent_packages(module_name)
    mod = types.ModuleType(module_name)
    mod.get_background_job_scheduler = lambda: mock_scheduler
    mod.BackgroundJobScheduler = MagicMock
    sys.modules[module_name] = mod
    try:
        yield
    finally:
        if orig is None:
            sys.modules.pop(module_name, None)
        else:
            sys.modules[module_name] = orig


@contextmanager
def _fake_news_core_utils(date_string="2030-01-01"):
    """Inject a fake news.core.utils so get_local_date_string returns
    date_string without importing spacy-dependent code."""
    module_name = "local_deep_research.news.core.utils"
    orig = sys.modules.get(module_name)
    _ensure_parent_packages(module_name)
    mod = types.ModuleType(module_name)
    mod.get_local_date_string = lambda *a, **kw: date_string
    sys.modules[module_name] = mod
    try:
        yield
    finally:
        if orig is None:
            sys.modules.pop(module_name, None)
        else:
            sys.modules[module_name] = orig


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


@pytest.fixture
def app():
    """Create a minimal Flask app with the news_api blueprint registered."""
    flask_app = Flask(__name__)
    flask_app.config["SECRET_KEY"] = "test-secret-key"
    flask_app.config["WTF_CSRF_ENABLED"] = False
    flask_app.config["TESTING"] = True

    from local_deep_research.news.flask_api import news_api_bp

    flask_app.register_blueprint(news_api_bp, url_prefix="/news/api")
    return flask_app


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


# ---------------------------------------------------------------------------
# Shared helper
# ---------------------------------------------------------------------------


def _authenticated_client(app, mock_db_mgr):
    """Return a test client with a session that satisfies @login_required."""
    mock_db_mgr.is_user_connected.return_value = True
    c = app.test_client()
    with c.session_transaction() as sess:
        sess["username"] = "testuser"
    return c


# ---------------------------------------------------------------------------
# 1. get_scheduler_status — user-scoped (show_all=False)
# ---------------------------------------------------------------------------


class TestGetSchedulerStatusUserScoped:
    """show_all=False -> active_users counts only the current user."""

    def test_get_scheduler_status_user_scoped(self, app):
        # spec limits attributes so hasattr(mock, 'scheduler') is False,
        # preventing the APScheduler job-listing branch from executing.
        mock_scheduler = MagicMock(
            spec=["is_running", "config", "user_sessions"]
        )
        mock_scheduler.is_running = True
        mock_scheduler.config = {"interval": 60}
        mock_scheduler.user_sessions = {
            "testuser": {"scheduled_jobs": {"job1", "job2"}},
        }

        with (
            _fake_scheduler_module(mock_scheduler),
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=False,  # show_all = False
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.get("/news/api/scheduler/status")

        assert response.status_code == 200
        data = response.get_json()
        assert data["is_running"] is True
        assert data["active_users"] == 1  # only testuser
        assert data["total_scheduled_jobs"] == 2  # testuser's 2 jobs
        assert data["scheduled_jobs"] == 2


# ---------------------------------------------------------------------------
# 2. get_scheduler_status — apscheduler_jobs populated
# ---------------------------------------------------------------------------


class TestGetSchedulerStatusWithJobs:
    """When a real APScheduler instance is present its jobs appear in status."""

    def test_get_scheduler_status_with_jobs(self, app):
        from datetime import datetime, timezone

        mock_job = MagicMock()
        mock_job.id = "job_abc"
        mock_job.name = "refresh_testuser"
        mock_job.next_run_time = datetime(2030, 1, 1, tzinfo=timezone.utc)
        mock_job.args = ["testuser"]  # _is_job_owned_by_user checks args[0]

        inner_scheduler = MagicMock()
        inner_scheduler.get_jobs.return_value = [mock_job]

        mock_scheduler = MagicMock()
        mock_scheduler.is_running = True
        mock_scheduler.config = {}
        mock_scheduler.user_sessions = {
            "testuser": {"scheduled_jobs": {"job_abc"}},
        }
        mock_scheduler.scheduler = inner_scheduler

        with (
            _fake_scheduler_module(mock_scheduler),
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=False,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.get("/news/api/scheduler/status")

        assert response.status_code == 200
        data = response.get_json()
        assert data["apscheduler_job_count"] == 1
        assert len(data["apscheduler_jobs"]) == 1
        assert data["apscheduler_jobs"][0]["id"] == "job_abc"
        assert data["apscheduler_jobs"][0]["name"] == "refresh_testuser"


# ---------------------------------------------------------------------------
# 3. start_scheduler — already running → 200
# ---------------------------------------------------------------------------


class TestStartSchedulerAlreadyRunning:
    """If the scheduler is already running, start returns 200 + message."""

    def test_start_scheduler_already_running(self, app):
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = True

        with (
            _fake_scheduler_module(mock_scheduler),
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,  # allow_api_control = True
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/scheduler/start")

        assert response.status_code == 200
        data = response.get_json()
        assert "already running" in data["message"].lower()


# ---------------------------------------------------------------------------
# 4. stop_scheduler — scheduler present but not running → 200
# ---------------------------------------------------------------------------


class TestStopSchedulerNotRunning:
    """If the scheduler exists but is_running=False, stop returns 200."""

    def test_stop_scheduler_not_running(self, app):
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = False

        with (
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            # Inject news_scheduler into app before the test client runs
            app.background_job_scheduler = mock_scheduler
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/scheduler/stop")

        assert response.status_code == 200
        data = response.get_json()
        assert "not running" in data["message"].lower()


# ---------------------------------------------------------------------------
# 5. stop_scheduler — no scheduler instance → 404
# ---------------------------------------------------------------------------


class TestStopSchedulerNoInstance:
    """If current_app has no news_scheduler, stop returns 404."""

    def test_stop_scheduler_no_instance(self, app):
        # Ensure the attribute is absent from this fresh app
        if hasattr(app, "background_job_scheduler"):
            del app.background_job_scheduler

        with (
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/scheduler/stop")

        assert response.status_code == 404
        data = response.get_json()
        assert "message" in data
        assert "scheduler" in data["message"].lower()


# ---------------------------------------------------------------------------
# 6. check_subscriptions_now — no scheduler → 503
# ---------------------------------------------------------------------------


class TestCheckSubscriptionsNowNotInitialized:
    """When current_app has no news_scheduler, check-now returns 503."""

    def test_check_subscriptions_now_not_initialized(self, app):
        if hasattr(app, "background_job_scheduler"):
            del app.background_job_scheduler

        with (
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/scheduler/check-now")

        assert response.status_code == 503
        data = response.get_json()
        assert "error" in data


# ---------------------------------------------------------------------------
# 7. trigger_cleanup — scheduler not running → 400
# ---------------------------------------------------------------------------


class TestTriggerCleanupNotRunning:
    """If the scheduler is not running, cleanup returns 400."""

    def test_trigger_cleanup_not_running(self, app):
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = False

        with (
            _fake_scheduler_module(mock_scheduler),
            patch(
                "local_deep_research.news.flask_api.get_env_setting",
                return_value=True,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/scheduler/cleanup-now")

        assert response.status_code == 400
        data = response.get_json()
        assert "error" in data
        assert "not running" in data["error"].lower()


# ---------------------------------------------------------------------------
# 8. create_folder — folder already exists → 409
# ---------------------------------------------------------------------------


class TestCreateFolderAlreadyExists:
    """Creating a folder whose name is already taken returns 409."""

    def test_create_folder_already_exists(self, app):
        existing_folder = MagicMock()

        mock_db = MagicMock()
        mock_db.query.return_value.filter_by.return_value.first.return_value = (
            existing_folder
        )

        mock_ctx = MagicMock()
        mock_ctx.__enter__ = MagicMock(return_value=mock_db)
        mock_ctx.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.news.flask_api.get_user_db_session",
                return_value=mock_ctx,
            ),
            patch(
                "local_deep_research.news.flask_api.get_user_id",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post(
                "/news/api/subscription/folders",
                json={"name": "Duplicate Folder"},
                content_type="application/json",
            )

        assert response.status_code == 409
        data = response.get_json()
        assert "error" in data
        assert "already exists" in data["error"].lower()


# ---------------------------------------------------------------------------
# 9. update_folder — folder not found → 404
# ---------------------------------------------------------------------------


class TestUpdateFolderNotFound:
    """Updating a non-existent folder returns 404."""

    def test_update_folder_not_found(self, app):
        mock_manager = MagicMock()
        mock_manager.update_folder.return_value = None  # not found

        mock_db = MagicMock()
        mock_ctx = MagicMock()
        mock_ctx.__enter__ = MagicMock(return_value=mock_db)
        mock_ctx.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.news.flask_api.get_user_db_session",
                return_value=mock_ctx,
            ),
            patch(
                "local_deep_research.news.flask_api.FolderManager",
                return_value=mock_manager,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.put(
                "/news/api/subscription/folders/nonexistent-id",
                json={"name": "New Name"},
                content_type="application/json",
            )

        assert response.status_code == 404
        data = response.get_json()
        assert "error" in data
        assert "not found" in data["error"].lower()


# ---------------------------------------------------------------------------
# 10. delete_folder — folder not found → 404
# ---------------------------------------------------------------------------


class TestDeleteFolderNotFound:
    """Deleting a non-existent folder returns 404."""

    def test_delete_folder_not_found(self, app):
        mock_manager = MagicMock()
        mock_manager.delete_folder.return_value = False  # not found

        mock_db = MagicMock()
        mock_ctx = MagicMock()
        mock_ctx.__enter__ = MagicMock(return_value=mock_db)
        mock_ctx.__exit__ = MagicMock(return_value=False)

        with (
            patch(
                "local_deep_research.news.flask_api.get_user_db_session",
                return_value=mock_ctx,
            ),
            patch(
                "local_deep_research.news.flask_api.FolderManager",
                return_value=mock_manager,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.delete("/news/api/subscription/folders/nonexistent-id")

        assert response.status_code == 404
        data = response.get_json()
        assert "error" in data
        assert "not found" in data["error"].lower()


# ---------------------------------------------------------------------------
# 11. get_search_history — current_user() returns None → empty list
# ---------------------------------------------------------------------------


class TestGetSearchHistoryNoUser:
    """When current_user() returns None, search history returns an empty list."""

    def test_get_search_history_no_user(self, app):
        # current_user is imported locally from web.auth.decorators; patch there.
        with (
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value=None,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.get("/news/api/search-history")

        assert response.status_code == 200
        data = response.get_json()
        assert data == {"search_history": []}


# ---------------------------------------------------------------------------
# 12. add_search_history — missing query → 400
# ---------------------------------------------------------------------------


class TestAddSearchHistoryNoQuery:
    """POSTing to search-history without 'query' returns 400."""

    def test_add_search_history_no_query(self, app):
        with (
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value="testuser",
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post(
                "/news/api/search-history",
                json={"type": "filter"},  # 'query' key intentionally absent
                content_type="application/json",
            )

        assert response.status_code == 400
        data = response.get_json()
        assert "error" in data
        assert "query" in data["error"].lower()


# ---------------------------------------------------------------------------
# 13. clear_search_history — current_user() returns None → success
# ---------------------------------------------------------------------------


class TestClearSearchHistoryNoUser:
    """When current_user() returns None, clear returns success immediately."""

    def test_clear_search_history_no_user(self, app):
        with (
            patch(
                "local_deep_research.web.auth.decorators.current_user",
                return_value=None,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.delete("/news/api/search-history")

        assert response.status_code == 200
        data = response.get_json()
        assert data == {"status": "success"}


# ---------------------------------------------------------------------------
# 14. check_overdue — no overdue subscriptions → empty results
# ---------------------------------------------------------------------------


class TestCheckOverdueNoOverdue:
    """When there are no overdue subscriptions the response is empty."""

    def test_check_overdue_no_overdue(self, app):
        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = []

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_db

        mock_settings_cls = MagicMock()
        mock_settings_cls.return_value.get_setting.return_value = "UTC"

        # check_overdue_subscriptions does a local
        # `from ..database.session_context import get_user_db_session`
        # which bypasses a patch of flask_api.get_user_db_session.
        # Patching the source module ensures the local re-import gets the mock.
        with (
            _fake_news_core_utils("2030-01-01"),
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                # SettingsManager is also imported locally; patch at its source.
                "local_deep_research.settings.manager.SettingsManager",
                mock_settings_cls,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/check-overdue")

        assert response.status_code == 200
        data = response.get_json()
        assert data["status"] == "success"
        assert data["overdue_found"] == 0
        assert data["started"] == 0
        assert data["results"] == []


# ---------------------------------------------------------------------------
# 15. check_overdue — mix of success and failure results
# ---------------------------------------------------------------------------


class TestCheckOverdueSuccessAndFailureMix:
    """Some subscriptions succeed (research starts) and some fail (HTTP error)."""

    def test_check_overdue_success_and_failure_mix(self, app):
        sub_ok = MagicMock()
        sub_ok.id = "uuid-ok"
        sub_ok.name = "Good Sub"
        sub_ok.query_or_topic = "climate YYYY-MM-DD"
        sub_ok.model_provider = "OLLAMA"
        sub_ok.model = "llama3"
        sub_ok.search_strategy = "news_aggregation"
        sub_ok.search_engine = None
        sub_ok.custom_endpoint = None
        sub_ok.refresh_interval_minutes = 60

        sub_fail = MagicMock()
        sub_fail.id = "uuid-fail"
        sub_fail.name = "Bad Sub"
        sub_fail.query_or_topic = "politics YYYY-MM-DD"
        sub_fail.model_provider = "OLLAMA"
        sub_fail.model = "llama3"
        sub_fail.search_strategy = "news_aggregation"
        sub_fail.search_engine = None
        sub_fail.custom_endpoint = None
        sub_fail.refresh_interval_minutes = 60

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.all.return_value = [
            sub_ok,
            sub_fail,
        ]

        @contextmanager
        def fake_session(*a, **kw):
            yield mock_db

        # start_research result for sub_ok: research started successfully
        ok_result = {
            "status": "success",
            "research_id": "rid-ok",
        }

        # start_research result for sub_fail: research returned an error body
        fail_result = {
            "status": "error",
            "message": "model not found",
        }

        mock_settings_cls = MagicMock()
        mock_settings_cls.return_value.get_setting.return_value = "UTC"

        with (
            _fake_news_core_utils("2030-01-01"),
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                side_effect=fake_session,
            ),
            patch(
                "local_deep_research.news.flask_api._call_start_research_internal",
                side_effect=[ok_result, fail_result],
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager",
                mock_settings_cls,
            ),
            patch(
                "local_deep_research.web.auth.decorators.db_manager"
            ) as mock_db_mgr,
        ):
            c = _authenticated_client(app, mock_db_mgr)
            response = c.post("/news/api/check-overdue")

        assert response.status_code == 200
        data = response.get_json()
        assert data["status"] == "success"
        assert data["overdue_found"] == 2
        assert data["started"] == 1  # only sub_ok succeeded

        by_id = {r["id"]: r for r in data["results"]}
        assert "uuid-ok" in by_id
        assert "uuid-fail" in by_id
        # The failed sub surfaces start_research's "message" text, not a generic
        # fallback — guards the message-over-error extraction.
        assert by_id["uuid-fail"]["error"] == "model not found"
        assert by_id["uuid-ok"]["research_id"] == "rid-ok"

        ok_result = next(r for r in data["results"] if r["id"] == "uuid-ok")
        fail_result = next(r for r in data["results"] if r["id"] == "uuid-fail")
        assert ok_result.get("research_id") == "rid-ok"
        assert "error" in fail_result
