#!/usr/bin/env python3
"""Minimal Semantic Scholar API client wrapper.

Implements the lookup contract documented at
`deep-research/references/semantic_scholar_api_protocol.md` for the
v3.7.3 contamination-signals migration tool (#105). DOI-first, title-
similarity fallback, 429 backoff per the protocol's retry budget.

Not a general-purpose S2 client — this is the migration tool's narrow
need (single-paper existence check). When ARS later adds a broader S2
helper, this module's `SemanticScholarClient` class should satisfy the
contamination_signals.SemanticScholarClient Protocol so the migration
tool can switch over without code changes.
"""
from __future__ import annotations

import os
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Mapping

# Dual-path import: try sibling-style first (scripts/-on-sys.path, used by
# tests and direct CLI invocation) so module identity matches when callers
# import via the sibling path. Fall back to `scripts.<module>` (namespace
# package style, repo-root PYTHONPATH). Same pattern as scripts/slr_lineage.py.
try:
    from _text_similarity import (
        _BACKOFF_SECONDS,
        _MAX_RETRIES,
        _TITLE_SIMILARITY_THRESHOLD,
        _normalize_title,
        _similarity,
    )
    from contamination_signals import SemanticScholarUnavailable
except ImportError:
    from scripts._text_similarity import (
        _BACKOFF_SECONDS,
        _MAX_RETRIES,
        _TITLE_SIMILARITY_THRESHOLD,
        _normalize_title,
        _similarity,
    )
    from scripts.contamination_signals import SemanticScholarUnavailable


# Per protocol: api.semanticscholar.org/graph/v1, 1 req/s unauthenticated.
_API_BASE = "https://api.semanticscholar.org/graph/v1"
_API_HOST = "api.semanticscholar.org"
_API_KEY_ENV = "S2_API_KEY"
_FIELDS = "title,authors,year,externalIds,venue,publicationDate"

# Per protocol line 6: unauthenticated tier is ~1 req/s, authenticated
# (S2_API_KEY) tier is 10 req/s. Default throttle interval defends
# against proactive rate limiting before a 429 fires (#115 R5-2).
_UNAUTHENTICATED_MIN_INTERVAL = 1.0
_AUTHENTICATED_MIN_INTERVAL = 0.1


def _require_api_url(url: str) -> None:
    parsed = urllib.parse.urlsplit(url)
    if parsed.scheme != "https" or parsed.netloc != _API_HOST:
        raise SemanticScholarUnavailable(f"Refusing non-S2 URL: {url}")


class SemanticScholarClient:
    """Production lookup-by-(doi-then-title) client for v3.7.3 backfill.

    Satisfies `contamination_signals.SemanticScholarClient` Protocol.
    Tests inject MagicMocks; production callers use this concrete class.

    Concurrency note: rate-limit pacing is per-instance. A migration tool
    that spawns N concurrent clients will issue N × (1 req/s) outbound
    even though each instance respects the throttle, blowing past the
    protocol's 1 req/s unauthenticated limit and triggering 429s. Share
    a single instance across a migration run.
    """

    def __init__(
        self,
        api_key: str | None = None,
        sleep: Any = time.sleep,
        clock: Any = time.monotonic,
        min_interval_seconds: float | None = None,
    ) -> None:
        self._api_key = api_key or os.environ.get(_API_KEY_ENV)
        self._sleep = sleep
        self._clock = clock
        if min_interval_seconds is None:
            # Auto-pick tier from API key presence (#115 R5-2).
            self._min_interval = (
                _AUTHENTICATED_MIN_INTERVAL if self._api_key
                else _UNAUTHENTICATED_MIN_INTERVAL
            )
        else:
            self._min_interval = min_interval_seconds
        # Pacing state: timestamp of last request. None = no prior call.
        self._last_request_at: float | None = None
        # Outage latch (#115 R5-3): once URLError fires, short-circuit
        # subsequent calls until reset_outage_latch() is invoked.
        self._latched_unavailable: bool = False

    def reset_outage_latch(self) -> None:
        """Clear the outage latch so the next lookup retries the network.

        Long-running tools (e.g., per-passport-batch migration runs) can
        call this between batches to attempt recovery. Per protocol
        §"On API failure": network errors skip the remaining batch;
        this method is the explicit "next batch starts fresh" signal.
        """
        self._latched_unavailable = False

    def lookup(self, entry: Mapping[str, Any]) -> Mapping[str, Any]:
        """Return {"matched": bool, "paperId": str | None}.

        Failure semantics:
        - HTTP 5xx → SemanticScholarUnavailable for THIS reference only
          (server-side error; subsequent lookups retry normally).
        - URLError (network-level) → SemanticScholarUnavailable AND
          latches the client unavailable. Subsequent lookups short-
          circuit without invoking urlopen until reset_outage_latch().
          Per protocol §"On API failure": skip S2 for remaining batch.

        Per protocol §"Query Patterns" + §"Response Handling":
        `semantic_scholar_unmatched` is True only when NEITHER DOI nor
        title lookup yields a hit. So:

        1. If `doi` present → GET /paper/DOI:{doi}; on hit, cross-check
           returned title (Levenshtein ≥ 0.70). DOI_MISMATCH (title
           differs despite DOI hit) AND DOI-404 BOTH fall through to (2)
           rather than returning no-match immediately — the v3.7.3
           Vector 2 contract requires both DOI AND title to miss before
           setting the signal. Codex R2-2 closure.
        2. Title search: GET /paper/search?query={url-encoded-title};
           pick the top result with title similarity ≥ 0.70, prefer
           matching year.

        Raises SemanticScholarUnavailable on:
        - HTTP 429 after exhausting `_MAX_RETRIES` retries
        - HTTP 5xx
        - Network error (URLError)
        """
        doi = entry.get("doi")
        title = entry.get("title") or ""
        if doi:
            doi_result = self._lookup_by_doi(doi, title)
            if doi_result["matched"]:
                return doi_result
            # DOI miss or DOI_MISMATCH: fall through to title search
            # per protocol §Vector 2 "neither DOI nor title" rule.
        if title:
            return self._lookup_by_title(title, entry.get("year"))
        return {"matched": False, "paperId": None}

    def _request(self, path: str) -> dict[str, Any]:
        # Latch short-circuit (#115 R5-3): if a prior URLError marked
        # the network dead, fail fast without trying again.
        if self._latched_unavailable:
            raise SemanticScholarUnavailable(
                "S2 API latched unavailable after prior network failure; "
                "call reset_outage_latch() to retry."
            )

        # Throttle (#115 R5-2): pace requests at the protocol's rate
        # limit. First call passes through (no prior timestamp). Update
        # `_last_request_at` to "now" before issuing the request so all
        # exit paths (success, 404, 5xx, HTTPError, 429-retry) leave a
        # fresh anchor for the next outer call. 429 retries below
        # re-update after their backoff sleep so the anchor reflects
        # actual wall time even when retries land in a slow second.
        if self._last_request_at is not None and self._min_interval > 0:
            elapsed = self._clock() - self._last_request_at
            remaining = self._min_interval - elapsed
            if remaining > 0:
                self._sleep(remaining)
        self._last_request_at = self._clock()

        url = f"{_API_BASE}{path}"
        _require_api_url(url)
        headers = {"User-Agent": "ARS-migration/1.0"}
        if self._api_key:
            headers["x-api-key"] = self._api_key
        req = urllib.request.Request(url, headers=headers)

        for attempt in range(_MAX_RETRIES + 1):
            try:
                # URL is fixed-host HTTPS after _require_api_url().
                with urllib.request.urlopen(req, timeout=30) as resp:  # nosec B310
                    import json
                    return json.loads(resp.read().decode("utf-8"))
            except urllib.error.HTTPError as e:
                if e.code == 404:
                    return {}
                if e.code == 429 and attempt < _MAX_RETRIES:
                    self._sleep(_BACKOFF_SECONDS)
                    # F5 closure (simplify efficiency): refresh anchor
                    # after each 429 backoff so the next outer _request
                    # paces against actual wake time, not entry time.
                    # Without this the next outer call may under-sleep
                    # (because elapsed counts the 2s × N backoff time)
                    # and re-trigger 429.
                    self._last_request_at = self._clock()
                    continue
                if 500 <= e.code < 600:
                    raise SemanticScholarUnavailable(
                        f"S2 API HTTP {e.code}"
                    ) from e
                raise SemanticScholarUnavailable(
                    f"S2 API HTTP {e.code} after {_MAX_RETRIES} retries"
                ) from e
            except urllib.error.URLError as e:
                # Network-level failure: latch the client so subsequent
                # lookups in the same batch fail fast (#115 R5-3).
                # Per protocol §"On API failure": skip S2 for remaining
                # batch on network error.
                self._latched_unavailable = True
                raise SemanticScholarUnavailable(f"S2 API network error: {e}") from e
            except (OSError, TimeoutError) as e:
                # Response-body read timeouts (socket.timeout subclasses
                # OSError; TimeoutError is the 3.10+ alias) and other
                # transient I/O failures during resp.read() must be
                # treated as API degradation per spec — never let them
                # abort the migration. Codex R4-2 closure.
                #
                # Also latch the client (codex #115 R2 closure): the
                # protocol §"On API failure" rule "skip S2 for remaining
                # batch on network error" covers transport-level
                # failures, which include connection resets / socket
                # read timeouts that surface during resp.read(), not
                # only URLError at urlopen() time. Without this latch,
                # a real network outage produces 30s read-timeout per
                # entry × N entries.
                self._latched_unavailable = True
                raise SemanticScholarUnavailable(
                    f"S2 API I/O failure during response read: {e}"
                ) from e
        raise SemanticScholarUnavailable(f"S2 API exhausted {_MAX_RETRIES} retries")

    def _lookup_by_doi(self, doi: str, expected_title: str) -> dict[str, Any]:
        data = self._request(
            f"/paper/DOI:{urllib.parse.quote(doi, safe='')}?fields={_FIELDS}"
        )
        if not data or not data.get("paperId"):
            return {"matched": False, "paperId": None}
        # Per protocol: cross-check title; DOI_MISMATCH counts as no-match
        # for this binary signal.
        returned_title = data.get("title") or ""
        if _similarity(expected_title, returned_title) < _TITLE_SIMILARITY_THRESHOLD:
            return {"matched": False, "paperId": None}
        return {"matched": True, "paperId": data["paperId"]}

    def _lookup_by_title(self, title: str, year: int | None) -> dict[str, Any]:
        path = (
            f"/paper/search?query={urllib.parse.quote(title)}"
            f"&limit=5&fields={_FIELDS}"
        )
        data = self._request(path)
        candidates = data.get("data") or []
        best: tuple[float, dict[str, Any]] | None = None
        for cand in candidates:
            sim = _similarity(title, cand.get("title") or "")
            if sim < _TITLE_SIMILARITY_THRESHOLD:
                continue
            # Per protocol: prefer matching year when multiple ≥0.70 results.
            year_match = year is not None and cand.get("year") == year
            score = sim + (0.05 if year_match else 0.0)
            if best is None or score > best[0]:
                best = (score, cand)
        if best is None:
            return {"matched": False, "paperId": None}
        return {"matched": True, "paperId": best[1].get("paperId")}
