"""Credential lifecycle tests for the background scheduler.

These tests pin behavior the surrounding ~600-test scheduler suite did not
already cover: multi-retrieve across TTL expiry, snapshot semantics across
unregister_user, cross-user isolation, clear() idempotence, TTL boundary
cycles through the SchedulerCredentialStore wrapper, ttl_hours=0 constructor
behavior, last_run preservation when DB open fails, and search-context
propagation before document processing runs.

Each test documents the production line(s) it pins and the mutation that
would flip it.
"""

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

import pytest


# ---------------------------------------------------------------------------
# Credential expiry + isolation through SchedulerCredentialStore
# ---------------------------------------------------------------------------


class TestCredentialExpiryAndIsolation:
    """Credential lifecycle through the SchedulerCredentialStore wrapper.

    SchedulerCredentialStore (background.py:34-57) delegates to
    CredentialStoreBase (database/credential_store_base.py). The base class
    has its own TTL tests at tests/database/test_credential_store_ttl.py;
    this class covers wrapper-level integration and multi-call scenarios
    the base tests do not.
    """

    def test_credential_expiry_between_two_retrieves_in_same_job(self):
        """First retrieve at t=0 returns the password; second retrieve after
        TTL expires returns None and the entry has been swept from _store.

        Pins credential_store_base.py:73-75 (the lazy-delete on expired
        retrieve) reachable via SchedulerCredentialStore.retrieve at
        background.py:50-53.

        Mutation: changing the comparison to ``time.time() > entry["expires_at"] + 86400``
        would let the second retrieve return "pw".
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        store = SchedulerCredentialStore(ttl_hours=48)
        # TTL = 48 * 3600 = 172800 seconds
        with patch(
            "local_deep_research.database.credential_store_base.time.time"
        ) as mock_time:
            mock_time.return_value = 1000.0
            store.store("alice", "pw")
            first = store.retrieve("alice")

            # Advance past expiry
            mock_time.return_value = 1000.0 + 172800 + 1
            second = store.retrieve("alice")

        assert first == "pw"
        assert second is None

    def test_unregister_user_clears_credential(self):
        """unregister_user must clear the credential entry. Local references
        the caller already extracted continue to work — Python locals are
        not invalidated — but subsequent retrieves see no entry.

        Pins background.py:454-468 (unregister_user holds self.lock and
        calls _credential_store.clear inside it) and the chain
        SchedulerCredentialStore.clear -> CredentialStoreBase.clear_entry
        at credential_store_base.py:98-107.

        Mutation: removing the clear call in unregister_user would let the
        second retrieve still return "pw".
        """
        from local_deep_research.scheduler.background import (
            BackgroundJobScheduler,
        )

        with patch(
            "local_deep_research.scheduler.background.BackgroundScheduler"
        ) as mock_sched_cls:
            mock_sched_cls.return_value = MagicMock()
            scheduler = BackgroundJobScheduler()
            scheduler.is_running = True
            # unregister_user is a no-op unless user is in user_sessions
            # (background.py:455) — seed both stores to mirror real state
            # after a login.
            scheduler.user_sessions["alice"] = {
                "last_activity": datetime.now(UTC),
                "scheduled_jobs": set(),
            }
            scheduler._credential_store.store("alice", "pw")

            # Caller (a job) has already retrieved the password
            snapshot = scheduler._credential_store.retrieve("alice")

            scheduler.unregister_user("alice")

            assert snapshot == "pw"
            assert scheduler._credential_store.retrieve("alice") is None
            assert "alice" not in scheduler.user_sessions

    @pytest.mark.parametrize(
        "lookup,expected",
        [
            ("alice", "pwd1"),
            ("bob", "pwd2"),
            ("charlie", None),
        ],
    )
    def test_cross_user_credential_isolation(self, lookup, expected):
        """retrieve must return only the credential keyed by the requested
        username — never another user's, never a default.

        Pins the username-keyed dispatch at background.py:44-53 and
        credential_store_base.py:67.

        Mutation: replacing the lookup key with a constant (e.g., always
        return the first stored entry) would make at least one parametrize
        case fail.
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        store = SchedulerCredentialStore(ttl_hours=48)
        store.store("alice", "pwd1")
        store.store("bob", "pwd2")

        assert store.retrieve(lookup) == expected

    def test_clear_is_idempotent_and_safe_on_unknown_user(self):
        """clear() must be safe to call twice on the same user and once
        on a never-stored user. Pins the ``if key in self._store`` guard at
        credential_store_base.py:106.

        Mutation: removing the membership guard makes the second clear and
        the ghost clear raise KeyError.
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        store = SchedulerCredentialStore(ttl_hours=48)
        store.store("alice", "pw")

        store.clear("alice")
        assert store.retrieve("alice") is None
        store.clear("alice")  # second clear on already-cleared user
        store.clear("ghost")  # never-stored user
        # No exception means the guard works


# ---------------------------------------------------------------------------
# TTL boundary behavior through the SchedulerCredentialStore wrapper
# ---------------------------------------------------------------------------


class TestTtlWrapperBehavior:
    """SchedulerCredentialStore-specific TTL behavior.

    The base class TTL math is exercised by tests/database/test_credential_store_ttl.py.
    These tests cover the wrapper's ``ttl_hours * 3600`` conversion at
    background.py:42 and full store -> expire -> store cycles through the
    wrapper API, which the base-class tests do not.
    """

    def test_ttl_boundary_store_expire_store_cycle(self):
        """Store, let the entry expire, then store again — the second
        store must give a fresh credential that retrieve returns.

        Pins background.py:42 (hours -> seconds conversion) and
        credential_store_base.py:47 (expires_at recomputed on each store).
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        ttl_hours = 48
        ttl_seconds = ttl_hours * 3600

        with patch(
            "local_deep_research.database.credential_store_base.time.time"
        ) as mock_time:
            mock_time.return_value = 1000.0
            store = SchedulerCredentialStore(ttl_hours=ttl_hours)

            store.store("alice", "p1")
            assert store.retrieve("alice") == "p1"

            mock_time.return_value = 1000.0 + ttl_seconds + 1
            assert store.retrieve("alice") is None

            # Re-store at the same (now-expired) timestamp
            store.store("alice", "p2")
            assert store.retrieve("alice") == "p2"

            # Advance again — fresh entry also expires after its own TTL
            mock_time.return_value = (
                (1000.0 + ttl_seconds + 1) + ttl_seconds + 1
            )
            assert store.retrieve("alice") is None

    def test_ttl_hours_zero_expires_at_next_clock_tick(self):
        """``SchedulerCredentialStore(ttl_hours=0)`` is silently accepted.
        At the exact store time the credential is still retrievable
        (strict ``>`` in credential_store_base.py:73), but any later tick
        returns None.

        Pins the absence of validation in the constructor (background.py:41-42
        does not reject ttl_hours=0) and the strict comparison in
        credential_store_base.py:73.

        This is a contract test: anyone who adds ``if ttl_hours <= 0:
        raise ValueError`` must update this test.
        """
        from local_deep_research.scheduler.background import (
            SchedulerCredentialStore,
        )

        with patch(
            "local_deep_research.database.credential_store_base.time.time"
        ) as mock_time:
            mock_time.return_value = 1000.0
            store = SchedulerCredentialStore(ttl_hours=0)
            store.store("alice", "pw")

            # At the exact expiry instant the entry is still live
            assert store.retrieve("alice") == "pw"

            # Any later tick — even sub-second — expires it
            mock_time.return_value = 1000.0001
            assert store.retrieve("alice") is None


# ---------------------------------------------------------------------------
# Document scheduler: last_run preservation and search context propagation
# ---------------------------------------------------------------------------


def _make_scheduler():
    """Build a BackgroundJobScheduler with APScheduler mocked so no threads
    are spawned. The global ``reset_all_singletons`` autouse fixture at
    tests/conftest.py:76-94 handles teardown (including .stop()).
    """
    from local_deep_research.scheduler.background import (
        BackgroundJobScheduler,
    )

    with patch(
        "local_deep_research.scheduler.background.BackgroundScheduler"
    ) as mock_sched_cls:
        mock_sched_cls.return_value = MagicMock()
        return BackgroundJobScheduler()


def _seed_user(scheduler, username="alice", password="pw"):
    """Seed the scheduler with one active user. Returns (scheduler, username,
    password).
    """
    scheduler.user_sessions[username] = {
        "last_activity": datetime.now(UTC),
        "scheduled_jobs": set(),
    }
    scheduler._credential_store.store(username, password)


def _doc_scheduler_settings(**overrides):
    """Build a DocumentSchedulerSettings with at least one processing flag
    set so ``_process_user_documents`` does not short-circuit at the
    ``if not any([...])`` guard (background.py:717-727).
    """
    from local_deep_research.scheduler.background import (
        DocumentSchedulerSettings,
    )

    defaults = dict(enabled=True, download_pdfs=True, last_run="")
    defaults.update(overrides)
    return DocumentSchedulerSettings(**defaults)


def _make_db_session_with_research(research_id="r-1"):
    """Build a mock ``get_user_db_session`` context manager whose ``db``
    yields one ResearchHistory-shaped row. Returned object can be used
    as ``return_value`` of a ``patch(...get_user_db_session...)`` call.
    """
    mock_research = MagicMock()
    mock_research.id = research_id
    mock_research.title = "test research"
    mock_research.completed_at = datetime(2026, 1, 1, tzinfo=UTC)

    mock_db = MagicMock()
    mock_query = mock_db.query.return_value
    mock_query.filter.return_value = mock_query
    mock_query.order_by.return_value = mock_query
    mock_query.limit.return_value = mock_query
    mock_query.all.return_value = [mock_research]

    mock_db_ctx = MagicMock()
    mock_db_ctx.__enter__.return_value = mock_db
    mock_db_ctx.__exit__.return_value = False
    return mock_db_ctx


class TestDocSchedulerCredentialLifecycle:
    """Tests that exercise ``_process_user_documents`` end-to-end with
    just enough mocking to reach the protected branches.
    """

    def test_last_run_not_advanced_when_db_open_fails(self):
        """If ``get_user_db_session`` raises before the work block, the
        outer except at background.py:1095-1098 swallows it and the
        ``set_setting("document_scheduler.last_run", ...)`` call at
        background.py:1082-1084 is never reached.

        Pins the intentional design from PR #3288 / commit 405226638
        which documents (1076-1080) why ``last_run`` is NOT in a
        try/finally — advancing it on setup failure would mask a
        persistent failure (corrupted DB, wrong password).

        Mutation: wrapping the set_setting call in a try/finally that
        always runs would surface here as an unexpected ``last_run``
        update. Note this only catches mutations that can succeed in
        advancing last_run without DB access — the complementary
        happy-path assertion below confirms the call IS made when the
        with-block succeeds, so the contrast pins the contract.
        """
        scheduler = _make_scheduler()
        _seed_user(scheduler)

        set_setting_calls = []

        def fake_set_setting(key, value, **kwargs):
            set_setting_calls.append(key)

        # --- Unhappy path: DB open fails, last_run must NOT be advanced. ---
        with (
            patch.object(
                scheduler,
                "_get_document_scheduler_settings",
                return_value=_doc_scheduler_settings(),
            ),
            patch(
                "local_deep_research.database.session_context.get_user_db_session"
            ) as mock_get_db,
            patch(
                "local_deep_research.settings.manager.SettingsManager.set_setting",
                side_effect=fake_set_setting,
            ),
        ):
            mock_get_db.side_effect = RuntimeError("simulated DB open failure")
            scheduler._process_user_documents("alice")

        assert "document_scheduler.last_run" not in set_setting_calls, (
            "last_run must NOT be advanced when DB open fails — see PR #3288"
        )

        # --- Happy path contrast: DB open succeeds, last_run IS advanced.
        # Without this contrast, the unhappy-path assertion above could pass
        # trivially (e.g. if the set_setting call were removed entirely).
        set_setting_calls.clear()

        with (
            patch.object(
                scheduler,
                "_get_document_scheduler_settings",
                return_value=_doc_scheduler_settings(download_pdfs=True),
            ),
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_make_db_session_with_research(),
            ),
            patch(
                "local_deep_research.settings.manager.SettingsManager"
            ) as mock_sm_cls,
            patch(
                "local_deep_research.utilities.thread_context.set_search_context"
            ),
            patch(
                "local_deep_research.research_library.services.download_service.DownloadService"
            ),
        ):
            mock_sm_cls.return_value.set_setting.side_effect = fake_set_setting
            scheduler._process_user_documents("alice")

        assert "document_scheduler.last_run" in set_setting_calls, (
            "last_run MUST be advanced on the happy path — otherwise the "
            "unhappy-path assertion above is trivially satisfied"
        )

    def test_search_context_set_before_processing_each_research(self):
        """``set_search_context`` must be called for each research with the
        username, password, research_id, and a research_phase tag of
        ``document_scheduler``. This is the fix from PR #3289 / commit
        1a0d46e69 — without it, downloads bypass rate limiting because
        the per-thread context is missing.

        Pins background.py:831-844.

        Mutation: deleting the set_search_context call would surface here
        because the mock would never be invoked.
        """
        scheduler = _make_scheduler()
        _seed_user(scheduler, password="pw")

        with (
            patch.object(
                scheduler,
                "_get_document_scheduler_settings",
                return_value=_doc_scheduler_settings(download_pdfs=True),
            ),
            # get_user_db_session and set_search_context are imported
            # INSIDE _process_user_documents (background.py:739 and 833-835),
            # so patches target the source modules.
            patch(
                "local_deep_research.database.session_context.get_user_db_session",
                return_value=_make_db_session_with_research(
                    research_id="r-123"
                ),
            ),
            patch("local_deep_research.settings.manager.SettingsManager"),
            patch(
                "local_deep_research.utilities.thread_context.set_search_context"
            ) as mock_set_ctx,
            # download_pdfs path imports DownloadService inside the loop;
            # patch the source module so the import resolves to a no-op.
            patch(
                "local_deep_research.research_library.services.download_service.DownloadService"
            ),
        ):
            scheduler._process_user_documents("alice")

        assert mock_set_ctx.called, (
            "set_search_context must be called for each research; the "
            "fix from PR #3289 sets per-thread context so download rate "
            "limiting works."
        )
        call_arg = mock_set_ctx.call_args[0][0]
        assert call_arg["research_id"] == "r-123"
        assert call_arg["username"] == "alice"
        assert call_arg["user_password"] == "pw"
        assert call_arg["research_phase"] == "document_scheduler"
