"""Unit tests for the pure-function scoring helpers in
``journal_quality.scoring``.

These cover the rules baked into ``derive_quality_score`` and
``institution_score_from_h_index``. They live in their own file so the
scoring rubric is documented and locked in independently of the DB build
pipeline.
"""

import pytest

from local_deep_research.journal_quality.scoring import (
    derive_quality_score,
    institution_score_from_h_index,
    normalize_name,
)


class TestDeriveQualityScore:
    """The 1–10 quality score rubric."""

    def test_predatory_not_in_doaj_returns_one(self):
        # Auto-remove threshold — caller treats score 1 as "drop this".
        assert derive_quality_score(is_predatory=True, is_in_doaj=False) == 1

    def test_predatory_in_doaj_falls_through(self):
        # DOAJ membership rescues the journal — predatory flag is ignored
        # because the whitelist override at build time clears it.
        # `derive_quality_score` itself only applies the auto-remove rule
        # when NOT in DOAJ; otherwise it uses the normal scoring.
        assert derive_quality_score(is_predatory=True, is_in_doaj=True) == 5

    @pytest.mark.parametrize(
        "h_index,expected",
        [
            (151, 10),  # Elite (Nature/Science/NEJM)
            (76, 8),
            (41, 7),
            (21, 6),
            (11, 5),
            (1, 4),
            (10, 4),  # 10 is NOT > 10 → falls into the "default" branch
        ],
    )
    def test_h_index_thresholds(self, h_index, expected):
        assert derive_quality_score(h_index=h_index) == expected

    def test_doaj_listed_returns_five(self):
        # There used to be a DOAJ Seal tier (score 8) above this; DOAJ
        # retired the Seal in April 2025 so listing is the only DOAJ
        # signal now.
        assert derive_quality_score(is_in_doaj=True) == 5

    def test_h_index_takes_precedence_over_doaj(self):
        # If we have an h-index, it wins over DOAJ — h-index is the
        # canonical bibliometric and DOAJ is the OA fallback.
        assert (
            derive_quality_score(h_index=80, is_in_doaj=True)
            == 8  # h_index branch returns 8 (> 75)
        )

    def test_conference_default(self):
        assert derive_quality_score(source_type="conference") == 5

    def test_repository_capped_at_acceptable(self):
        """Preprint repositories (arXiv, bioRxiv, ...) are not peer-
        reviewed — their h-index reflects aggregate paper citations,
        not venue rigor. arXiv has h=674 + Q1 from OpenAlex but must
        NOT rank as ELITE/Q1 in our scoring.
        """
        # arXiv's real OpenAlex stats — should NOT produce 10.
        assert (
            derive_quality_score(
                h_index=674,
                quartile="Q1",
                source_type="repository",
            )
            == 5
        )
        # Predatory check still fires first for repositories flagged
        # predatory (unlikely but defensive).
        assert (
            derive_quality_score(
                is_predatory=True,
                source_type="repository",
            )
            == 1
        )

    def test_returns_none_when_no_signal(self):
        # No h-index, not in DOAJ, not a conference → caller should
        # treat as "unknown" and fall through to the next tier.
        assert derive_quality_score() is None
        assert derive_quality_score(h_index=0) is None

    def test_h_index_zero_treated_as_no_signal(self):
        # h-index=0 means "newly indexed, not meaningful" — should NOT
        # trigger the h-index branch.
        assert (
            derive_quality_score(h_index=0, is_in_doaj=True)
            == 5  # falls through to DOAJ-listed scoring
        )


class TestInstitutionScoreFromHIndex:
    """Single source of truth for institution h-index → score mapping.

    `db.score_from_affiliations` delegates here, so this also covers the
    consolidation of the previously-duplicated scoring path in db.py.
    """

    @pytest.mark.parametrize(
        "h_index,expected",
        [
            (None, None),
            (0, 4),  # below threshold → default
            (49, 4),
            (50, 4),  # NOT > 50 → default
            (51, 5),
            (100, 5),  # the historically duplicated branch — confirmed merged
            (250, 5),  # NOT > 250 → still 5
            (251, 6),  # top tier
            (1000, 6),  # cap at 6 — institution alone never beats venue
        ],
    )
    def test_thresholds(self, h_index, expected):
        assert institution_score_from_h_index(h_index) == expected


class TestNormalizeName:
    """Helper used by the build pipeline + the runtime accessor."""

    def test_lowercases(self):
        assert normalize_name("Nature") == "nature"

    def test_strips_whitespace(self):
        assert normalize_name("  Nature  ") == "nature"

    def test_nfkc_normalization(self):
        # Half-width and full-width characters should compare equal.
        assert normalize_name("ＮＡＴＵＲＥ") == normalize_name("NATURE")


class TestDeriveQualityScoreQuartile:
    """Tests for the new ``quartile`` parameter on ``derive_quality_score``.

    Quartile is the canonical librarian-facing journal quality signal.
    These tests document the rubric: Q1 → strong (or elite if h_index
    also tops the elite threshold), Q2 → very_good, Q3 → good, Q4 →
    acceptable. Predatory always wins. Quartile takes precedence over
    DOAJ but defers to the predatory check.
    """

    @pytest.mark.parametrize(
        "quartile,expected",
        [
            ("Q1", 8),  # JOURNAL_QUALITY_STRONG
            ("Q2", 7),  # JOURNAL_QUALITY_VERY_GOOD
            ("Q3", 6),  # JOURNAL_QUALITY_GOOD
            ("Q4", 5),  # JOURNAL_QUALITY_ACCEPTABLE
        ],
    )
    def test_quartile_maps_to_score(self, quartile, expected):
        assert derive_quality_score(quartile=quartile) == expected

    def test_q1_with_elite_h_index_returns_elite(self):
        # Q1 + h-index above the elite threshold (150) should bump to 10
        # so Nature stays distinguishable from a typical Q1.
        assert derive_quality_score(quartile="Q1", h_index=200) == 10

    def test_q1_with_low_h_index_stays_strong(self):
        # h-index below the elite threshold doesn't downgrade Q1.
        assert derive_quality_score(quartile="Q1", h_index=50) == 8

    @pytest.mark.parametrize("quartile", ["q1", " Q1 ", "Q1\n", "q2"])
    def test_quartile_is_normalized(self, quartile):
        # Lowercase / trailing whitespace / odd casing should still match.
        # We're verifying `derive_quality_score` does the .upper().strip()
        # itself rather than relying on the caller.
        result = derive_quality_score(quartile=quartile)
        assert result is not None
        assert 5 <= result <= 10  # All Q* should map to a valid score

    def test_unknown_quartile_falls_through_to_h_index(self):
        # Garbage quartile input should NOT silently use the h-index;
        # it should fall through to the next branch (h_index→default).
        # Tests document the safety: a typo in quartile data doesn't
        # corrupt the score.
        assert (
            derive_quality_score(quartile="ZZ", h_index=200) == 10
        )  # Falls through to h_index branch
        assert (
            derive_quality_score(quartile="ZZ") is None
        )  # No fallback signal at all

    def test_quartile_takes_precedence_over_doaj(self):
        # When both quartile and DOAJ are present, quartile wins (it's
        # a stronger signal than just being in DOAJ).
        # Q4 (5) is the same value as DOAJ_QUALITY_LISTED (5), so use
        # Q3 (6) which is distinct from the DOAJ-listed score.
        assert derive_quality_score(quartile="Q3", is_in_doaj=True) == 6

    def test_predatory_overrides_quartile(self):
        # Even a Q1 journal gets dropped if predatory and not in DOAJ.
        assert (
            derive_quality_score(
                quartile="Q1", is_predatory=True, is_in_doaj=False
            )
            == 1
        )
