#!/usr/bin/env python3
"""#105 v3.7.3 contamination_signals resolver tests.

Tests the two pure-function signals defined in v3.7.3 spec §3.2 that
the migration tool uses to backfill pre-v3.7.3 literature_corpus entries.

Signal 1 (preprint_post_llm_inflection): year >= 2024 AND venue in 10-server
closed list (per spec §3.2 Vector 1 + schema description).

Signal 2 (semantic_scholar_unmatched): SS API lookup with manual exemption
+ API degradation handling (per spec §3.2 Vector 2 + R-L3-2-B).

Design: docs/design/2026-05-15-issue-105-contamination-signals-backfill-design.md
"""
from __future__ import annotations

import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock

REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT / "scripts") not in sys.path:
    sys.path.insert(0, str(REPO_ROOT / "scripts"))

import contamination_signals as cs  # noqa: E402


# ============================================================================
# Signal 1 — preprint_post_llm_inflection
# ============================================================================
class PreprintSignalTest(unittest.TestCase):
    """Signal 1 per v3.7.3 spec §3.2 Vector 1: True iff year >= 2024 AND
    venue ∈ 10-server closed list."""

    def test_arxiv_2024_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "arXiv"}))

    def test_arxiv_2023_returns_false(self) -> None:
        # Year boundary: 2023 is below threshold
        self.assertFalse(cs.compute_preprint_signal({"year": 2023, "venue": "arXiv"}))

    def test_arxiv_2025_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2025, "venue": "arXiv"}))

    def test_biorxiv_2024_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "bioRxiv"}))

    def test_medrxiv_2024_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "medRxiv"}))

    def test_ssrn_2024_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "SSRN"}))

    def test_research_square_2024_returns_true(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal({"year": 2024, "venue": "Research Square"})
        )

    def test_preprints_org_2024_returns_true(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal({"year": 2024, "venue": "Preprints.org"})
        )

    def test_chemrxiv_2024_returns_true(self) -> None:
        """v3.7.3 codex F6 / gemini review F6 expansion."""
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "ChemRxiv"}))

    def test_eartharxiv_2024_returns_true(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal({"year": 2024, "venue": "EarthArXiv"})
        )

    def test_osf_preprints_2024_returns_true(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal({"year": 2024, "venue": "OSF Preprints"})
        )

    def test_techrxiv_2024_returns_true(self) -> None:
        self.assertTrue(cs.compute_preprint_signal({"year": 2024, "venue": "TechRxiv"}))

    def test_non_preprint_venue_2024_returns_false(self) -> None:
        # Year passes threshold but venue is not in the closed list
        self.assertFalse(
            cs.compute_preprint_signal({"year": 2024, "venue": "Nature"})
        )

    def test_missing_venue_returns_false(self) -> None:
        # Defensive: entries lacking venue cannot satisfy the AND
        self.assertFalse(cs.compute_preprint_signal({"year": 2024}))

    def test_missing_year_returns_false(self) -> None:
        # Defensive: entries lacking year cannot satisfy the AND
        self.assertFalse(cs.compute_preprint_signal({"venue": "arXiv"}))

    def test_source_pointer_infers_arxiv(self) -> None:
        """Codex R2-1 closure: when venue is missing, source_pointer
        URLs to preprint servers must satisfy Signal 1."""
        self.assertTrue(
            cs.compute_preprint_signal(
                {"year": 2024, "source_pointer": "https://arxiv.org/abs/2401.12345"}
            )
        )

    def test_source_pointer_infers_biorxiv(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal(
                {"year": 2025, "source_pointer": "https://www.biorxiv.org/content/10.1101/2025.01.01.000000v1"}
            )
        )

    def test_source_pointer_infers_osf_preprints(self) -> None:
        self.assertTrue(
            cs.compute_preprint_signal(
                {"year": 2024, "source_pointer": "https://osf.io/preprints/socarxiv/xyz123"}
            )
        )

    def test_source_pointer_non_preprint_returns_false(self) -> None:
        """An arbitrary URL with no preprint-server hint stays False."""
        self.assertFalse(
            cs.compute_preprint_signal(
                {"year": 2024, "source_pointer": "https://nature.com/articles/x"}
            )
        )

    def test_source_pointer_ignored_when_venue_present(self) -> None:
        """Explicit `venue` field takes precedence — Nature 2024 with an
        arxiv source_pointer is still venue=Nature (Signal 1 false)."""
        self.assertFalse(
            cs.compute_preprint_signal(
                {
                    "year": 2024,
                    "venue": "Nature",
                    "source_pointer": "https://arxiv.org/abs/2401.x",
                }
            )
        )

    def test_source_pointer_pre_2024_returns_false(self) -> None:
        """Year < 2024 short-circuits before pointer inference fires."""
        self.assertFalse(
            cs.compute_preprint_signal(
                {"year": 2023, "source_pointer": "https://arxiv.org/abs/2301.x"}
            )
        )


# ============================================================================
# Signal 2 — semantic_scholar_unmatched
# ============================================================================
class SemanticScholarSignalTest(unittest.TestCase):
    """Signal 2 per v3.7.3 spec §3.2 Vector 2: SS API lookup. Returns None
    on (a) manual exemption (b) API degradation; True/False otherwise.

    The SS client is dependency-injected — tests use a MagicMock that
    returns whatever match shape the test specifies."""

    def _entry(self, **overrides):
        base = {
            "citation_key": "chen2024ai",
            "title": "Test paper",
            "authors": [{"family": "Chen", "given": "A"}],
            "year": 2024,
            "doi": "10.1234/xyz",
            "obtained_via": "folder-scan",
        }
        return base | overrides

    def test_manual_entry_returns_none(self) -> None:
        """v3.7.3 spec §3.2 + schema allOf rule #4: obtained_via='manual'
        triggers SKIP — the field MUST be omitted from the emission."""
        client = MagicMock()
        result = cs.compute_ss_unmatched_signal(
            self._entry(obtained_via="manual"), client
        )
        self.assertIsNone(result)
        # The exemption is a skip, not an API call
        client.lookup.assert_not_called()

    def test_ss_match_returns_false(self) -> None:
        client = MagicMock()
        client.lookup.return_value = {"matched": True, "paperId": "abc123"}
        self.assertFalse(cs.compute_ss_unmatched_signal(self._entry(), client))

    def test_ss_no_match_returns_true(self) -> None:
        client = MagicMock()
        client.lookup.return_value = {"matched": False, "paperId": None}
        self.assertTrue(cs.compute_ss_unmatched_signal(self._entry(), client))

    def test_ss_api_degradation_returns_none(self) -> None:
        """Per spec §3.2: API unreachable (network failure / rate-limit-
        exhausted / 5xx) returns None. Absence ≠ negative confirmation;
        setting False would imply 'checked and found'."""
        client = MagicMock()
        client.lookup.side_effect = cs.SemanticScholarUnavailable("rate limit exhausted")
        self.assertIsNone(cs.compute_ss_unmatched_signal(self._entry(), client))

    def test_ss_network_failure_returns_none(self) -> None:
        client = MagicMock()
        client.lookup.side_effect = cs.SemanticScholarUnavailable("connection refused")
        self.assertIsNone(cs.compute_ss_unmatched_signal(self._entry(), client))

    def test_unexpected_exception_propagates(self) -> None:
        """Distinguish 'API degradation' (a known-handled signal) from
        unknown bugs. ValueError isn't SS-API-degradation; let it surface
        so the migration tool can fail loudly rather than silently OMIT."""
        client = MagicMock()
        client.lookup.side_effect = ValueError("bug in client")
        with self.assertRaises(ValueError):
            cs.compute_ss_unmatched_signal(self._entry(), client)


# ============================================================================
# Emission rules — building the contamination_signals object
# ============================================================================
class EmissionRulesTest(unittest.TestCase):
    """Per spec §3.2 emission rules: emit object with both fields false
    when computed-and-clean; emit partial object when one signal can't
    be computed; omit semantic_scholar_unmatched on manual exemption."""

    def _entry(self, **overrides):
        base = {
            "year": 2024,
            "venue": "arXiv",
            "obtained_via": "folder-scan",
        }
        return base | overrides

    def test_both_signals_computed_emits_full_object(self) -> None:
        client = MagicMock()
        client.lookup.return_value = {"matched": False}
        result = cs.build_signals_object(self._entry(), client)
        self.assertEqual(
            result,
            {"preprint_post_llm_inflection": True, "semantic_scholar_unmatched": True},
        )

    def test_clean_entry_emits_both_false(self) -> None:
        client = MagicMock()
        client.lookup.return_value = {"matched": True}
        result = cs.build_signals_object(self._entry(year=2023, venue="Nature"), client)
        self.assertEqual(
            result,
            {"preprint_post_llm_inflection": False, "semantic_scholar_unmatched": False},
        )

    def test_manual_entry_omits_unmatched_field(self) -> None:
        client = MagicMock()
        result = cs.build_signals_object(
            self._entry(obtained_via="manual"), client
        )
        self.assertEqual(result, {"preprint_post_llm_inflection": True})
        self.assertNotIn("semantic_scholar_unmatched", result)

    def test_api_degradation_omits_unmatched_field(self) -> None:
        """Partial computation: Signal 1 still emits (trivial from
        year+venue), Signal 2 omitted (API down). Per spec §3.2."""
        client = MagicMock()
        client.lookup.side_effect = cs.SemanticScholarUnavailable("5xx")
        result = cs.build_signals_object(self._entry(), client)
        self.assertEqual(result, {"preprint_post_llm_inflection": True})
        self.assertNotIn("semantic_scholar_unmatched", result)


class ResetClientOutageLatchHelperTest(unittest.TestCase):
    """#115 R5-3 helper: production clients implementing the outage-latch
    pattern expose `reset_outage_latch()`; minimal mocks may not. The
    helper invokes when present, no-ops when absent. Avoids
    AttributeError for callers that swap in test mocks."""

    def test_helper_invokes_reset_when_present(self) -> None:
        client = MagicMock()
        cs.reset_client_outage_latch(client)
        client.reset_outage_latch.assert_called_once()

    def test_helper_no_ops_when_method_absent(self) -> None:
        client = MagicMock(spec=["lookup"])  # only the Protocol's lookup
        # Should NOT raise AttributeError
        cs.reset_client_outage_latch(client)




class BuildSignalsWithOmissionsTest(unittest.TestCase):
    """#511 Part A: the omissions half distinguishes degraded from derivable."""

    def _entry(self, **over):
        base = {
            "citation_key": "chen2024",
            "title": "AI in education",
            "authors": [{"family": "Chen"}],
            "year": 2024,
            "source_pointer": "file:///x.pdf",
            "obtained_via": "folder-scan",
        }
        base.update(over)
        return base

    def _client(self, *, matched=False, degraded=False):
        client = MagicMock()
        if degraded:
            client.lookup.side_effect = cs.SemanticScholarUnavailable("down")
        else:
            client.lookup.return_value = {"matched": matched, "paperId": "x"}
        return client

    def test_degraded_lookup_yields_omission(self):
        signals, omissions = cs.build_signals_with_omissions(
            self._entry(), self._client(degraded=True))
        self.assertNotIn("semantic_scholar_unmatched", signals)
        self.assertEqual(
            omissions, {"semantic_scholar_unmatched": "api_degraded"})

    def test_computed_lookup_yields_no_omission(self):
        signals, omissions = cs.build_signals_with_omissions(
            self._entry(), self._client(matched=True))
        self.assertIs(signals["semantic_scholar_unmatched"], False)
        self.assertEqual(omissions, {})

    def test_manual_entry_yields_no_omission(self):
        """Manual exemption is derivable from obtained_via — never recorded."""
        signals, omissions = cs.build_signals_with_omissions(
            self._entry(obtained_via="manual"), self._client(degraded=True))
        self.assertNotIn("semantic_scholar_unmatched", signals)
        self.assertEqual(omissions, {})

    def test_no_arxiv_id_skips_arxiv_row(self):
        """The no-arxiv-id skip is derivable from the entry — never recorded,
        even when an arxiv client is supplied."""
        ax = MagicMock()
        signals, omissions = cs.build_signals_with_omissions(
            self._entry(), self._client(), ax)
        self.assertNotIn("arxiv_unmatched", signals)
        self.assertEqual(omissions, {})
        ax.arxiv_id_lookup.assert_not_called()

    def test_degraded_arxiv_yields_omission(self):
        ax = MagicMock()
        ax.arxiv_id_lookup.side_effect = cs.ArxivUnavailable("down")
        ax.title_search.side_effect = cs.ArxivUnavailable("down")
        signals, omissions = cs.build_signals_with_omissions(
            self._entry(arxiv_id="2401.00001"), self._client(), ax)
        self.assertEqual(omissions.get("arxiv_unmatched"), "api_degraded")

    def test_build_signals_object_stays_equivalent(self):
        """The legacy single-return API is the signals half, byte-equal."""
        for client in (self._client(), self._client(degraded=True)):
            entry = self._entry()
            self.assertEqual(
                cs.build_signals_object(entry, client),
                cs.build_signals_with_omissions(entry, client)[0])


class OmissionHelpersTest(unittest.TestCase):
    """#511 Part A: record/clear are idempotent and keep the schema shape
    (no empty omissions object)."""

    def test_record_then_rerecord_is_idempotent(self):
        entry = {}
        self.assertTrue(cs.record_signal_omission(entry, "openalex_unmatched"))
        self.assertFalse(cs.record_signal_omission(entry, "openalex_unmatched"))
        self.assertEqual(
            entry["contamination_signal_omissions"],
            {"openalex_unmatched": "api_degraded"})

    def test_clear_removes_key_and_empty_object(self):
        entry = {"contamination_signal_omissions": {
            "openalex_unmatched": "api_degraded"}}
        self.assertTrue(cs.clear_signal_omission(entry, "openalex_unmatched"))
        self.assertNotIn("contamination_signal_omissions", entry)

    def test_clear_keeps_other_keys(self):
        entry = {"contamination_signal_omissions": {
            "openalex_unmatched": "api_degraded",
            "crossref_unmatched": "api_degraded"}}
        self.assertTrue(cs.clear_signal_omission(entry, "openalex_unmatched"))
        self.assertEqual(
            entry["contamination_signal_omissions"],
            {"crossref_unmatched": "api_degraded"})

    def test_clear_on_absent_is_noop(self):
        entry = {}
        self.assertFalse(cs.clear_signal_omission(entry, "openalex_unmatched"))
        self.assertEqual(entry, {})


if __name__ == "__main__":
    unittest.main()


# ============================================================================
# v3.9.0 — resolve_openalex_unmatched
# ============================================================================


class ResolveOpenAlexUnmatchedTest(unittest.TestCase):
    """Tests for resolve_openalex_unmatched per spec v3.9.0 §3.4."""

    def test_match(self):
        """OpenAlex hit via DOI cross-check → False."""
        from contamination_signals import resolve_openalex_unmatched

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.return_value = {"title": "X"}

        entry = {
            "title": "X",
            "doi": "10.5555/abc",
            "obtained_via": "zotero-bbt-export",
        }
        result = resolve_openalex_unmatched(entry, mock_client)
        self.assertIs(result, False)

    def test_no_match(self):
        """OpenAlex no DOI hit + no title hit → True."""
        from contamination_signals import resolve_openalex_unmatched

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.return_value = None
        mock_client.title_search.return_value = None

        entry = {
            "title": "Nonexistent Paper",
            "doi": "10.5555/fake",
            "obtained_via": "folder-scan",
        }
        result = resolve_openalex_unmatched(entry, mock_client)
        self.assertIs(result, True)

    def test_manual_exempt(self):
        """obtained_via='manual' → return None, no client calls."""
        from contamination_signals import resolve_openalex_unmatched

        mock_client = MagicMock()
        entry = {"title": "X", "obtained_via": "manual"}
        result = resolve_openalex_unmatched(entry, mock_client)
        self.assertIsNone(result)
        mock_client.doi_lookup_with_title_check.assert_not_called()
        mock_client.title_search.assert_not_called()

    def test_doi_absent_falls_through_to_title(self):
        """DOI absent: title search alone, NO DOI lookup attempted."""
        from contamination_signals import resolve_openalex_unmatched

        mock_client = MagicMock()
        mock_client.title_search.return_value = {"title": "X"}

        entry = {"title": "X", "obtained_via": "zotero-bbt-export"}  # no doi
        result = resolve_openalex_unmatched(entry, mock_client)
        self.assertIs(result, False)
        mock_client.doi_lookup_with_title_check.assert_not_called()

    def test_api_down_raises(self):
        """API degraded → re-raise OpenAlexUnavailable for caller to omit field."""
        from contamination_signals import resolve_openalex_unmatched
        from openalex_client import OpenAlexUnavailable

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.side_effect = OpenAlexUnavailable("down")

        entry = {"title": "X", "doi": "10.5555/abc", "obtained_via": "folder-scan"}
        with self.assertRaises(OpenAlexUnavailable):
            resolve_openalex_unmatched(entry, mock_client)


# ============================================================================
# v3.9.0 — resolve_crossref_unmatched
# ============================================================================


class ResolveCrossrefUnmatchedTest(unittest.TestCase):
    """Tests for resolve_crossref_unmatched per spec v3.9.0 §3.5."""

    def test_match(self):
        """Crossref hit via DOI cross-check → False."""
        from contamination_signals import resolve_crossref_unmatched

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.return_value = {"title": ["X"]}

        entry = {
            "title": "X",
            "doi": "10.5555/abc",
            "obtained_via": "zotero-bbt-export",
        }
        result = resolve_crossref_unmatched(entry, mock_client)
        self.assertIs(result, False)

    def test_no_match(self):
        """Crossref no DOI hit + no title hit → True."""
        from contamination_signals import resolve_crossref_unmatched

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.return_value = None
        mock_client.title_search.return_value = None

        entry = {
            "title": "Nonexistent",
            "doi": "10.5555/fake",
            "obtained_via": "folder-scan",
        }
        result = resolve_crossref_unmatched(entry, mock_client)
        self.assertIs(result, True)

    def test_manual_exempt(self):
        """obtained_via='manual' → return None, no client calls."""
        from contamination_signals import resolve_crossref_unmatched

        mock_client = MagicMock()
        entry = {"title": "X", "obtained_via": "manual"}
        result = resolve_crossref_unmatched(entry, mock_client)
        self.assertIsNone(result)
        mock_client.doi_lookup_with_title_check.assert_not_called()
        mock_client.title_search.assert_not_called()

    def test_doi_absent_falls_through_to_title(self):
        """DOI absent: title search alone, NO DOI lookup attempted."""
        from contamination_signals import resolve_crossref_unmatched

        mock_client = MagicMock()
        mock_client.title_search.return_value = {"title": ["X"]}

        entry = {"title": "X", "obtained_via": "obsidian-vault"}  # no doi
        result = resolve_crossref_unmatched(entry, mock_client)
        self.assertIs(result, False)
        mock_client.doi_lookup_with_title_check.assert_not_called()

    def test_api_down_raises(self):
        """API degraded → re-raise CrossrefUnavailable for caller to omit field."""
        from contamination_signals import resolve_crossref_unmatched
        from crossref_client import CrossrefUnavailable

        mock_client = MagicMock()
        mock_client.doi_lookup_with_title_check.side_effect = CrossrefUnavailable("down")

        entry = {"title": "X", "doi": "10.5555/abc", "obtained_via": "folder-scan"}
        with self.assertRaises(CrossrefUnavailable):
            resolve_crossref_unmatched(entry, mock_client)


# ============================================================================
# v3.11 #182 Delta 1 — resolve_arxiv_unmatched
# ============================================================================


class ResolveArxivUnmatchedTest(unittest.TestCase):
    """Tests for resolve_arxiv_unmatched per spec v3.11 #182 Delta 1.

    Differs from the crossref/openalex resolvers: keyed by entry['arxiv_id']
    (not 'doi'), and the client exact-key method is arxiv_id_lookup, not
    doi_lookup_with_title_check."""

    def test_match(self):
        """arXiv hit via ID cross-check → False."""
        from contamination_signals import resolve_arxiv_unmatched

        mock_client = MagicMock()
        mock_client.arxiv_id_lookup.return_value = {"title": "X", "year": 2017}

        entry = {
            "title": "X",
            "arxiv_id": "1706.03762",
            "obtained_via": "zotero-bbt-export",
        }
        result = resolve_arxiv_unmatched(entry, mock_client)
        self.assertIs(result, False)

    def test_no_match(self):
        """arXiv no ID hit + no title hit → True."""
        from contamination_signals import resolve_arxiv_unmatched

        mock_client = MagicMock()
        mock_client.arxiv_id_lookup.return_value = None
        mock_client.title_search.return_value = None

        entry = {
            "title": "Nonexistent",
            "arxiv_id": "9999.99999",
            "obtained_via": "folder-scan",
        }
        result = resolve_arxiv_unmatched(entry, mock_client)
        self.assertIs(result, True)

    def test_manual_exempt(self):
        """obtained_via='manual' → return None, no client calls."""
        from contamination_signals import resolve_arxiv_unmatched

        mock_client = MagicMock()
        entry = {"title": "X", "arxiv_id": "1706.03762", "obtained_via": "manual"}
        result = resolve_arxiv_unmatched(entry, mock_client)
        self.assertIsNone(result)
        mock_client.arxiv_id_lookup.assert_not_called()
        mock_client.title_search.assert_not_called()

    def test_arxiv_id_absent_skips_resolver(self):
        """arXiv ID absent (#331 P2): the arXiv resolver is SKIPPED — return None
        (caller omits arxiv_unmatched) and run NO network query. A non-arXiv
        citation (e.g. a DOI-keyed journal article) must not be title-searched
        against arXiv: a title miss there is a coverage gap, not non-existence
        evidence, and would emit a false arxiv_unmatched=true (inflating the
        triangulation k on a clean citation). Spec §4 / the orchestrator k_max
        rule both state arxiv_unmatched is ABSENT on citations with no arXiv ID
        ('arXiv resolver skipped on a non-arXiv citation per Delta 1')."""
        from contamination_signals import resolve_arxiv_unmatched

        mock_client = MagicMock()

        entry = {"title": "X", "obtained_via": "obsidian-vault"}  # no arxiv_id
        result = resolve_arxiv_unmatched(entry, mock_client)
        self.assertIsNone(result)
        mock_client.arxiv_id_lookup.assert_not_called()
        mock_client.title_search.assert_not_called()

    def test_arxiv_id_miss_falls_through_to_title(self):
        """ID present but ID lookup misses → fall through to title search."""
        from contamination_signals import resolve_arxiv_unmatched

        mock_client = MagicMock()
        mock_client.arxiv_id_lookup.return_value = None
        mock_client.title_search.return_value = {"title": "X", "year": 2017}

        entry = {"title": "X", "arxiv_id": "1706.03762", "obtained_via": "folder-scan"}
        result = resolve_arxiv_unmatched(entry, mock_client)
        self.assertIs(result, False)
        mock_client.title_search.assert_called_once()

    def test_api_down_raises(self):
        """API degraded → re-raise ArxivUnavailable for caller to omit field."""
        from contamination_signals import resolve_arxiv_unmatched
        from arxiv_client import ArxivUnavailable

        mock_client = MagicMock()
        mock_client.arxiv_id_lookup.side_effect = ArxivUnavailable("down")

        entry = {"title": "X", "arxiv_id": "1706.03762", "obtained_via": "folder-scan"}
        with self.assertRaises(ArxivUnavailable):
            resolve_arxiv_unmatched(entry, mock_client)


# ============================================================================
# v3.11 #182 Delta 1 — build_signals_object arxiv extension
# ============================================================================


class BuildSignalsArxivExtensionTest(unittest.TestCase):
    """build_signals_object grows an optional arxiv_client keyword arg; when
    omitted, behavior is byte-equivalent to the v3.7.3 caller (no arxiv field).
    Per spec §2 Delta 1 'signal computation only' scope."""

    def _entry(self, **overrides):
        base = {"year": 2024, "venue": "arXiv", "obtained_via": "folder-scan"}
        return base | overrides

    def test_no_arxiv_client_omits_arxiv_field(self) -> None:
        """Byte-equivalent v3.7.3 path: no arxiv_client → no arxiv_unmatched."""
        ss = MagicMock()
        ss.lookup.return_value = {"matched": True}
        result = cs.build_signals_object(self._entry(), ss)
        self.assertNotIn("arxiv_unmatched", result)

    def test_arxiv_client_emits_arxiv_field(self) -> None:
        """With arxiv_client, a non-manual entry emits arxiv_unmatched."""
        ss = MagicMock()
        ss.lookup.return_value = {"matched": True}
        ax = MagicMock()
        ax.arxiv_id_lookup.return_value = None
        ax.title_search.return_value = None
        result = cs.build_signals_object(
            self._entry(arxiv_id="9999.99999"), ss, arxiv_client=ax
        )
        self.assertIs(result["arxiv_unmatched"], True)

    def test_no_arxiv_id_omits_arxiv_field_even_with_client(self) -> None:
        """#331: a non-arXiv citation (arxiv_client present but entry has no
        arxiv_id) omits arxiv_unmatched and runs NO arXiv query. Without the
        skip, the resolver would title-search arXiv and emit a false
        arxiv_unmatched=true, inflating triangulation k on a clean citation."""
        ss = MagicMock()
        ss.lookup.return_value = {"matched": True}
        ax = MagicMock()
        result = cs.build_signals_object(
            self._entry(), ss, arxiv_client=ax  # _entry() carries no arxiv_id
        )
        self.assertNotIn("arxiv_unmatched", result)
        ax.arxiv_id_lookup.assert_not_called()
        ax.title_search.assert_not_called()

    def test_manual_entry_omits_arxiv_field_even_with_client(self) -> None:
        """Manual entry: arxiv_unmatched omitted (not-rule), like the ss field."""
        ss = MagicMock()
        ax = MagicMock()
        result = cs.build_signals_object(
            self._entry(obtained_via="manual", arxiv_id="1706.03762"), ss,
            arxiv_client=ax
        )
        self.assertNotIn("arxiv_unmatched", result)
        ax.arxiv_id_lookup.assert_not_called()

    def test_arxiv_api_down_omits_arxiv_field(self) -> None:
        """arXiv API degradation → omit arxiv_unmatched (absent != false)."""
        ss = MagicMock()
        ss.lookup.return_value = {"matched": True}
        ax = MagicMock()
        ax.arxiv_id_lookup.side_effect = cs.ArxivUnavailable("5xx")
        result = cs.build_signals_object(
            self._entry(arxiv_id="1706.03762"), ss, arxiv_client=ax
        )
        self.assertNotIn("arxiv_unmatched", result)


# ============================================================================
# v3.11 #182 Delta 2 — resolver cache integration (wrapper layer)
# ============================================================================


class _FakeCache:
    """Minimal in-memory stand-in for VerificationCache. Records get/put calls
    so tests can assert hit/skip behavior without SQLite."""

    def __init__(self, seed=None):
        self._store = dict(seed or {})
        self.get_calls = []
        self.put_calls = []

    def get(self, citation_key, resolver_name, query_form):
        self.get_calls.append((citation_key, resolver_name, query_form))
        return self._store.get((citation_key, resolver_name, query_form))

    def put(self, citation_key, resolver_name, query_form, response):
        self.put_calls.append((citation_key, resolver_name, query_form, response))
        self._store[(citation_key, resolver_name, query_form)] = response


class ResolveCrossrefCacheTest(unittest.TestCase):
    """Cache integration is byte-equivalent when cache=None, consults the cache
    before the network on hit, and populates on miss. Per spec §2 Delta 2."""

    def _entry(self, **overrides):
        base = {
            "citation_key": "vaswani2017",
            "title": "Attention Is All You Need",
            "doi": "10.5555/abc",
            "obtained_via": "folder-scan",
        }
        return base | overrides

    def test_cache_none_is_byte_equivalent(self):
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = {"title": ["X"]}
        result = resolve_crossref_unmatched(self._entry(), client, cache=None)
        self.assertIs(result, False)
        client.doi_lookup_with_title_check.assert_called_once()

    def test_cache_hit_skips_network(self):
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        qf = "doi:10.5555/abc|title:Attention Is All You Need"
        # A same-decision-version row IS reused (#431 §0.12.3b).
        cache = _FakeCache(seed={
            ("vaswani2017", "crossref", qf): {
                "matched": True, "matched_by": "doi",
                "decision_version": cs.RESOLVER_DECISION_VERSION,
            }
        })
        result = resolve_crossref_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, False)  # matched=True → unmatched=False
        client.doi_lookup_with_title_check.assert_not_called()
        client.title_search.assert_not_called()

    def test_stale_pre_v5_row_forces_recompute(self):
        """#431 §0.12.3b (ship blocker): a pre-v5 row written under the
        author-agree logic (matched_by=title, NO decision_version) must be
        treated as a MISS so the v5 candidate loop actually runs — without this
        the pivot is a no-op for up to 90 days of cached `matched` rows. The
        stale row says matched=True; under v5 the DOI misses and the title
        fallback (non-exact) returns no match → unmatched=True, and the live
        client methods ARE called."""
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = None  # DOI miss
        client.title_search.return_value = None  # v5: non-exact → no match
        qf = "doi:10.5555/abc|title:Attention Is All You Need"
        cache = _FakeCache(seed={
            ("vaswani2017", "crossref", qf): {
                "matched": True, "matched_by": "title",  # stale, no version
            }
        })
        result = resolve_crossref_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, True)  # recomputed unmatched, NOT stale matched
        client.title_search.assert_called_once()  # v5 loop ran
        # The recomputed verdict is re-stored WITH the current version.
        _, _, _, response = cache.put_calls[0]
        self.assertEqual(
            response["decision_version"], cs.RESOLVER_DECISION_VERSION
        )

    def test_cache_miss_calls_network_and_populates(self):
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = None
        client.title_search.return_value = None  # unmatched
        cache = _FakeCache()
        result = resolve_crossref_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, True)  # unmatched
        self.assertEqual(len(cache.put_calls), 1)
        # The negative verdict is cached (so repeat runs don't re-hammer API).
        _, resolver, qf, response = cache.put_calls[0]
        self.assertEqual(resolver, "crossref")
        self.assertIs(response["matched"], False)

    def test_malformed_cache_payload_treated_as_miss(self):
        """A persistent on-disk cache row written by an older/other tool that
        lacks the 'matched' key must be treated as a MISS (force live recompute),
        not crash with KeyError for 90 days. Robustness guard."""
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = {"title": ["X"]}  # match
        qf = "doi:10.5555/abc|title:Attention Is All You Need"
        cache = _FakeCache(seed={
            ("vaswani2017", "crossref", qf): {"legacy": "no matched key"}
        })
        result = resolve_crossref_unmatched(self._entry(), client, cache=cache)
        # Falls through to live call (which matched) → unmatched=False.
        self.assertIs(result, False)
        client.doi_lookup_with_title_check.assert_called_once()

    def test_degradation_is_not_cached(self):
        from contamination_signals import resolve_crossref_unmatched
        from crossref_client import CrossrefUnavailable

        client = MagicMock()
        client.doi_lookup_with_title_check.side_effect = CrossrefUnavailable("down")
        cache = _FakeCache()
        with self.assertRaises(CrossrefUnavailable):
            resolve_crossref_unmatched(self._entry(), client, cache=cache)
        self.assertEqual(cache.put_calls, [])  # NEVER cache a degradation

    def test_manual_entry_does_not_touch_cache(self):
        from contamination_signals import resolve_crossref_unmatched

        client = MagicMock()
        cache = _FakeCache()
        result = resolve_crossref_unmatched(
            self._entry(obtained_via="manual"), client, cache=cache
        )
        self.assertIsNone(result)
        self.assertEqual(cache.get_calls, [])
        self.assertEqual(cache.put_calls, [])


class ResolveArxivCacheTest(unittest.TestCase):
    """Same cache contract for the arXiv resolver (ID-keyed query_form)."""

    def _entry(self, **overrides):
        base = {
            "citation_key": "vaswani2017",
            "title": "Attention Is All You Need",
            "arxiv_id": "1706.03762",
            "obtained_via": "folder-scan",
        }
        return base | overrides

    def test_cache_hit_skips_network(self):
        from contamination_signals import resolve_arxiv_unmatched

        client = MagicMock()
        qf = "arxiv:1706.03762|title:Attention Is All You Need"
        cache = _FakeCache(seed={
            ("vaswani2017", "arxiv", qf): {
                "matched": False, "matched_by": None,
                "decision_version": cs.RESOLVER_DECISION_VERSION,
            }
        })
        result = resolve_arxiv_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, True)  # matched=False → unmatched=True
        client.arxiv_id_lookup.assert_not_called()

    def test_stale_pre_v5_row_forces_recompute(self):
        """#431 §0.12.3b: a pre-v5 arXiv row with no decision_version is a MISS;
        the ID lookup runs live under v5."""
        from contamination_signals import resolve_arxiv_unmatched

        client = MagicMock()
        client.arxiv_id_lookup.return_value = None  # ID miss
        client.title_search.return_value = None  # v5: non-exact → no match
        qf = "arxiv:1706.03762|title:Attention Is All You Need"
        cache = _FakeCache(seed={
            ("vaswani2017", "arxiv", qf): {
                "matched": True, "matched_by": "title",  # stale, no version
            }
        })
        result = resolve_arxiv_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, True)  # recomputed, NOT stale matched
        client.arxiv_id_lookup.assert_called_once()

    def test_cache_miss_populates(self):
        from contamination_signals import resolve_arxiv_unmatched

        client = MagicMock()
        client.arxiv_id_lookup.return_value = {"title": "X", "year": 2017}
        cache = _FakeCache()
        result = resolve_arxiv_unmatched(self._entry(), client, cache=cache)
        self.assertIs(result, False)  # matched
        self.assertEqual(len(cache.put_calls), 1)
        _, resolver, _, response = cache.put_calls[0]
        self.assertEqual(resolver, "arxiv")
        self.assertIs(response["matched"], True)


class SemanticScholarCacheTest(unittest.TestCase):
    """S2's wrapper is compute_ss_unmatched_signal (the lone client.lookup is
    entry-keyed already). Cache integration lives at that wrapper, matching
    Option A — the resolver_name is 'semantic_scholar'. Per spec §2 Delta 2
    'the S2 lookup'."""

    def _entry(self, **overrides):
        base = {
            "citation_key": "chen2024ai",
            "title": "Test paper",
            "doi": "10.1234/xyz",
            "obtained_via": "folder-scan",
        }
        return base | overrides

    def test_cache_none_byte_equivalent(self):
        client = MagicMock()
        client.lookup.return_value = {"matched": True}
        self.assertFalse(cs.compute_ss_unmatched_signal(self._entry(), client, cache=None))
        client.lookup.assert_called_once()

    def test_cache_hit_skips_lookup(self):
        client = MagicMock()
        qf = "doi:10.1234/xyz|title:Test paper"
        cache = _FakeCache(seed={
            ("chen2024ai", "semantic_scholar", qf): {
                "matched": False, "matched_by": None,
                "decision_version": cs.RESOLVER_DECISION_VERSION,
            }
        })
        result = cs.compute_ss_unmatched_signal(self._entry(), client, cache=cache)
        self.assertTrue(result)  # matched=False → unmatched=True
        client.lookup.assert_not_called()

    def test_stale_pre_v5_row_forces_recompute(self):
        """#431 §0.12.3b (ship blocker), the exact regression the spec names: a
        stale `{matched: True, matched_by: title}` row with no decision_version
        (written under v3/v4 author-agree, e.g. a Study 1 / Study 2 same-author
        pair) MUST be a miss so client.lookup runs under v5 — not returned as a
        stale `matched`."""
        client = MagicMock()
        client.lookup.return_value = {"matched": False}  # v5 live verdict
        qf = "doi:10.1234/xyz|title:Test paper"
        cache = _FakeCache(seed={
            ("chen2024ai", "semantic_scholar", qf): {
                "matched": True, "matched_by": "title",  # stale, no version
            }
        })
        result = cs.compute_ss_unmatched_signal(self._entry(), client, cache=cache)
        self.assertTrue(result)  # recomputed unmatched=True, NOT stale matched
        client.lookup.assert_called_once()  # v5 loop ran

    def test_cache_miss_populates(self):
        client = MagicMock()
        client.lookup.return_value = {"matched": True}
        cache = _FakeCache()
        result = cs.compute_ss_unmatched_signal(self._entry(), client, cache=cache)
        self.assertFalse(result)
        self.assertEqual(len(cache.put_calls), 1)
        _, resolver, _, response = cache.put_calls[0]
        self.assertEqual(resolver, "semantic_scholar")
        self.assertIs(response["matched"], True)

    def test_manual_does_not_touch_cache(self):
        client = MagicMock()
        cache = _FakeCache()
        result = cs.compute_ss_unmatched_signal(
            self._entry(obtained_via="manual"), client, cache=cache
        )
        self.assertIsNone(result)
        self.assertEqual(cache.get_calls, [])
        self.assertEqual(cache.put_calls, [])

    def test_degradation_not_cached(self):
        client = MagicMock()
        client.lookup.side_effect = cs.SemanticScholarUnavailable("5xx")
        cache = _FakeCache()
        result = cs.compute_ss_unmatched_signal(self._entry(), client, cache=cache)
        self.assertIsNone(result)  # degradation → None (omit field)
        self.assertEqual(cache.put_calls, [])  # never cache degradation


# ============================================================================
# v3.11 #182 Delta 4 — queried_by retrofit (the ID-keyed-unmatched signal that
# the narrowed-false reducer C-V6(a) needs). The internal resolver-flow helpers
# return (unmatched, matched_by, queried_by) where queried_by records what the
# resolver ACTUALLY queried by, so a title-only unmatched is distinguishable
# from an ID-keyed unmatched at reduce time.
# ============================================================================


class ResolveDoiThenTitleQueriedByTest(unittest.TestCase):
    """_resolve_doi_then_title now returns (unmatched, matched_by, queried_by).
    queried_by ∈ {'id', 'title'} for unmatched; mirrors matched_by for matched."""

    def test_doi_match_queried_by_id(self):
        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = {"title": ["X"]}
        unmatched, matched_by, queried_by = cs._resolve_doi_then_title(
            {"title": "X", "doi": "10.5/x"}, client
        )
        self.assertIs(unmatched, False)
        self.assertEqual(matched_by, "doi")
        self.assertEqual(queried_by, "id")

    def test_doi_present_but_unmatched_queried_by_id(self):
        """DOI present, ID lookup miss, title miss → unmatched, queried_by='id'
        (an ID lookup WAS attempted — this is ID-keyed unmatched, C-V6(a))."""
        client = MagicMock()
        client.doi_lookup_with_title_check.return_value = None
        client.title_search.return_value = None
        unmatched, matched_by, queried_by = cs._resolve_doi_then_title(
            {"title": "Ghost", "doi": "10.5/fake"}, client
        )
        self.assertIs(unmatched, True)
        self.assertIsNone(matched_by)
        self.assertEqual(queried_by, "id")

    def test_no_doi_unmatched_queried_by_title(self):
        """No DOI to key on → title-only search → unmatched, queried_by='title'
        (NOT id-keyed — coverage gap, must reduce to unresolvable not false)."""
        client = MagicMock()
        client.title_search.return_value = None
        unmatched, matched_by, queried_by = cs._resolve_doi_then_title(
            {"title": "Unindexed regional paper"}, client  # no doi
        )
        self.assertIs(unmatched, True)
        self.assertIsNone(matched_by)
        self.assertEqual(queried_by, "title")

    def test_no_doi_title_match_queried_by_title(self):
        client = MagicMock()
        client.title_search.return_value = {"title": ["X"]}
        unmatched, matched_by, queried_by = cs._resolve_doi_then_title(
            {"title": "X"}, client  # no doi
        )
        self.assertIs(unmatched, False)
        self.assertEqual(matched_by, "title")
        self.assertEqual(queried_by, "title")


class ResolveArxivQueriedByTest(unittest.TestCase):
    """_resolve_arxiv_id_then_title queried_by uses arxiv_id presence."""

    def test_arxiv_id_present_unmatched_queried_by_id(self):
        client = MagicMock()
        client.arxiv_id_lookup.return_value = None
        client.title_search.return_value = None
        unmatched, matched_by, queried_by = cs._resolve_arxiv_id_then_title(
            {"title": "Ghost", "arxiv_id": "9999.99999"}, client
        )
        self.assertIs(unmatched, True)
        self.assertEqual(queried_by, "id")

    # (#331) No test for the no-arxiv_id case here: _resolve_arxiv_id_then_title's
    # precondition is now "entry has an arxiv_id" — both callers
    # (resolve_arxiv_unmatched and verification_gate._run_arxiv) skip the resolver
    # before reaching it when arxiv_id is absent. The skip is covered by
    # ResolveArxivUnmatchedTest.test_arxiv_id_absent_skips_resolver and the
    # verification_gate skipped-status tests.

    def test_arxiv_id_match_queried_by_id(self):
        client = MagicMock()
        client.arxiv_id_lookup.return_value = {"title": "X", "year": 2017}
        unmatched, matched_by, queried_by = cs._resolve_arxiv_id_then_title(
            {"title": "X", "arxiv_id": "1706.03762"}, client
        )
        self.assertIs(unmatched, False)
        self.assertEqual(matched_by, "arxiv")
        self.assertEqual(queried_by, "id")
