"""Tests for the #000052 §3.2 relevance shadow scaffold.

Mirrors `tests/test_nli_shadow.py`'s discipline: pure-Python parts +
graceful degradation when the ``[nli]`` extra is absent. The real
model path is exercised opportunistically (skipped when deps missing).
"""
from __future__ import annotations

import json
from pathlib import Path

import pytest

from arborist.qa.relevance import (
    ShadowRelevance, ShadowRelevanceResult, load_manifest,
    shadow_check_question_answer, shadow_check_claim_source,
)
from arborist.qa.relevance.shadow import _resolve_relevance_index


# --- manifest --------------------------------------------------------------

def test_manifest_has_required_fields():
    m = load_manifest()
    for key in ("relevance_model_version", "hf_repo", "license", "max_length",
                "score_shape", "alternates", "demote_below_score",
                "demote_below_score_comment"):
        assert key in m, key
    # demote_below_score MUST be null until bench-set (§7 #18→#27 lesson)
    assert m["demote_below_score"] is None


def test_manifest_is_valid_json_file():
    p = Path(__file__).resolve().parents[1] / "arborist" / "qa" / "relevance" / "manifest.json"
    json.loads(p.read_text())  # raises on malformed


# --- label-index resolution ------------------------------------------------

@pytest.mark.parametrize("id2label,expect", [
    ({0: "LABEL_0"}, 0),                                         # single-output reranker (typical)
    ({0: "irrelevant", 1: "relevant"}, 1),                       # 2-class with "relevant" label
    ({0: "negative", 1: "positive"}, 1),                          # 2-class with "positive" label
    ({0: "LABEL_0", 1: "LABEL_1"}, 1),                           # fallback to "label_1" substring
    ({0: "score_0", 1: "score_1"}, 0),                           # no relevant/positive → fallback to 0
])
def test_resolve_relevance_index(id2label, expect):
    assert _resolve_relevance_index(id2label) == expect


# --- ShadowRelevance contract ----------------------------------------------

def test_shadowrelevance_construction_never_raises_and_starts_unavailable():
    r = ShadowRelevance()
    assert r.available is False
    assert r.model_version == load_manifest()["relevance_model_version"]
    assert r.demote_below_score is None      # no hardcoded threshold (§7 #18→#27 discipline)
    assert r.device_pref in ("auto", "cpu", "cuda")
    assert r.batch_size >= 1
    assert r.backend is None
    # _ensure_loaded never raises even when the extra is missing
    r._ensure_loaded()
    assert r.available in (True, False)


def test_shadow_check_question_answer_returns_result_and_degrades_gracefully():
    res = shadow_check_question_answer("who painted the mona lisa?",
                                       "Leonardo da Vinci painted the Mona Lisa.")
    assert isinstance(res, ShadowRelevanceResult)
    assert res.pair_kind == "question_answer"
    assert res.would_demote in (True, False)
    d = res.as_dict()
    assert set(d) >= {"available", "relevance_score", "would_demote",
                      "demote_below_score", "model_version", "pair_kind", "reason"}
    if not res.available:
        assert res.would_demote is False
        assert "deps_missing" in res.reason or "load_failed" in res.reason


def test_shadow_check_claim_source_returns_result_and_degrades_gracefully():
    res = shadow_check_claim_source("Leonardo da Vinci painted the Mona Lisa.",
                                    "The Mona Lisa is a half-length portrait painting by Italian artist Leonardo da Vinci.")
    assert isinstance(res, ShadowRelevanceResult)
    assert res.pair_kind == "claim_source"


def test_no_hardcoded_threshold_means_would_demote_stays_False():
    # the §7 #18→#27 discipline: demote_below_score must be null until
    # a shadow sweep sets it. The contract: would_demote=False until then.
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed — would_demote-False path only reachable when loaded")
    # any pair the model scores should still produce would_demote=False
    # because demote_below_score is None at scaffold time
    res = r.check_question_answer("capital of france?", "Paris.")
    assert res.would_demote is False
    assert res.demote_below_score is None


def test_empty_input_when_available_is_no_demote():
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed")
    assert r.check_question_answer("", "non-empty answer").reason == "empty_input"
    assert r.check_question_answer("non-empty question", "").reason == "empty_input"


def test_zionist_entity_field_case_when_available():
    """The motivating case (#000052 §1): a garbled claim about 'phrase
    used as the entity' against a 'Zionist entity' source. The
    relevance score for the (claim, source) pair should be LOWER than
    for a well-aligned (claim, source) pair — that is the signal the
    veto would act on if the threshold were set."""
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed")
    # off-topic: a claim about the abstract noun 'entity' against a source about Israel
    off = r.check_claim_source(
        "the phrase 'Zionist entity' is sometimes used as the entity, referring to the State of Israel",
        "Wikipedia article 'Phrase' — In grammar, a phrase is a group of words functioning as a single unit in the syntax of a sentence.",
    )
    on = r.check_claim_source(
        "the phrase 'Zionist entity' is sometimes used as a pejorative for the State of Israel",
        "Zionist entity () is a phrase sometimes used by Arabs and Muslims as a pejorative for the State of Israel.",
    )
    # both available
    assert off.available and on.available
    # on-topic should score strictly higher than off-topic — that's the discriminator
    assert on.relevance_score > off.relevance_score


# --- broader §3.2 coverage (round-2 candidate-bench, 2026-05-13) ----------
# Added after extending the size spectrum 30MB→560MB (#000052 §3.2.1
# round-2): more tests covering pair-kind-specific behavior, batch
# correctness, and the new manifest entries.

def test_manifest_round2_primary_is_bge_reranker_large():
    m = load_manifest()
    assert m["relevance_model_version"] == "relevance-shadow-v1-bge-reranker-large"
    assert m["hf_repo"] == "BAAI/bge-reranker-large"
    # the round-2 candidate-bench block is recorded
    assert "candidate_bench_results_2026_05_13_round2" in m
    cb = m["candidate_bench_results_2026_05_13_round2"]
    assert cb["rank_by_sep_margin"][0]["model"] == "bge-reranker-large"
    # capacity-floor finding is documented
    floor = cb["rank_by_sep_margin"][-1]
    assert floor["model"] == "ms-marco-MiniLM-L-2-v2"
    assert floor["margin"] < 0  # NOT clean-separable
    # findings block exists with the key science
    assert "findings" in cb
    for key in ("biggest_is_best_within_a_family", "but_not_across_families",
                "deeper_is_not_better_within_MS_MARCO_MiniLM",
                "capacity_floor_between_L_2_and_L_4"):
        assert key in cb["findings"]


def test_manifest_alternates_cover_size_spectrum():
    m = load_manifest()
    sizes = sorted(a["approx_mb"] for a in m["alternates"])
    # the alternates must span the spectrum from ≤50MB (small) to ≥280MB (large)
    assert sizes[0] <= 50, f"smallest alternate is {sizes[0]}MB — needs a ≤50MB datapoint"
    assert sizes[-1] >= 280, f"largest alternate is {sizes[-1]}MB — needs a ≥280MB datapoint"


def test_pair_kind_recorded_separately_for_qa_vs_claim_source():
    # The result records WHICH method produced the score — same model,
    # same primitive, but the bookkeeping must distinguish the two
    # for downstream telemetry / governance hashing.
    r = ShadowRelevance()
    res_qa = r.check_question_answer("who painted the mona lisa?",
                                     "Leonardo da Vinci painted the Mona Lisa.")
    res_cs = r.check_claim_source("Leonardo da Vinci painted the Mona Lisa.",
                                  "The Mona Lisa is a portrait by Leonardo da Vinci.")
    assert res_qa.pair_kind == "question_answer"
    assert res_cs.pair_kind == "claim_source"


def test_batch_score_preserves_input_order():
    """_score_batch returns scores in input order (any padding/batching
    must not reorder). This is load-bearing for downstream bench
    scripts that zip scores back onto input records."""
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed")
    pairs = [
        ("very short", "very short"),
        ("query about the painting Mona Lisa by Leonardo da Vinci in the 16th century",
         "Leonardo da Vinci painted the Mona Lisa around 1503–1519, an Italian Renaissance portrait."),
        ("a", "b"),
    ]
    a = r._score_batch(pairs)
    # scoring each pair individually should give the same answers (modulo
    # padding noise, which for fp32 should be 0)
    individual = [r._score_batch([p])[0] for p in pairs]
    assert len(a) == 3 == len(individual)
    for batched, single in zip(a, individual):
        # padding can introduce tiny numerical drift on fp16/fp32; allow
        # 0.05 logit slack (well below any threshold we'd ever set)
        assert abs(batched - single) < 0.05, f"batched {batched} vs single {single}"


def test_empty_string_query_or_document_returns_empty_input():
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed")
    assert r.check_question_answer("", "some answer").reason == "empty_input"
    assert r.check_claim_source("a claim", "").reason == "empty_input"
    # whitespace-only too
    assert r.check_question_answer("   ", "answer").reason == "empty_input"


def test_score_sign_distinguishes_on_topic_from_off_topic():
    """Sanity: on the Zionist field case (the motivating example),
    the on-topic (claim, source) pair MUST score strictly higher than
    the off-topic one. This is the load-bearing discrimination — if
    this assertion fails on the manifest's primary, the whole §3.2
    approach is invalidated and we walk back."""
    r = ShadowRelevance()
    r._ensure_loaded()
    if not r.available:
        pytest.skip("[nli] extra not installed")
    on = r.check_claim_source(
        "the phrase 'Zionist entity' is sometimes used as a pejorative for the State of Israel",
        "Zionist entity () is a phrase sometimes used by Arabs and Muslims as a pejorative for the State of Israel.",
    )
    off = r.check_claim_source(
        "the phrase 'Zionist entity' is sometimes used as the entity, referring to the State of Israel",
        "In grammar, a phrase is a group of words functioning as a single unit in the syntax of a sentence.",
    )
    assert on.available and off.available
    assert on.relevance_score > off.relevance_score


def test_demote_below_score_remains_null_until_bench_sets_it():
    """Load-bearing invariant from #000049 §7 #18→#27: the demote
    threshold MUST be set by a real-traffic shadow sweep, not by a
    candidate-bench number or literature default. If anyone sets
    demote_below_score in the manifest before step 2 lands, this
    test fires."""
    m = load_manifest()
    assert m["demote_below_score"] is None, \
        "demote_below_score must be null until §3.2.2 step 2 sets it from a real-traffic shadow sweep"
    # the round-2 candidate-bench DOES record candidate θ values, but
    # explicitly notes they are not the runtime threshold
    cb = m["candidate_bench_results_2026_05_13_round2"]
    assert "note" in cb and "CANDIDATE-BENCH ONLY" in cb["note"]
