"""Tests for journal_data_downloader — fetch from OpenAlex/DOAJ APIs."""

import gzip
import json
from unittest.mock import patch

import pytest

from local_deep_research.journal_quality.downloader import (
    JOURNAL_DATA_VERSION,
    download_journal_data,
    ensure_journal_data,
    get_journal_data_status,
)


@pytest.fixture
def tmp_data_dir(tmp_path):
    """Override journal data directory to a temp path."""
    with patch(
        "local_deep_research.journal_quality.downloader._get_data_dir",
        return_value=tmp_path,
    ):
        yield tmp_path


def _write_openalex(data_dir):
    """Create a minimal openalex file."""
    with gzip.open(data_dir / "openalex_sources.json.gz", "wt") as f:
        json.dump({"s": {"S1": {"n": "Nature", "t": "j", "h": 1000}}}, f)


class TestGetJournalDataStatus:
    def test_no_files(self, tmp_data_dir):
        status = get_journal_data_status()
        assert status["available"] is False
        assert status["version"] is None
        assert status["needs_update"] is True

    def test_files_present(self, tmp_data_dir):
        _write_openalex(tmp_data_dir)
        (tmp_data_dir / "doaj_journals.json").write_text("{}")
        (tmp_data_dir / "version.json").write_text(
            json.dumps({"version": JOURNAL_DATA_VERSION})
        )
        status = get_journal_data_status()
        assert status["available"] is True
        assert status["needs_update"] is False

    def test_outdated_version(self, tmp_data_dir):
        _write_openalex(tmp_data_dir)
        (tmp_data_dir / "version.json").write_text(
            json.dumps({"version": "v0"})
        )
        status = get_journal_data_status()
        assert status["needs_update"] is True


class TestDownloadJournalData:
    def test_already_up_to_date(self, tmp_data_dir):
        _write_openalex(tmp_data_dir)
        (tmp_data_dir / "doaj_journals.json").write_text("{}")
        (tmp_data_dir / "version.json").write_text(
            json.dumps({"version": JOURNAL_DATA_VERSION})
        )
        success, msg = download_journal_data()
        assert success is True
        assert "up to date" in msg

    def test_concurrent_download_blocked(self, tmp_data_dir):
        # Simulate an in-flight download by writing the *current* PID
        # into the sentinel. The runtime liveness check sees our own
        # PID → returns alive → download_journal_data bows out without
        # reclaiming (the "don't nuke self" guard).
        #
        # An empty sentinel (old behavior) would be treated as orphan
        # debris, reclaimed, and the test would fall through to real
        # network calls — which is exactly what the PID stamp prevents.
        import os

        sentinel = tmp_data_dir / ".downloading"
        sentinel.write_text(str(os.getpid()))
        success, msg = download_journal_data()
        assert success is False
        assert "already in progress" in msg

    @patch("local_deep_research.journal_quality.downloader._fetch_institutions")
    @patch(
        "local_deep_research.journal_quality.downloader"
        "._fetch_jabref_abbreviations"
    )
    @patch("local_deep_research.journal_quality.downloader._fetch_predatory")
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_openalex_sources"
    )
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_doaj_journals"
    )
    @patch("local_deep_research.journal_quality.db.build_db")
    def test_successful_fetch(
        self,
        mock_build_db,
        mock_doaj,
        mock_openalex,
        mock_pred,
        mock_jabref,
        mock_inst,
        tmp_data_dir,
    ):
        mock_openalex.return_value = 100
        mock_doaj.return_value = 50
        mock_pred.return_value = 0
        mock_jabref.return_value = 0
        mock_inst.return_value = 0
        # build_db runs after all fetches succeed; mock it so the happy-path
        # test doesn't depend on a real build against the minimal fixture.
        mock_build_db.return_value = None
        # Need the openalex file to exist after mock runs
        _write_openalex(tmp_data_dir)

        success, msg = download_journal_data(force=True)
        assert success is True
        assert "100 OpenAlex" in msg
        assert "50 DOAJ" in msg
        mock_openalex.assert_called_once()
        mock_doaj.assert_called_once()

    @patch("local_deep_research.journal_quality.downloader._fetch_institutions")
    @patch(
        "local_deep_research.journal_quality.downloader"
        "._fetch_jabref_abbreviations"
    )
    @patch("local_deep_research.journal_quality.downloader._fetch_predatory")
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_openalex_sources"
    )
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_doaj_journals"
    )
    def test_openalex_failure(
        self,
        mock_doaj,
        mock_openalex,
        mock_pred,
        mock_jabref,
        mock_inst,
        tmp_data_dir,
    ):
        """If the required OpenAlex source fails, the whole download
        returns failure — even though the other four sources now run
        in parallel and may succeed.
        """
        mock_openalex.side_effect = Exception("Network error")
        # Keep optional sources cheap so the parallel pool finishes
        # quickly; we're only exercising the required-source-failure
        # path.
        mock_doaj.return_value = 0
        mock_pred.return_value = 0
        mock_jabref.return_value = 0
        mock_inst.return_value = 0
        success, msg = download_journal_data(force=True)
        assert success is False
        assert "Failed" in msg

    @patch("local_deep_research.journal_quality.downloader._fetch_institutions")
    @patch(
        "local_deep_research.journal_quality.downloader"
        "._fetch_jabref_abbreviations"
    )
    @patch("local_deep_research.journal_quality.downloader._fetch_predatory")
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_openalex_sources"
    )
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_doaj_journals"
    )
    @patch("local_deep_research.journal_quality.db.build_db")
    def test_build_db_failure_returns_false(
        self,
        mock_build_db,
        mock_doaj,
        mock_openalex,
        mock_pred,
        mock_jabref,
        mock_inst,
        tmp_data_dir,
    ):
        """If fetches succeed but build_db raises, caller must see success=False.

        Previously this case silently returned (True, 'Fetched ...') while the
        DB was never built, leading the dashboard to show a green toast with
        no DB on disk.
        """
        mock_openalex.return_value = 100
        mock_doaj.return_value = 50
        mock_pred.return_value = 0
        mock_jabref.return_value = 0
        mock_inst.return_value = 0
        mock_build_db.side_effect = RuntimeError("synthetic build failure")
        _write_openalex(tmp_data_dir)

        success, msg = download_journal_data(force=True)
        assert success is False
        assert "DB build failed" in msg
        # Exception message is not surfaced to callers (info-disclosure hardening
        # in commit da803376d); only the exception class name is included.
        assert "RuntimeError" in msg

    @patch("local_deep_research.journal_quality.downloader._fetch_institutions")
    @patch(
        "local_deep_research.journal_quality.downloader"
        "._fetch_jabref_abbreviations"
    )
    @patch("local_deep_research.journal_quality.downloader._fetch_predatory")
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_openalex_sources"
    )
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_doaj_journals"
    )
    @patch("local_deep_research.journal_quality.db.build_db")
    def test_build_db_schema_drift_error_surfaced(
        self,
        mock_build_db,
        mock_doaj,
        mock_openalex,
        mock_pred,
        mock_jabref,
        mock_inst,
        tmp_data_dir,
    ):
        """SchemaDriftError messages are developer-authored and safe to
        surface verbatim, so operators can see *which* upstream field
        drifted. Only the generic-exception path gets the class-name
        scrubbing; drift-errors pass through.
        """
        from local_deep_research.journal_quality.data_sources.openalex import (
            SchemaDriftError,
        )

        mock_openalex.return_value = 100
        mock_doaj.return_value = 50
        mock_pred.return_value = 0
        mock_jabref.return_value = 0
        mock_inst.return_value = 0
        drift_msg = (
            "OpenAlex snapshot appears to have renamed a required field: "
            "h_index present in journal sample=False"
        )
        mock_build_db.side_effect = SchemaDriftError(drift_msg)
        _write_openalex(tmp_data_dir)

        success, msg = download_journal_data(force=True)
        assert success is False
        assert "DB build failed" in msg
        # Full drift message is surfaced — operators need to see which
        # field drifted to act on it.
        assert "h_index present in journal sample=False" in msg
        # The class name path is only used for non-SchemaDriftError.
        assert "SchemaDriftError" not in msg


class TestDownloadStateCountsLifecycle:
    """Verify `_download_state["counts"]` is populated only on success and
    cleared on every other return path, so the `/api/journal-data/download`
    route (and any other reader of `get_download_state()`) cannot observe
    stale counts from a prior run.
    """

    def _get_counts(self):
        from local_deep_research.journal_quality.downloader import (
            get_download_state,
        )

        return get_download_state().get("counts")

    def test_counts_none_on_already_up_to_date(self, tmp_data_dir):
        _write_openalex(tmp_data_dir)
        (tmp_data_dir / "doaj_journals.json").write_text("{}")
        (tmp_data_dir / "version.json").write_text(
            json.dumps({"version": JOURNAL_DATA_VERSION})
        )
        success, _ = download_journal_data()
        assert success is True
        assert self._get_counts() is None

    def test_counts_none_on_disk_space_failure(self, tmp_data_dir):
        class _LowDisk:
            total = 10 * 1024**3
            used = total - 500 * 1024**2
            free = 500 * 1024**2

        with patch("shutil.disk_usage", return_value=_LowDisk()):
            success, _ = download_journal_data(force=True)
        assert success is False
        assert self._get_counts() is None

    def test_counts_none_on_concurrent_download_block(self, tmp_data_dir):
        import os

        (tmp_data_dir / ".downloading").write_text(str(os.getpid()))
        success, _ = download_journal_data()
        assert success is False
        assert self._get_counts() is None

    @patch("local_deep_research.journal_quality.downloader._fetch_institutions")
    @patch(
        "local_deep_research.journal_quality.downloader"
        "._fetch_jabref_abbreviations"
    )
    @patch("local_deep_research.journal_quality.downloader._fetch_predatory")
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_openalex_sources"
    )
    @patch(
        "local_deep_research.journal_quality.downloader._fetch_doaj_journals"
    )
    @patch("local_deep_research.journal_quality.db.build_db")
    def test_counts_populated_on_success_then_cleared_on_up_to_date(
        self,
        mock_build_db,
        mock_doaj,
        mock_openalex,
        mock_pred,
        mock_jabref,
        mock_inst,
        tmp_data_dir,
    ):
        """The key anti-stale-leak scenario: a successful download
        populates counts, then a subsequent "already up to date" call
        must invalidate them so the route cannot render last-run's
        numbers as if they came from the new (non-existent) fetch.
        """
        mock_openalex.return_value = 100
        mock_doaj.return_value = 50
        mock_pred.return_value = 0
        mock_jabref.return_value = 0
        mock_inst.return_value = 0
        mock_build_db.return_value = None
        _write_openalex(tmp_data_dir)

        success, _ = download_journal_data(force=True)
        assert success is True
        counts = self._get_counts()
        assert counts is not None
        assert counts["openalex"] == 100
        assert counts["doaj"] == 50

        # Stamp the version.json that the real success path would have
        # written, so the next call takes the "already up to date" branch.
        (tmp_data_dir / "doaj_journals.json").write_text("{}")
        (tmp_data_dir / "version.json").write_text(
            json.dumps({"version": JOURNAL_DATA_VERSION})
        )
        success, _ = download_journal_data()
        assert success is True
        assert self._get_counts() is None


class TestEnsureJournalData:
    def test_files_in_user_dir(self, tmp_data_dir):
        _write_openalex(tmp_data_dir)
        data_dir, available = ensure_journal_data(auto_download=False)
        assert available is True
        assert data_dir == tmp_data_dir

    def test_missing_no_download(self, tmp_data_dir):
        # The user data dir (tmp_data_dir) has no files.
        # Also need to prevent the package dir fallback from finding
        # bundled files in a dev install.
        # Verify status reports unavailable when user dir is empty
        status = get_journal_data_status()
        assert status["available"] is False


class TestEnsureJournalDataCache:
    """Verify the thundering-herd guard on ensure_journal_data.

    When 30+ filter workers call this concurrently during a search and
    the data files aren't yet on disk, only one should reach the real
    ``download_journal_data`` call; the rest should hit the 30-second
    cooldown cache and return the previous result without adding log
    spam or racing for the sentinel.
    """

    def test_second_call_within_ttl_uses_cache(self, tmp_data_dir):
        import local_deep_research.journal_quality.downloader as dl

        # Clear any stale module-level cache from previous tests.
        dl._ensure_cache = None

        with patch.object(dl, "download_journal_data") as mock_dl:
            mock_dl.return_value = (False, "network error")
            # First call: reaches download_journal_data, gets the
            # negative result, caches it.
            first = ensure_journal_data(auto_download=True)
            assert first == (None, False)
            assert mock_dl.call_count == 1

            # Rapid second call (well within 30 s TTL) must NOT
            # re-invoke download_journal_data — that's the whole
            # point of the herd guard.
            second = ensure_journal_data(auto_download=True)
            assert second == (None, False)
            assert mock_dl.call_count == 1
