"""Field-level schema drift detection in the OpenAlex fetcher.

A row-count floor catches the case where the whole fetch collapses,
but not the case where every row loads but a key field (``h_index``,
``cited_by_count``) has been renamed upstream. This test feeds
OpenAlex-shaped JSONL with `h_index` silently renamed to `h_idx`
and asserts that ``SchemaDriftError`` is raised instead of a
silently corrupt snapshot.
"""

from __future__ import annotations

import gzip
import io
import json
from unittest.mock import MagicMock, patch

import pytest

from local_deep_research.journal_quality.data_sources.openalex import (
    OpenAlexSource,
    SchemaDriftError,
)


def _make_jsonl_gz(records):
    buf = io.BytesIO()
    with gzip.GzipFile(fileobj=buf, mode="wb") as gz:
        for rec in records:
            gz.write((json.dumps(rec) + "\n").encode("utf-8"))
    return buf.getvalue()


def _manifest(num_parts: int):
    return {
        "entries": [
            {"url": f"s3://openalex/data/sources/part_{i}.gz"}
            for i in range(num_parts)
        ]
    }


def _response(*, status_code=200, content=b"", json_body=None):
    resp = MagicMock()
    resp.status_code = status_code
    resp.raise_for_status = MagicMock()
    resp.content = content
    resp.json = MagicMock(return_value=json_body or {})
    return resp


def test_missing_h_index_raises_schema_drift(tmp_path):
    # 10010 records — enough to clear the row-count floor, but every
    # one of them carries h_idx (renamed) instead of h_index.
    drifted = [
        {
            "id": f"S{i}",
            "display_name": f"J {i}",
            "type": "journal",
            "summary_stats": {
                "h_idx": 5,  # renamed from h_index
                "2yr_mean_citedness": 1.2,
            },
            "cited_by_count": 1000,
            "issn_l": None,
        }
        for i in range(10_010)
    ]
    content = _make_jsonl_gz(drifted)

    with patch(
        "local_deep_research.security.safe_requests.safe_get_with_retries",
    ) as mock_get:
        mock_get.side_effect = [
            _response(json_body=_manifest(1)),  # manifest
            _response(content=content),  # single partition
        ]
        with pytest.raises(SchemaDriftError) as exc_info:
            OpenAlexSource().fetch(tmp_path)

    msg = str(exc_info.value)
    assert "h_index present in journal sample=False" in msg
    # Existing file must not have been overwritten.
    assert not (tmp_path / OpenAlexSource().filename).exists()


def test_missing_cited_by_count_raises_schema_drift(tmp_path):
    drifted = [
        {
            "id": f"S{i}",
            "display_name": f"J {i}",
            "type": "journal",
            "summary_stats": {"h_index": 5, "2yr_mean_citedness": 1.2},
            # cited_by_count silently missing
        }
        for i in range(10_010)
    ]
    content = _make_jsonl_gz(drifted)

    with patch(
        "local_deep_research.security.safe_requests.safe_get_with_retries",
    ) as mock_get:
        mock_get.side_effect = [
            _response(json_body=_manifest(1)),
            _response(content=content),
        ]
        with pytest.raises(SchemaDriftError) as exc_info:
            OpenAlexSource().fetch(tmp_path)

    assert "cited_by_count present in journal sample=False" in str(
        exc_info.value
    )


def test_healthy_snapshot_passes(tmp_path):
    healthy = [
        {
            "id": f"S{i}",
            "display_name": f"J {i}",
            "type": "journal",
            "summary_stats": {"h_index": 5, "2yr_mean_citedness": 1.2},
            "cited_by_count": 1000,
        }
        for i in range(10_010)
    ]
    content = _make_jsonl_gz(healthy)

    with patch(
        "local_deep_research.security.safe_requests.safe_get_with_retries",
    ) as mock_get:
        mock_get.side_effect = [
            _response(json_body=_manifest(1)),
            _response(content=content),
        ]
        count = OpenAlexSource().fetch(tmp_path)

    assert count == 10_010
    assert (tmp_path / OpenAlexSource().filename).exists()


def test_missing_id_raises_schema_drift(tmp_path):
    """If the ``id`` field is renamed (e.g. to ``source_id``), every record
    gets silently skipped at parse time. Without a dedicated drift check
    this surfaces only as the generic row-count floor RuntimeError, hiding
    the real cause. Guard that the ``SchemaDriftError`` path fires first.
    """
    drifted = [
        {
            "source_id": f"S{i}",  # renamed from id
            "display_name": f"J {i}",
            "type": "journal",
            "summary_stats": {"h_index": 5, "2yr_mean_citedness": 1.2},
            "cited_by_count": 1000,
        }
        for i in range(10_010)
    ]
    content = _make_jsonl_gz(drifted)

    with patch(
        "local_deep_research.security.safe_requests.safe_get_with_retries",
    ) as mock_get:
        mock_get.side_effect = [
            _response(json_body=_manifest(1)),
            _response(content=content),
        ]
        with pytest.raises(SchemaDriftError) as exc_info:
            OpenAlexSource().fetch(tmp_path)

    msg = str(exc_info.value)
    assert "none carried an 'id' field" in msg
    assert not (tmp_path / OpenAlexSource().filename).exists()


def test_conference_only_snapshot_does_not_false_trigger(tmp_path):
    """Conferences and other non-journal source types legitimately lack
    ``h_index`` — a sample that's all conferences must not raise drift.
    Journal records with ``h_index`` drive the check; conference records
    are excluded from the sample.
    """
    mixed = []
    # 10,010 conference records without h_index (would false-trigger if
    # the sample were unfiltered).
    mixed.extend(
        {
            "id": f"C{i}",
            "display_name": f"Conf {i}",
            "type": "conference",
            "summary_stats": {"2yr_mean_citedness": 1.2},
            "cited_by_count": 500,
        }
        for i in range(10_010)
    )
    # 100 journals with h_index — enough to fill the drift sample.
    mixed.extend(
        {
            "id": f"J{i}",
            "display_name": f"Journal {i}",
            "type": "journal",
            "summary_stats": {"h_index": 5, "2yr_mean_citedness": 1.2},
            "cited_by_count": 1000,
        }
        for i in range(100)
    )
    content = _make_jsonl_gz(mixed)

    with patch(
        "local_deep_research.security.safe_requests.safe_get_with_retries",
    ) as mock_get:
        mock_get.side_effect = [
            _response(json_body=_manifest(1)),
            _response(content=content),
        ]
        count = OpenAlexSource().fetch(tmp_path)

    assert count == 10_110
    assert (tmp_path / OpenAlexSource().filename).exists()
