"""Release-gate integration test for the journal-quality data pipeline.

Why this test exists
====================

The journal-quality system depends on five external data sources:

- OpenAlex Sources (S3 bulk dump)     ~280K journals/conferences
- DOAJ public CSV dump                 ~22K  open-access journals
- Stop Predatory Journals              ~2.5K predatory entries
- JabRef abbreviation list             ~66K abbreviations
- OpenAlex Institutions (S3 bulk dump) ~120K institutions

If any of these upstreams change their schema (rename a field, drop a
column, restructure the JSON layout), the bundled-data tier silently
breaks: every academic search result starts coming back unscored. This
test catches that BEFORE we cut a release.

Structure
---------

A session-scoped fixture downloads ALL FIVE sources **in parallel** via
``ThreadPoolExecutor``. Each per-source test then asserts file presence
and record-shape against the already-downloaded data (fast). A separate
test runs ``build_db()`` against the freshly-downloaded files and a
final test verifies the runtime accessor can score a real journal.

Parallelism is essential: the OpenAlex Institutions API alone takes
~10 minutes (550 paginated requests). Sequentially, all five would
exceed 25 minutes; in parallel the wall-clock is bounded by the
slowest single source (~10 min for institutions).

This test is intentionally **not** part of the regular suite — it pulls
~30 MB from third-party APIs and runs ~10–15 minutes wall-clock. It's
marked with ``@pytest.mark.integration`` and ``@pytest.mark.slow`` so
it's skipped by default; the dedicated workflow opts in via ``-m``
selection.

Run locally with::

    pytest tests/integration/test_journal_quality_release_gate.py \\
        -m "integration and slow" --no-header -v --timeout=2700

Or via the dedicated CI workflow::

    .github/workflows/journal-data-integration.yml
"""

from __future__ import annotations

import gzip
import json
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import pytest
import requests.exceptions
from loguru import logger


pytestmark = [
    pytest.mark.integration,
    pytest.mark.slow,
]


# Lower bounds — deliberately loose so a small upstream fluctuation
# doesn't break the gate, but tight enough to catch a catastrophic
# regression (an API returning an empty result set).
MIN_OPENALEX_SOURCES = 100_000  # actual ~217K
MIN_DOAJ_JOURNALS = 5_000  # actual ~35K
MIN_PREDATORY_JOURNALS = 500  # actual ~1.3K
MIN_INSTITUTIONS = 50_000  # actual ~110K
MIN_ABBREVIATIONS = 10_000  # actual ~66K


@pytest.fixture(scope="module")
def downloaded_data_dir(tmp_path_factory) -> Path:
    """Download every external data source in parallel into a tmp dir.

    Uses ``ThreadPoolExecutor`` so the slowest source (institutions)
    sets the wall-clock floor instead of the sum of all five. Each
    source is fetched directly via its ``DataSource.fetch()`` method —
    we deliberately bypass ``download_journal_data()`` here so a single
    source's failure doesn't abort the others. We want the test to
    report ALL broken sources, not just the first one we hit.

    Module-scoped so the per-source tests + the build test + the
    lookup test all share one download.
    """
    from local_deep_research.journal_quality.data_sources import (
        ALL_SOURCES,
    )

    tmp_dir: Path = tmp_path_factory.mktemp("journal_quality_release_gate")

    errors: dict[str, str] = {}
    counts: dict[str, int] = {}

    # Transient network errors that warrant a full-source retry. The
    # per-partition download in ``iter_partitions`` already retries
    # individual partitions with a 2-5-10-20-40 s backoff (5 attempts).
    # If even that budget is exhausted (e.g. a sustained S3 outage >
    # ~75 s), we retry the *entire* source from scratch — fresh TCP
    # connections, fresh manifest, fresh partition list — up to 3 times.
    _TRANSIENT_ERRORS = (
        requests.exceptions.ChunkedEncodingError,
        requests.exceptions.ConnectionError,
        requests.exceptions.Timeout,
        ConnectionResetError,
        ConnectionAbortedError,
        BrokenPipeError,
    )
    _FETCH_MAX_RETRIES = 3
    _FETCH_BACKOFF_SECONDS = (30, 60, 120)
    assert len(_FETCH_BACKOFF_SECONDS) == _FETCH_MAX_RETRIES, (
        "backoff tuple length must match retry count"
    )

    def _fetch_one(src):
        for attempt in range(1 + _FETCH_MAX_RETRIES):
            try:
                n = src.fetch(tmp_dir)
                return src.key, n, None
            except _TRANSIENT_ERRORS as e:
                if attempt < _FETCH_MAX_RETRIES:
                    wait = _FETCH_BACKOFF_SECONDS[attempt]
                    logger.warning(
                        f"Source {src.key} failed (attempt {attempt + 1}/"
                        f"{1 + _FETCH_MAX_RETRIES}): {e!r}  — retrying in {wait}s"
                    )
                    time.sleep(wait)
                    continue
                logger.exception(
                    f"Source {src.key} exhausted all "
                    f"{1 + _FETCH_MAX_RETRIES} retries"
                )
                return src.key, 0, repr(e)
            except Exception as e:  # noqa: BLE001 — surface every failure
                return src.key, 0, repr(e)

    # All five in parallel. max_workers=5 lets each source own a thread
    # and run end-to-end without blocking on its peers.
    with ThreadPoolExecutor(max_workers=len(ALL_SOURCES)) as pool:
        futures = [pool.submit(_fetch_one, src) for src in ALL_SOURCES]
        for fut in as_completed(futures):
            key, n, err = fut.result()
            counts[key] = n
            if err:
                errors[key] = err

    # If the REQUIRED source (OpenAlex) failed, abort the whole module
    # — there's nothing meaningful to assert against. Optional sources
    # are reported as test failures by their own per-source tests so
    # the report still tells us which one broke.
    if "openalex" in errors:
        pytest.fail(
            f"Required OpenAlex source failed to download: {errors['openalex']}"
        )

    # Stash the count + error info on the dir for the per-source tests
    # to assert against. tmp_path is otherwise a plain Path so we use a
    # sidecar JSON file.
    (tmp_dir / "_release_gate_meta.json").write_text(
        json.dumps({"counts": counts, "errors": errors})
    )

    return tmp_dir


def _meta(data_dir: Path) -> dict:
    return json.loads((data_dir / "_release_gate_meta.json").read_text())


# ---------------------------------------------------------------------------
# Per-source download tests
# Each one asserts that ONE source downloaded successfully, the file is
# present, and the field names we depend on at build time are still
# there. They run instantly because the download already happened in
# the parallel fixture.
# ---------------------------------------------------------------------------


def test_openalex_sources(downloaded_data_dir: Path):
    """OpenAlex sources file is gzipped JSON with the compact-record
    field names we read in db.py::_populate_sources."""
    meta = _meta(downloaded_data_dir)
    assert "openalex" not in meta["errors"], (
        f"OpenAlex fetch failed: {meta['errors'].get('openalex')}"
    )
    assert meta["counts"]["openalex"] >= MIN_OPENALEX_SOURCES

    f = downloaded_data_dir / "openalex_sources.json.gz"
    assert f.exists()
    assert f.stat().st_size > 1_000_000

    with gzip.open(f, "rt", encoding="utf-8") as fh:
        payload = json.load(fh)
    assert "s" in payload
    sources = payload["s"]
    assert len(sources) >= MIN_OPENALEX_SOURCES

    # Spot-check a sample — the field names are the wire contract
    # between OpenAlex's API and our build pipeline. ``cb`` is the new
    # cited_by_count field added for quartile derivation; if OpenAlex
    # ever drops that field this assertion fires.
    sample = next(iter(sources.values()))
    expected = {"n", "t", "h", "if", "cb", "p", "i"}
    missing = expected - set(sample.keys())
    assert len(missing) < len(expected) / 2, (
        f"OpenAlex compact record missing too many expected keys: "
        f"{missing} (sample={sample!r})"
    )


def test_doaj_journals(downloaded_data_dir: Path):
    """DOAJ dump downloaded with the field names we read at build time."""
    meta = _meta(downloaded_data_dir)
    assert "doaj" not in meta["errors"], (
        f"DOAJ fetch failed: {meta['errors'].get('doaj')}"
    )
    assert meta["counts"]["doaj"] >= MIN_DOAJ_JOURNALS

    f = downloaded_data_dir / "doaj_journals.json"
    assert f.exists()
    data = json.loads(f.read_text())
    assert isinstance(data, dict)
    assert len(data) >= MIN_DOAJ_JOURNALS

    sample = next(iter(data.values()))
    # Field names consumed by the DOAJ pass in db.py::_populate_sources.
    assert "name" in sample
    assert "publisher" in sample


def test_predatory_list(downloaded_data_dir: Path):
    """Stop-predatory-journals lists downloaded and shaped correctly."""
    meta = _meta(downloaded_data_dir)
    assert "predatory" not in meta["errors"], (
        f"Predatory fetch failed: {meta['errors'].get('predatory')}"
    )
    assert meta["counts"]["predatory"] >= MIN_PREDATORY_JOURNALS

    f = downloaded_data_dir / "predatory.json"
    assert f.exists()
    data = json.loads(f.read_text())
    assert "journals" in data
    assert "publishers" in data
    assert "hijacked" in data
    assert len(data["journals"]) >= MIN_PREDATORY_JOURNALS


def test_jabref_abbreviations(downloaded_data_dir: Path):
    """JabRef abbreviation list downloaded with sane row counts."""
    meta = _meta(downloaded_data_dir)
    assert "jabref" not in meta["errors"], (
        f"JabRef fetch failed: {meta['errors'].get('jabref')}"
    )
    assert meta["counts"]["jabref"] >= MIN_ABBREVIATIONS

    f = downloaded_data_dir / "jabref_abbreviations.json.gz"
    assert f.exists()
    with gzip.open(f, "rt", encoding="utf-8") as fh:
        data = json.load(fh)
    assert len(data) >= MIN_ABBREVIATIONS


def test_openalex_institutions(downloaded_data_dir: Path):
    """OpenAlex institutions API still returns compact records.

    This is the slowest source (~10 min for ~110K institutions via
    cursor pagination). It's not strictly required for the journal
    scoring tier — it powers the Tier 3.5 affiliation salvage path —
    but a regression here means arxiv preprints lose their
    institution-tier scoring fallback, which is a real quality drop.
    """
    meta = _meta(downloaded_data_dir)
    assert "institutions" not in meta["errors"], (
        f"Institutions fetch failed: {meta['errors'].get('institutions')}"
    )
    assert meta["counts"]["institutions"] >= MIN_INSTITUTIONS

    f = downloaded_data_dir / "openalex_institutions.json.gz"
    assert f.exists()
    with gzip.open(f, "rt", encoding="utf-8") as fh:
        data = json.load(fh)
    # Same wrapper convention as the sources file.
    institutions = data.get("i") or data.get("institutions") or data
    if isinstance(institutions, dict):
        assert len(institutions) >= MIN_INSTITUTIONS
    else:
        assert len(institutions) >= MIN_INSTITUTIONS


# ---------------------------------------------------------------------------
# Build + lookup tests — these run AFTER all five downloads have
# completed (the fixture is module-scoped so the build test sees a
# fully-populated data directory).
# ---------------------------------------------------------------------------


def test_build_journal_quality_db(downloaded_data_dir: Path):
    """``build_db()`` runs end-to-end against the freshly-downloaded
    files and produces a queryable database with all the columns from
    this PR (cited_by_count + quartile + the existing schema).
    """
    from sqlalchemy import create_engine, func, select

    from local_deep_research.journal_quality.db import build_db
    from local_deep_research.journal_quality.models import (
        Institution,
        PredatoryJournal,
        Source,
    )

    db_file = downloaded_data_dir / "journal_quality.db"
    if db_file.exists():
        # Build is the only writer; the file is chmod 0o444 by default.
        db_file.chmod(0o644)
        db_file.unlink()

    build_db(data_dir=downloaded_data_dir, output_path=db_file)

    assert db_file.exists()

    engine = create_engine(f"sqlite:///{db_file}")
    try:
        with engine.connect() as conn:
            # 1. Source table populated.
            n_sources = conn.execute(
                select(func.count()).select_from(Source)
            ).scalar()
            assert n_sources >= MIN_OPENALEX_SOURCES, (
                f"Source row count below minimum: {n_sources}"
            )

            # 2. cited_by_count populated for at least some rows (it's
            #    NULL on DOAJ-only entries by design).
            n_with_citations = conn.execute(
                select(func.count())
                .select_from(Source)
                .where(Source.cited_by_count.is_not(None))
            ).scalar()
            assert n_with_citations > 0, (
                "cited_by_count is NULL for every source — either the "
                "OpenAlex API stopped exposing the field or the openalex.py "
                "data source loader regressed."
            )

            # 3. Quartile post-pass ran and assigned every bucket.
            quartiles = {
                row[0]
                for row in conn.execute(
                    select(Source.quartile)
                    .where(Source.quartile.is_not(None))
                    .distinct()
                ).all()
            }
            assert quartiles == {"Q1", "Q2", "Q3", "Q4"}, (
                f"Quartile buckets not all populated: got {quartiles}"
            )

            # 4. Predatory list loaded into its dedicated table.
            n_pred = conn.execute(
                select(func.count()).select_from(PredatoryJournal)
            ).scalar()
            assert n_pred >= MIN_PREDATORY_JOURNALS

            # 5. Institutions loaded (used by Tier 3.5 affiliation salvage).
            n_inst = conn.execute(
                select(func.count()).select_from(Institution)
            ).scalar()
            assert n_inst >= MIN_INSTITUTIONS
    finally:
        engine.dispose()


def test_runtime_accessor_can_score_real_journal(
    downloaded_data_dir: Path,
):
    """End-to-end smoke: bind the runtime ``JournalQualityDB`` to the
    freshly-built file and score a real journal. This is the same code
    path the ``JournalReputationFilter`` uses in production.
    """
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker

    from local_deep_research.journal_quality.db import JournalQualityDB

    db_file = downloaded_data_dir / "journal_quality.db"
    assert db_file.exists(), (
        "test_build_journal_quality_db must run before this test"
    )

    db = JournalQualityDB()
    db._engine = create_engine(f"sqlite:///{db_file}")
    db._SessionLocal = sessionmaker(bind=db._engine, expire_on_commit=False)

    try:
        nature = db.lookup_openalex(name="Nature")
        assert nature is not None, "Nature not found in built DB"
        assert nature["h_index"] is not None and nature["h_index"] > 1000
        # Field shape contract used by the filter (`is_in_doaj`,
        # `publisher`, `issn_l`, `openalex_source_id`).
        assert "is_in_doaj" in nature
        assert "publisher" in nature
        assert "issn_l" in nature
        assert "openalex_source_id" in nature
    finally:
        db.reset()


def test_dashboard_queries_against_real_db(downloaded_data_dir: Path):
    """Exercise the dashboard query methods against the freshly built
    DB. Same code path that ``/api/journals`` (the journal-quality
    dashboard) hits in production — if the schema or query helpers
    regress, the dashboard goes blank.
    """
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker

    from local_deep_research.journal_quality.db import JournalQualityDB

    db_file = downloaded_data_dir / "journal_quality.db"
    assert db_file.exists(), (
        "test_build_journal_quality_db must run before this test"
    )

    db = JournalQualityDB()
    db._engine = create_engine(f"sqlite:///{db_file}")
    db._SessionLocal = sessionmaker(bind=db._engine, expire_on_commit=False)

    try:
        # 1. Summary card on the dashboard top.
        summary = db.get_summary()
        assert summary["total"] >= MIN_OPENALEX_SOURCES
        assert summary["avg_quality"] is not None
        assert summary["doaj_count"] >= MIN_DOAJ_JOURNALS // 2
        assert summary["predatory_count"] >= MIN_PREDATORY_JOURNALS

        # 2. Quality histogram (powers the bar chart).
        qdist = db.get_quality_distribution()
        assert qdist, "quality distribution is empty"
        assert all(int(k) >= 1 and int(k) <= 10 for k in qdist.keys())
        assert sum(qdist.values()) >= MIN_OPENALEX_SOURCES // 2

        # 3. Source breakdown (openalex / doaj / predatory / llm).
        sdist = db.get_source_distribution()
        assert "openalex" in sdist
        assert sdist["openalex"] >= MIN_OPENALEX_SOURCES // 2

        # 4. Default first page of the journals table.
        journals, total = db.get_journals_page(page=1, per_page=50)
        assert total >= MIN_OPENALEX_SOURCES
        assert len(journals) == 50
        # Default sort=quality desc — first page should be Q1 / elite.
        assert journals[0]["quality"] >= journals[-1]["quality"]
        # Field shape consumed by the dashboard JS.
        j0 = journals[0]
        for key in ("name", "quality", "h_index", "score_source"):
            assert key in j0, f"dashboard row missing field: {key}"

        # 5. Search filter — "nature" should always match a real journal.
        journals, total = db.get_journals_page(
            page=1, per_page=10, search="nature"
        )
        assert total > 0
        assert any("nature" in (j["name"] or "").lower() for j in journals)

        # 6. Tier filter — elite tier should always have entries given the
        # ~280K-row corpus.
        _, total_elite = db.get_journals_page(page=1, per_page=10, tier="elite")
        assert total_elite > 0
    finally:
        db.reset()
