"""Unit tests for ``utilities.openalex_enrichment``.

Covers:
  - ``_normalize_doi``: every branch of the anchored ``startswith`` ladder,
    including the CodeQL-reviewed https/http/bare-10.* paths.
  - ``enrich_results_with_source_ids``: happy path, skip conditions,
    already-enriched input, OpenAlex HTTP errors, malformed responses,
    and batching past the 50-per-request cap.

All network access goes through a mocked ``safe_get`` so the tests are
deterministic and offline.
"""

from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.utilities.openalex_enrichment import (
    _normalize_doi,
    enrich_results_with_source_ids,
)


# ---------------------------------------------------------------------------
# _normalize_doi
# ---------------------------------------------------------------------------


class TestNormalizeDoi:
    """Pure function, no I/O — one branch per input shape."""

    def test_https_doi_returned_unchanged(self):
        """CodeQL-anchored path: already the canonical form."""
        doi = "https://doi.org/10.1038/nature12373"
        assert _normalize_doi(doi) == doi

    def test_http_doi_upgraded_to_https(self):
        """http://doi.org/... gets scheme-upgraded; prefix preserved."""
        assert (
            _normalize_doi("http://doi.org/10.1038/nature12373")
            == "https://doi.org/10.1038/nature12373"
        )

    def test_bare_10_doi_wrapped(self):
        """Most APIs return DOIs as bare ``10.xxxx/...``."""
        assert (
            _normalize_doi("10.1038/nature12373")
            == "https://doi.org/10.1038/nature12373"
        )

    def test_whitespace_stripped(self):
        """Incoming values from various citation parsers often have
        trailing whitespace."""
        assert (
            _normalize_doi("  10.1038/nature12373  ")
            == "https://doi.org/10.1038/nature12373"
        )

    def test_unrecognized_form_passed_through(self):
        """A non-DOI string (or a DOI with an unexpected prefix like
        ``dx.doi.org``) passes through unchanged — we don't guess."""
        assert _normalize_doi("dx.doi.org/10.1038/nature12373") == (
            "dx.doi.org/10.1038/nature12373"
        )
        assert _normalize_doi("not-a-doi") == "not-a-doi"
        assert _normalize_doi("") == ""

    def test_codeql_anchoring_is_real(self):
        """Regression guard for CodeQL alert 7635. A substring match
        on ``doi.org/`` anywhere in the URL would be unsafe, so this
        asserts that the function does NOT normalize a URL that merely
        *contains* ``doi.org/`` in a non-anchored position.
        """
        # Malicious-looking input: the prefix-check is anchored via
        # startswith, so this hostile URL is passed through unchanged
        # rather than being mangled into an ambiguous canonical form.
        malicious = "https://attacker.example/?ref=doi.org/10.1038/x"
        assert _normalize_doi(malicious) == malicious


# ---------------------------------------------------------------------------
# enrich_results_with_source_ids
# ---------------------------------------------------------------------------


def _make_openalex_response(works):
    """Build a MagicMock response mimicking the OpenAlex /works payload."""
    resp = MagicMock()
    resp.status_code = 200
    resp.json.return_value = {"results": works}
    return resp


def _work(doi, source_id, source_type="journal"):
    """Build a single OpenAlex work record with a resolved primary_location."""
    return {
        "doi": doi,
        "primary_location": {
            "source": {
                "id": f"https://openalex.org/{source_id}",
                "type": source_type,
            }
        },
    }


class TestEnrichResultsWithSourceIds:
    """HTTP layer mocked at ``safe_get``."""

    def test_empty_list_returns_empty(self):
        """Short-circuit: no work to do, no request made."""
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get"
        ) as mock_get:
            result = enrich_results_with_source_ids([])
            assert result == []
            assert mock_get.call_count == 0

    def test_results_without_dois_skip_network(self):
        """No DOI in any result → no request made, inputs untouched."""
        results = [
            {"title": "Paper A", "url": "https://example.com/a"},
            {"title": "Paper B"},
        ]
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get"
        ) as mock_get:
            out = enrich_results_with_source_ids(results)
        assert out is results  # in-place semantics
        assert "openalex_source_id" not in results[0]
        assert mock_get.call_count == 0

    def test_already_enriched_result_skipped(self):
        """Results with an ``openalex_source_id`` already populated must
        not be re-requested (network savings + stability)."""
        results = [
            {
                "doi": "10.1038/nature12373",
                "openalex_source_id": "S4306417988",
            }
        ]
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get"
        ) as mock_get:
            enrich_results_with_source_ids(results)
            assert mock_get.call_count == 0

    def test_happy_path_populates_source_id_and_type(self):
        """One DOI → one resolved source; the result dict gets both
        ``openalex_source_id`` and ``source_type`` populated."""
        results = [{"doi": "10.1038/nature12373"}]
        mock_resp = _make_openalex_response(
            [_work("https://doi.org/10.1038/nature12373", "S137773608")]
        )
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ):
            enrich_results_with_source_ids(results)
        assert results[0]["openalex_source_id"] == "S137773608"
        assert results[0]["source_type"] == "journal"

    def test_multiple_results_same_doi_all_enriched(self):
        """Duplicate DOIs across results share one HTTP request and all
        get the resolved source_id applied."""
        results = [
            {"doi": "10.1038/nature12373"},
            {"doi": "10.1038/nature12373"},
        ]
        mock_resp = _make_openalex_response(
            [_work("https://doi.org/10.1038/nature12373", "S137773608")]
        )
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ) as mock_get:
            enrich_results_with_source_ids(results)
        assert mock_get.call_count == 1
        for r in results:
            assert r["openalex_source_id"] == "S137773608"

    def test_unresolved_doi_leaves_result_unchanged(self):
        """OpenAlex can't resolve every DOI; unmatched results are left
        untouched — no silent mis-attribution."""
        results = [{"doi": "10.0000/never-existed"}]
        mock_resp = _make_openalex_response([])
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ):
            enrich_results_with_source_ids(results)
        assert "openalex_source_id" not in results[0]

    def test_non_200_response_logs_and_continues(self):
        """HTTP 429/500 etc. must not raise — the batch aborts silently
        and results pass through unenriched. Caller shouldn't fail just
        because OpenAlex is rate-limiting.
        """
        results = [{"doi": "10.1038/nature12373"}]
        bad_resp = MagicMock()
        bad_resp.status_code = 503
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=bad_resp,
        ):
            out = enrich_results_with_source_ids(results)
        assert "openalex_source_id" not in results[0]
        assert out is results

    def test_network_exception_swallowed_graceful(self):
        """``safe_get`` itself can raise on network errors. The function
        catches and logs — caller still gets back its list."""
        results = [{"doi": "10.1038/nature12373"}]
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            side_effect=ConnectionError("boom"),
        ):
            out = enrich_results_with_source_ids(results)
        assert "openalex_source_id" not in results[0]
        assert out is results

    def test_work_without_primary_location_skipped(self):
        """OpenAlex returns a work with no primary_location (e.g.
        preprints, withdrawn papers). Must not KeyError or write a
        bogus source_id."""
        results = [{"doi": "10.0000/preprint"}]
        bad_work = {"doi": "https://doi.org/10.0000/preprint"}  # no location
        mock_resp = _make_openalex_response([bad_work])
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ):
            enrich_results_with_source_ids(results)
        assert "openalex_source_id" not in results[0]

    def test_work_with_null_source_skipped(self):
        """``primary_location.source`` can be literally ``null`` in the
        OpenAlex payload (book chapters, datasets). Must handle without
        crashing.
        """
        results = [{"doi": "10.0000/chapter"}]
        mock_resp = _make_openalex_response(
            [
                {
                    "doi": "https://doi.org/10.0000/chapter",
                    "primary_location": {"source": None},
                }
            ]
        )
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ):
            enrich_results_with_source_ids(results)
        assert "openalex_source_id" not in results[0]

    def test_source_type_optional(self):
        """``source.type`` may be missing — source_id still gets written
        but source_type does not."""
        results = [{"doi": "10.1038/nature12373"}]
        work = {
            "doi": "https://doi.org/10.1038/nature12373",
            "primary_location": {
                "source": {"id": "https://openalex.org/S137773608"}
            },
        }
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=_make_openalex_response([work]),
        ):
            enrich_results_with_source_ids(results)
        assert results[0]["openalex_source_id"] == "S137773608"
        assert "source_type" not in results[0]

    def test_batching_respects_50_per_request_cap(self):
        """75 distinct DOIs → 2 HTTP requests (50 + 25). Verified by
        call_count and by the ``per_page`` param on each call.
        """
        # 75 results, each with a unique DOI
        results = [{"doi": f"10.1234/paper{i:03d}"} for i in range(75)]
        # Return a minimal valid response for each call
        mock_resp = _make_openalex_response([])
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ) as mock_get:
            enrich_results_with_source_ids(results)
        assert mock_get.call_count == 2
        per_pages = [
            call.kwargs["params"]["per_page"]
            for call in mock_get.call_args_list
        ]
        assert per_pages == ["50", "25"]

    def test_email_passed_as_mailto_and_user_agent(self):
        """When a polite-pool email is supplied, it lands in both the
        ``mailto`` query param AND the User-Agent header."""
        results = [{"doi": "10.1038/nature12373"}]
        mock_resp = _make_openalex_response([])
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ) as mock_get:
            enrich_results_with_source_ids(
                results, email="researcher@example.org"
            )
        call = mock_get.call_args
        assert call.kwargs["params"]["mailto"] == "researcher@example.org"
        assert "researcher@example.org" in call.kwargs["headers"]["User-Agent"]

    def test_email_omitted_leaves_mailto_absent(self):
        """No email → no mailto param, no User-Agent override."""
        results = [{"doi": "10.1038/nature12373"}]
        mock_resp = _make_openalex_response([])
        with patch(
            "local_deep_research.utilities.openalex_enrichment.safe_get",
            return_value=mock_resp,
        ) as mock_get:
            enrich_results_with_source_ids(results)
        call = mock_get.call_args
        assert "mailto" not in call.kwargs["params"]
        assert "User-Agent" not in call.kwargs["headers"]


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
