"""G0 / CTI — claim-lattice-pointer mode (quote-by-pointer).

Covers the pointer-line protocol Hermes emits, plus the runtime
two-layer id mapping (prompt-facing E1/E2/…, content-addressed
E########). Tests the deterministic verifier + 9-stage run-DAG +
schema migration round-trip.
"""

from __future__ import annotations

import sqlite3
from pathlib import Path

import pytest

from aborist.qa.dag import build_run_dag, verify_run_dag
from aborist.qa.evidence import (
    build_evidence_map,
    evidence_map_by_evidence_id,
    evidence_map_by_pointer_id,
    evidence_map_root,
    render_claim_lattice,
    render_evidence_map,
)
from aborist.qa.keys import governance_policy_hash
from aborist.qa.parse_claims import parse_pointer_claims
from aborist.qa.runner import DEFAULT_POLICY
from aborist.qa.query import DEFAULT_QUERY_POLICY
from aborist.qa.verify import (
    ANSWER_MODES,
    DEFAULT_ANSWER_MODE,
    verify_claim_lattice,
)


# ---------- evidence map ----------------------------------------------------


def _sample_chunks() -> list[dict]:
    """Three chunks across two sources, two source roles."""
    return [
        {
            "source_root": "a" * 64,
            "document_uri": "https://example.org/jp",
            "title": "Jurassic Park (film)",
            "chunk_idx": 0,
            "chunk_root": "11" * 32,
            "span": "Tyrannosaurus rex appears in the climactic scene.",
            "source_role": "primary_answer_source",
        },
        {
            "source_root": "a" * 64,
            "document_uri": "https://example.org/jp",
            "title": "Jurassic Park (film)",
            "chunk_idx": 1,
            "chunk_root": "22" * 32,
            "span": "Velociraptors are shown attacking workers.",
            "source_role": "primary_answer_source",
        },
        {
            "source_root": "b" * 64,
            "document_uri": "https://example.org/jp-game",
            "title": "Jurassic Park (video game)",
            "chunk_idx": 0,
            "chunk_root": "33" * 32,
            "span": "The Sega Genesis adaptation was released in 1993.",
            "source_role": "noisy_background_source",
        },
    ]


def test_pointer_id_is_sequential():
    em = build_evidence_map(_sample_chunks())
    assert [e.pointer_id for e in em] == ["E1", "E2", "E3"]


def test_evidence_id_is_content_addressed_and_stable():
    chunks = _sample_chunks()
    map1 = build_evidence_map(chunks)
    map2 = build_evidence_map(chunks)
    # Content-addressed evidence_ids stay stable across runs.
    assert [e.evidence_id for e in map1] == [e.evidence_id for e in map2]
    assert all(e.evidence_id.startswith("E") and len(e.evidence_id) == 9
               for e in map1)
    # Pointer ids are deterministic too (position-based).
    assert [e.pointer_id for e in map1] == [e.pointer_id for e in map2]


def test_pointer_id_is_run_dependent_when_chunks_reorder():
    chunks = _sample_chunks()
    map1 = build_evidence_map(chunks)
    map2 = build_evidence_map(list(reversed(chunks)))
    # Chunk B's content-addressed evidence_id stays stable...
    assert {e.chunk_root: e.evidence_id for e in map1} == \
           {e.chunk_root: e.evidence_id for e in map2}
    # ...but its pointer_id moves with position.
    assert {e.chunk_root: e.pointer_id for e in map1} != \
           {e.chunk_root: e.pointer_id for e in map2}


def test_evidence_map_root_order_independent():
    chunks = _sample_chunks()
    r1 = evidence_map_root(build_evidence_map(chunks))
    r2 = evidence_map_root(build_evidence_map(list(reversed(chunks))))
    assert r1 == r2
    assert r1 != "00" * 32


def test_render_evidence_map_uses_pointer_ids():
    em = build_evidence_map(_sample_chunks())
    text = render_evidence_map(em)
    for e in em:
        assert f"=== {e.pointer_id} " in text
        # Hex evidence_id must NOT leak into the prompt.
        assert e.evidence_id not in text
        assert e.span in text


# ---------- parser ---------------------------------------------------------


def test_parse_pointer_claims_basic():
    out = parse_pointer_claims(
        "Trex appears in the film. [E1]\n"
        "Velociraptors stalk workers. [E2,E3]\n"
    )
    assert len(out) == 2
    assert out[0].claim_text == "Trex appears in the film."
    assert out[0].pointer_ids == ["E1"]
    assert out[0].parse_status == "PARSED"
    assert out[1].pointer_ids == ["E2", "E3"]


def test_parse_handles_bullet_markers():
    out = parse_pointer_claims("- Trex appears. [E1]\n")
    assert out[0].claim_text == "Trex appears."


def test_parse_handles_whitespace_inside_brackets():
    out = parse_pointer_claims("Trex appears. [E1, E2 ,E3]\n")
    assert out[0].pointer_ids == ["E1", "E2", "E3"]


def test_parse_skips_blank_lines():
    out = parse_pointer_claims("\n   \nTrex appears. [E1]\n\n")
    assert len(out) == 1


def test_parse_no_evidence_pointer_status():
    out = parse_pointer_claims("Trex appears in the film.\n")
    assert out[0].parse_status == "NO_EVIDENCE_POINTER"
    assert out[0].pointer_ids == []
    assert out[0].claim_text == "Trex appears in the film."


def test_parse_inline_tag_preserves_prose():
    out = parse_pointer_claims("Trex [E1] appears in the film.\n")
    # Tag stripped; prose around it stays.
    assert "Trex" in out[0].claim_text
    assert "appears in the film" in out[0].claim_text
    assert out[0].pointer_ids == ["E1"]


# ---------- verifier: happy path ------------------------------------------


def test_verify_strict_when_all_resolve():
    em = build_evidence_map(_sample_chunks())
    # Claim text must textually overlap the cited span (G+: 6th hard
    # check — claim content tokens must appear in the cited evidence).
    # AND at least one cited source's title must share a content
    # token with the claim (Rule 8, post-2026-05-02): so we keep
    # "film" in each claim to anchor against the
    # `Jurassic Park (film)` title.
    # E1 span = "Tyrannosaurus rex appears in the climactic scene."
    # E2 span = "Velociraptors are shown attacking workers."
    answer = (
        "Tyrannosaurus rex appears in the film. [E1]\n"
        "Velociraptors are shown attacking workers in the film. [E2]\n"
    )
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "STRICT"
    assert v["verifier_method"] == "claim_lattice"
    assert v["n_quotes"] == 2
    assert v["n_verified"] == 2
    assert v["violations"] == []
    # Evidence_id pairs use the content-addressed form for run-DAG.
    pairs = v["evidence_id_pairs"]
    assert pairs[0] == [em[0].evidence_id]
    assert pairs[1] == [em[1].evidence_id]


def test_verify_renders_with_literal_spans_from_runtime():
    em = build_evidence_map(_sample_chunks())
    answer = "Tyrannosaurus rex is in the film. [E1]\n"
    v = verify_claim_lattice(answer, em)
    # Renderer pulls literal span from runtime, not from model output.
    assert em[0].span in v["rendered_text"]
    # New pointer format: [E1 | <title> | <chunk_prefix>: "<excerpt>"]
    # Closes the visual provenance gap where E# could be confused
    # with the source-list 1-indexed rank.
    assert "[E1 |" in v["rendered_text"]
    assert "Jurassic Park (film)" in v["rendered_text"]


def test_verify_passes_multi_pointer_claims():
    em = build_evidence_map(_sample_chunks())
    # Title is "Jurassic Park (film)" — claim must share a content
    # token (Rule 8). Add "film" so the claim anchors against the
    # source title.
    answer = "Tyrannosaurus and velociraptors appear in the film. [E1,E2]\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "STRICT"
    assert v["n_quotes"] == 2
    assert v["n_verified"] == 2


# ---------- verifier: failure modes ---------------------------------------


def test_unknown_pointer_id_is_violation():
    em = build_evidence_map(_sample_chunks())
    answer = "Imaginary claim. [E99]\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "UNGROUNDED"
    assert v["n_verified"] == 0
    assert any(viol["kind"] == "UNKNOWN_EVIDENCE_ID" for viol in v["violations"])
    assert v["claim_statuses"][0]["status"] == "UNKNOWN_EVIDENCE_ID"


def test_source_role_blocked_violation():
    em = build_evidence_map(_sample_chunks())
    # E3 → noisy_background_source, disallowed by default.
    answer = "Sega adaptation exists. [E3]\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "UNGROUNDED"
    assert any(viol["kind"] == "SOURCE_ROLE_BLOCKED" for viol in v["violations"])
    assert v["claim_statuses"][0]["status"] == "SOURCE_ROLE_BLOCKED"


def test_double_quote_in_claim_text_no_longer_blocks_verification():
    """Pre-2026-04-30: any `"` in claim text was a hard MANUAL_QUOTE_VIOLATION
    that blocked every pointer on the claim — even when the claim was
    factually correct and source-grounded. Hermes-3-8B paraphrases prose
    but copies named-quoted phrases verbatim from source (e.g.
    `"Constitution State"` from a Connecticut chunk), so the rule was
    rejecting good claims for cosmetic punctuation. Removed in favor
    of the coverage-threshold check (Rule 5) and pointer cap (Rule 6).

    The claim below would previously have failed with MANUAL_QUOTE_VIOLATION;
    now it stands or falls on whether the cited evidence actually supports
    it — same as any quote-free claim.
    """
    em = build_evidence_map(_sample_chunks())
    answer = 'The "T-rex" appears. [E1]\n'
    v = verify_claim_lattice(answer, em)
    # No MANUAL_QUOTE_VIOLATION emitted anywhere.
    assert not any(viol["kind"] == "MANUAL_QUOTE_VIOLATION"
                   for viol in v["violations"])
    assert v["claim_statuses"][0]["status"] != "MANUAL_QUOTE_VIOLATION"


def test_curly_quotes_also_no_longer_block():
    """Mirrors the ASCII-quote case for curly typographic quotes."""
    em = build_evidence_map(_sample_chunks())
    answer = "The “T-rex” appears. [E1]\n"
    v = verify_claim_lattice(answer, em)
    assert not any(viol["kind"] == "MANUAL_QUOTE_VIOLATION"
                   for viol in v["violations"])


def test_no_evidence_pointer_downgrades():
    em = build_evidence_map(_sample_chunks())
    answer = (
        "Trex appears in the film.\n"  # no pointer
        "Velociraptors are shown. [E2]\n"
    )
    # Opt out of bare-name check (Velociraptors stems to 1 content token
    # after spotlight stopword strip); test exercises NO_EVIDENCE_POINTER.
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    # Mixed: one verified, one NO_EVIDENCE_POINTER → HYBRID.
    assert v["audit_mode"] == "HYBRID"
    assert any(viol["kind"] == "NO_EVIDENCE_POINTER" for viol in v["violations"])
    statuses = {c["status"] for c in v["claim_statuses"]}
    assert "NO_EVIDENCE_POINTER" in statuses
    assert "EVIDENCE_LINKED" in statuses


def test_partial_resolution_yields_hybrid():
    em = build_evidence_map(_sample_chunks())
    answer = (
        "Tyrannosaurus rex appears. [E1]\n"
        "Made-up. [E99]\n"
    )
    # Test exercises mixed-pointer-resolution behavior; bare-name check
    # is orthogonal so opt out.
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    assert v["audit_mode"] == "HYBRID"
    assert v["n_verified"] == 1
    assert v["n_quotes"] == 2


def test_partial_pointer_within_claim_yields_partial():
    em = build_evidence_map(_sample_chunks())
    answer = "Tyrannosaurus rex appears. [E1,E99]\n"
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    assert v["audit_mode"] == "HYBRID"
    assert v["claim_statuses"][0]["status"] == "EVIDENCE_LINKED_PARTIAL"
    # Renderer keeps only resolved ids — new pointer format
    # `[E1 | <title> | <chunk_prefix>: ...]` since 2026-05-01.
    assert "[E1 |" in v["rendered_text"]
    assert "E99" not in v["rendered_text"]


def test_empty_response_yields_ungrounded():
    em = build_evidence_map(_sample_chunks())
    v = verify_claim_lattice("", em)
    assert v["audit_mode"] == "UNGROUNDED"
    assert v["n_verified"] == 0
    assert v["claim_statuses"] == []
    assert v["evidence_id_pairs"] == []


def test_non_pointer_response_yields_ungrounded():
    em = build_evidence_map(_sample_chunks())
    # Model wrote prose without any pointer tags.
    v = verify_claim_lattice(
        "I don't know based on the provided sources.", em
    )
    assert v["audit_mode"] == "UNGROUNDED"
    assert v["n_verified"] == 0
    # Each non-empty line without a tag becomes a NO_EVIDENCE_POINTER claim.
    assert v["claim_statuses"][0]["status"] == "NO_EVIDENCE_POINTER"


def test_orphan_tag_with_no_text_yields_schema_invalid():
    em = build_evidence_map(_sample_chunks())
    answer = "[E1]\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "UNGROUNDED"
    assert v["claim_statuses"][0]["status"] == "SCHEMA_INVALID"


# ---------- policy & cache aliasing ---------------------------------------


def test_default_answer_mode_is_quote():
    assert DEFAULT_POLICY["answer_mode"] == "quote"
    assert DEFAULT_QUERY_POLICY["answer_mode"] == "quote"
    assert DEFAULT_ANSWER_MODE == "quote"
    assert "claim_lattice_pointer" in ANSWER_MODES
    assert "quote" in ANSWER_MODES


def test_governance_hash_differs_between_modes():
    p_quote = dict(DEFAULT_POLICY, answer_mode="quote")
    p_lattice = dict(DEFAULT_POLICY, answer_mode="claim_lattice_pointer")
    assert governance_policy_hash(p_quote) != governance_policy_hash(p_lattice)


# ---------- run-DAG -------------------------------------------------------


def test_run_dag_quote_mode_is_seven_stages():
    common = dict(
        question_hash="11" * 32,
        sources=[{"document_root": "aa" * 32, "source_role": "primary_answer_source"}],
        context_root="bb" * 32,
        conversation_hash="cc" * 32,
        answer_text="hello",
        audit_mode="STRICT",
        verifier_method="quote",
        n_quotes=1,
        n_verified=1,
        claim_statuses=[],
        lookup_path="miss",
    )
    dag = build_run_dag(**common)
    stages = [n["stage"] for n in dag["nodes"]]
    assert stages == [
        "question", "retrieval", "context", "prompt",
        "answer", "verify", "final_label",
    ]
    assert verify_run_dag(dag) is True


def test_run_dag_pointer_mode_is_nine_stages():
    common = dict(
        question_hash="11" * 32,
        sources=[{"document_root": "aa" * 32, "source_role": "primary_answer_source"}],
        context_root="bb" * 32,
        conversation_hash="cc" * 32,
        answer_text="rendered prose",
        audit_mode="STRICT",
        verifier_method="claim_lattice",
        n_quotes=1,
        n_verified=1,
        claim_statuses=[],
        lookup_path="miss",
        evidence_map_root="dd" * 32,
        answer_mode="claim_lattice_pointer",
        violations=[],
        raw_answer_text="Claim. [E1]",
        parsed_lattice=[{"claim_text": "Claim.", "evidence_ids": ["E12345678"]}],
        rendered_text="rendered prose",
    )
    dag = build_run_dag(**common)
    stages = [n["stage"] for n in dag["nodes"]]
    assert stages == [
        "question", "retrieval", "evidence_map", "prompt",
        "raw_answer", "parsed_claim_lattice", "verify", "render",
        "final_label",
    ]
    # No 'context' or single 'answer' nodes in pointer mode.
    assert "context" not in stages
    assert "answer" not in stages
    assert verify_run_dag(dag) is True


def test_run_dag_root_changes_when_evidence_map_changes():
    base = dict(
        question_hash="11" * 32,
        sources=[{"document_root": "aa" * 32, "source_role": "primary_answer_source"}],
        context_root="bb" * 32,
        conversation_hash="cc" * 32,
        answer_text="hello",
        audit_mode="STRICT",
        verifier_method="claim_lattice",
        n_quotes=1,
        n_verified=1,
        claim_statuses=[],
        lookup_path="miss",
        answer_mode="claim_lattice_pointer",
        violations=[],
        raw_answer_text="raw",
        parsed_lattice=[],
        rendered_text="hello",
    )
    a = build_run_dag(**base, evidence_map_root="dd" * 32)
    b = build_run_dag(**base, evidence_map_root="ee" * 32)
    assert a["root"] != b["root"]


def test_run_dag_root_changes_when_parsed_lattice_changes():
    base = dict(
        question_hash="11" * 32,
        sources=[{"document_root": "aa" * 32, "source_role": "primary_answer_source"}],
        context_root="bb" * 32,
        conversation_hash="cc" * 32,
        answer_text="hello",
        audit_mode="STRICT",
        verifier_method="claim_lattice",
        n_quotes=1,
        n_verified=1,
        claim_statuses=[],
        lookup_path="miss",
        evidence_map_root="dd" * 32,
        answer_mode="claim_lattice_pointer",
        violations=[],
        raw_answer_text="raw",
        rendered_text="hello",
    )
    a = build_run_dag(**base, parsed_lattice=[{"claim_text": "x", "evidence_ids": ["E12345678"]}])
    b = build_run_dag(**base, parsed_lattice=[{"claim_text": "y", "evidence_ids": ["E12345678"]}])
    assert a["root"] != b["root"]


# ---------- schema / migration --------------------------------------------


def test_verifier_method_check_accepts_claim_lattice(tmp_path: Path):
    """Fresh shards include 'claim_lattice' in the verifier_method CHECK."""
    from aborist.store import connect

    db = tmp_path / "fresh.db"
    conn = connect(db)
    try:
        ddl = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='table' AND name='providence_cache'"
        ).fetchone()[0]
        assert "'claim_lattice'" in ddl
    finally:
        conn.close()


def test_legacy_shard_migrates_in_place(tmp_path: Path):
    """An old shard with the pre-claim_lattice CHECK gets rebuilt on open
    AND keeps run_dag_root / run_dag_blob populated."""
    from aborist.store import connect

    db = tmp_path / "legacy.db"
    c = sqlite3.connect(db)
    c.executescript(
        """
        PRAGMA journal_mode=WAL;
        CREATE TABLE providence_cache (
            cache_key                TEXT PRIMARY KEY,
            source_root              TEXT NOT NULL,
            document_uri             TEXT NOT NULL,
            question_hash            TEXT NOT NULL,
            question_text            TEXT NOT NULL,
            answer_text              TEXT NOT NULL,
            merkle_proof             TEXT NOT NULL,
            model_profile_hash       TEXT NOT NULL,
            conversation_hash        TEXT NOT NULL,
            governance_policy_hash   TEXT NOT NULL,
            schema_version           TEXT NOT NULL,
            canonicalization_version TEXT NOT NULL,
            chunking_version         TEXT NOT NULL,
            falsification_state      TEXT NOT NULL DEFAULT 'live'
                CHECK (falsification_state IN ('live','failed','stale','quarantined')),
            chain                    TEXT NOT NULL DEFAULT 'private'
                CHECK (chain IN ('private','public')),
            audit_event_hash         TEXT,
            created_at               INTEGER NOT NULL,
            last_hit_at              INTEGER,
            hit_count                INTEGER NOT NULL DEFAULT 0,
            audit_mode               TEXT NOT NULL DEFAULT 'UNGROUNDED'
                CHECK (audit_mode IN ('STRICT','HYBRID','UNGROUNDED')),
            n_quotes                 INTEGER NOT NULL DEFAULT 0,
            n_verified               INTEGER NOT NULL DEFAULT 0,
            unverified_quotes        TEXT,
            verifier_method          TEXT NOT NULL DEFAULT 'none'
                CHECK (verifier_method IN ('quote','span','entity','paraphrase','none')),
            run_dag_root             TEXT,
            run_dag_blob             TEXT
        );
        INSERT INTO providence_cache
          (cache_key, source_root, document_uri, question_hash, question_text,
           answer_text, merkle_proof, model_profile_hash, conversation_hash,
           governance_policy_hash, schema_version, canonicalization_version,
           chunking_version, audit_event_hash, created_at,
           run_dag_root, run_dag_blob)
        VALUES
          ('ck1','sr1','u','qh','q','a','{}','mh','ch','gh','v','c','c1','eh',
           1, 'rdr', '{"root":"rdr","nodes":[]}');
        """
    )
    c.commit()
    c.close()

    conn = connect(db)
    try:
        ddl = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='table' AND name='providence_cache'"
        ).fetchone()[0]
        assert "'claim_lattice'" in ddl
        row = conn.execute(
            "SELECT run_dag_root, run_dag_blob FROM providence_cache WHERE cache_key=?",
            ("ck1",),
        ).fetchone()
        assert row[0] == "rdr"
        assert row[1] == '{"root":"rdr","nodes":[]}'
    finally:
        conn.close()


# ---------- soft-signal isolation -----------------------------------------


def test_no_entailment_field_in_verify_output():
    """Hard verifier must not assert semantic claims."""
    em = build_evidence_map(_sample_chunks())
    answer = "Trex appears. [E1]\n"
    v = verify_claim_lattice(answer, em)
    forbidden = {"entailment", "entailed", "completeness",
                 "predicate_compatibility", "scope_match"}
    assert not (set(v) & forbidden)
    for c in v["claim_statuses"]:
        assert not (set(c) & forbidden)


# ---------- evidence_map index helpers ------------------------------------


def test_evidence_map_by_pointer_id_index():
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    assert set(by_pid) == {"E1", "E2", "E3"}
    assert by_pid["E1"] is em[0]


def test_evidence_map_by_evidence_id_index():
    em = build_evidence_map(_sample_chunks())
    by_eid = evidence_map_by_evidence_id(em)
    assert set(by_eid) == {e.evidence_id for e in em}
    # Ids are 9 chars: "E" + 8 hex.
    assert all(len(eid) == 9 for eid in by_eid)


def test_render_claim_lattice_with_pointer_ids():
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [
            {"text": "trex appears", "pointer_ids": ["E1"]},
            {"text": "raptors attack", "pointer_ids": ["E2"]},
        ],
        by_pid,
    )
    assert "Tyrannosaurus rex appears" in text
    assert "Velociraptors are shown" in text
    # New format includes source title + chunk_root prefix to avoid
    # E# / source-rank visual confusion (2026-05-01).
    assert "[E1 |" in text
    assert "[E2 |" in text
    assert "Jurassic Park (film)" in text


def test_render_claim_lattice_marks_unknown():
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "fake", "pointer_ids": ["E99"]}],
        by_pid,
    )
    assert "E99: ?" in text


def test_render_pointer_format_includes_source_title_and_chunk_prefix():
    """Pointer format: `[E# | <title> | <chunk_prefix>: "<excerpt>"]`.
    Closes the 2026-05-01 visual provenance gap on the Orwell run
    where `[E5: ...]` paired with a source list whose `[5]` was an
    unrelated document — operator could mis-attribute the citation.
    Now title + chunk_root prefix are inline so each pointer is
    self-identifying."""
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "trex appears", "pointer_ids": ["E1"]}],
        by_pid,
    )
    # Title appears inline with the pointer.
    assert "[E1 | Jurassic Park (film) | " in text
    # chunk_root prefix is the first 8 hex chars of em[0].chunk_root.
    chunk_prefix = em[0].chunk_root[:8]
    assert chunk_prefix in text
    # Excerpt still rendered after the prefix.
    assert "Tyrannosaurus rex appears" in text


def test_render_pointer_format_falls_back_to_uri_tail_when_title_none():
    """If EvidenceObject.title is None (rare — source had no title
    metadata), the renderer falls back to the URI tail. Defensive,
    keeps the pointer self-identifying even when title indexing
    fails for a chunk."""
    chunks = [{
        "source_root": "f" * 64,
        "document_uri": "https://example.org/some-article-slug",
        "title": None,  # explicit absence
        "chunk_idx": 0,
        "chunk_root": "ab" * 32,
        "span": "Some content here.",
        "source_role": "primary_answer_source",
    }]
    em = build_evidence_map(chunks)
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "Some content", "pointer_ids": ["E1"]}],
        by_pid,
    )
    # URI tail used as label.
    assert "[E1 | some-article-slug | " in text


def test_render_pointer_format_no_visual_collision_with_source_rank():
    """Regression for the Orwell case: the substring `[E5:` (which
    would visually collide with a source-rank `[5]` in older
    renders) does not appear in the new output. The format change
    isn't optional — it's enforced."""
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "trex appears", "pointer_ids": ["E1"]}],
        by_pid,
    )
    # Old format was `[E1: "..."]`; new format is `[E1 | ...: "..."]`.
    # The colon-after-id must be gone.
    assert "[E1:" not in text
    # New format always has the pipe separator.
    assert "[E1 |" in text


# ---------- spotlight excerpt (G0.2) --------------------------------------


def _long_chunk_with_buried_term() -> list[dict]:
    """Single chunk much longer than the spotlight window with the
    target term ('Brachiosaurus') buried mid-span."""
    intro = "Jurassic Park is a 1993 American science fiction thriller film. " * 30
    middle = (
        "Among the dinosaurs depicted on screen, Brachiosaurus is shown "
        "lifting its long neck to eat from tall trees in an early scene."
    )
    tail = "Production took place across multiple studios. " * 30
    long_span = intro + middle + tail
    return [{
        "source_root": "a" * 64,
        "document_uri": "https://example.org/jp",
        "title": "Jurassic Park (film)",
        "chunk_idx": 0,
        "chunk_root": "11" * 32,
        "span": long_span,
        "source_role": "primary_answer_source",
    }]


def test_spotlight_finds_buried_topic_token():
    em = build_evidence_map(_long_chunk_with_buried_term())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "Brachiosaurus appears in the film.",
          "pointer_ids": ["E1"]}],
        by_pid,
    )
    # The displayed excerpt must contain the actual brachiosaurus
    # mention from mid-span, not the leading article-intro sentence.
    assert "Brachiosaurus is shown lifting" in text


def test_spotlight_falls_back_when_no_token_matches():
    em = build_evidence_map(_long_chunk_with_buried_term())
    by_pid = evidence_map_by_pointer_id(em)
    # Claim contains no content tokens that appear in the span.
    text = render_claim_lattice(
        [{"text": "Quokka spotted yesterday morning.",
          "pointer_ids": ["E1"]}],
        by_pid,
    )
    # Falls back to leading window with trailing ellipsis.
    assert text.rstrip().endswith('..."]')
    # And the leading content of the span is shown.
    assert "Jurassic Park is a 1993" in text


def test_spotlight_no_truncation_for_short_spans():
    em = build_evidence_map(_sample_chunks())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "trex", "pointer_ids": ["E1"]}],
        by_pid,
    )
    # Sample span is <100 chars, fits in window; rendered intact.
    assert em[0].span in text


def test_spotlight_window_size_overridable():
    em = build_evidence_map(_long_chunk_with_buried_term())
    by_pid = evidence_map_by_pointer_id(em)
    narrow = render_claim_lattice(
        [{"text": "Brachiosaurus appears in the film.",
          "pointer_ids": ["E1"]}],
        by_pid,
        window=80,
    )
    wide = render_claim_lattice(
        [{"text": "Brachiosaurus appears in the film.",
          "pointer_ids": ["E1"]}],
        by_pid,
        window=400,
    )
    # Both windows hit the buried mention.
    assert "Brachiosaurus" in narrow
    assert "Brachiosaurus" in wide
    # Wider window carries more surrounding prose.
    assert len(wide) > len(narrow)


def test_spotlight_picks_longest_token_for_specificity():
    """Claim has both a generic ('film') and a specific ('Brachiosaurus')
    content token. Spotlight should pick the specific one — sorted by
    length desc — so the displayed excerpt anchors on what the claim
    is actually *about*."""
    em = build_evidence_map(_long_chunk_with_buried_term())
    by_pid = evidence_map_by_pointer_id(em)
    text = render_claim_lattice(
        [{"text": "Brachiosaurus appears in the film.",
          "pointer_ids": ["E1"]}],
        by_pid,
    )
    # Window is centered on Brachiosaurus, not on the first occurrence
    # of "film" (which is in the intro at offset 0).
    assert "Brachiosaurus is shown" in text


def test_spotlight_deterministic():
    em = build_evidence_map(_long_chunk_with_buried_term())
    by_pid = evidence_map_by_pointer_id(em)
    args = (
        [{"text": "Brachiosaurus appears in the film.",
          "pointer_ids": ["E1"]}],
        by_pid,
    )
    a = render_claim_lattice(*args)
    b = render_claim_lattice(*args)
    assert a == b


# ---------- G0.1: per-chunk evidence granularity --------------------------


def test_per_chunk_evidence_map_query_path(tmp_path):
    """A single source with three chunks should produce three evidence
    objects in the prompt-facing map — not one whole-doc entry."""
    from typing import Iterator
    from aborist.document import Document
    from aborist.ingest import ingest_source
    from aborist.qa.client import StubClient
    from aborist.qa.query import DEFAULT_QUERY_POLICY, query
    from aborist.source import Source
    from aborist.store import connect

    class FakeSource(Source):
        source_type = "test"
        def __init__(self, docs): self.docs = docs
        def iter_documents(self) -> Iterator[Document]:
            yield from self.docs

    # Long enough to chunk into multiple pieces under tok-512-v1.
    txt = (
        "Paragraph one introduces Brachiosaurus as a long-necked sauropod. "
        * 80
        + "Paragraph two introduces Velociraptor as a small carnivorous theropod. "
        * 80
        + "Paragraph three introduces Tyrannosaurus rex as the apex predator. "
        * 80
    )
    shard = tmp_path / "shard.db"
    qa_db = tmp_path / "qa.db"
    src_conn = connect(shard)
    try:
        ingest_source(src_conn, FakeSource([
            Document(uri="t://chunked", content=txt, source_type="test", title="Dinosaurs")
        ]))
    finally:
        src_conn.close()

    # Stub answer that hits all three pointer ids — proves the
    # evidence map exposed >1 chunk to the model. Each claim names
    # "dinosaur" so Rule 8 (title-relevance) anchors against the
    # source title "Dinosaurs".
    stub = StubClient(answer=(
        "Brachiosaurus is a sauropod dinosaur. [E1]\n"
        "Velociraptor is a theropod dinosaur. [E2]\n"
        "T-rex is the apex dinosaur. [E3]\n"
    ))
    # Override the per-source chunk cap so all three chunks of this
    # single test source surface as E1/E2/E3. Production default caps
    # at 2 chunks per source to keep the evidence catalog small for
    # broad-descriptive questions; this test is asserting the per-chunk
    # mapping itself, so it opts into the unbounded path.
    policy = dict(
        DEFAULT_QUERY_POLICY,
        answer_mode="claim_lattice_pointer",
        claim_lattice_max_chunks_per_source=8,
    )
    result = query(
        question="dinosaur paragraphs",
        qa_db=qa_db,
        chat_client=stub,
        model_id="stub",
        single_db=shard,
        top_k=4,
        over_fetch=8,
        max_context_chars=60000,
        policy=policy,
    )
    # All three pointers resolved → STRICT.
    assert result["audit_mode"] == "STRICT"
    assert result["n_quotes"] == 3
    assert result["n_verified"] == 3


def test_chunk_query_relevance_scores_by_overlap():
    from aborist.qa.query import _chunk_query_relevance

    qstem = {"brachiosaurus", "film"}
    # Many distinct + many mentions wins.
    a = _chunk_query_relevance(
        "Brachiosaurus appears in the film. The film features Brachiosaurus.",
        qstem,
    )
    # Only one of the two query tokens present.
    b = _chunk_query_relevance(
        "The film opens with credits and a logo.", qstem,
    )
    # Neither token present.
    c = _chunk_query_relevance(
        "Production took place across multiple studios.", qstem,
    )
    assert a > b > c
    assert a[0] == 2 and a[1] >= 3
    assert b[0] == 1
    assert c == (0, 0)


def test_chunk_query_relevance_stem_aware():
    from aborist.qa.query import _chunk_query_relevance

    # Query has the plural "dinosaurs"; chunk has the singular "dinosaur".
    # The stem strip in _body_count_with_stem should still match.
    qstem = {"dinosaur"}  # already stemmed
    score = _chunk_query_relevance(
        "Each dinosaur was modeled with animatronics.", qstem
    )
    assert score[0] == 1


def test_query_orders_chunks_by_relevance_g03(tmp_path):
    """End-to-end: a source whose query-relevant chunk is in the MIDDLE
    of the document — G0.3 must promote it to E1 so a model that
    lazy-anchors on early pointers still cites the relevant chunk."""
    from typing import Iterator
    from aborist.document import Document
    from aborist.ingest import ingest_source
    from aborist.qa.client import StubClient
    from aborist.qa.evidence import build_evidence_map
    from aborist.qa.query import (
        DEFAULT_QUERY_POLICY, _load_doc_chunks, _chunk_query_relevance,
    )
    from aborist.qa.query import _stem_token_for_match, _title_query_tokens
    from aborist.source import Source
    from aborist.store import connect

    class FakeSource(Source):
        source_type = "test"
        def __init__(self, docs): self.docs = docs
        def iter_documents(self) -> Iterator[Document]:
            yield from self.docs

    # Three chunks worth of content; the middle one mentions the
    # rare term. tok-512-v1 chunks ~500 tokens (~2000 chars) each.
    irrelevant = "Production notes and scheduling details. " * 80
    relevant = "The Brachiosaurus appears in a key scene. " * 80
    more_irrelevant = "Cast and crew biographies follow. " * 80
    txt = irrelevant + relevant + more_irrelevant

    shard = tmp_path / "shard.db"
    qa_db = tmp_path / "qa.db"
    src_conn = connect(shard)
    try:
        ingest_source(src_conn, FakeSource([
            Document(uri="t://reorder", content=txt, source_type="test", title="r")
        ]))
        doc_root = src_conn.execute(
            "SELECT document_root FROM documents WHERE document_uri='t://reorder'"
        ).fetchone()["document_root"]
    finally:
        src_conn.close()

    chunks = _load_doc_chunks(str(shard), doc_root)
    assert chunks is not None
    assert len(chunks) >= 3

    # Mirror what query() does: rank chunks by query-relevance.
    qstem = {
        _stem_token_for_match(t.lower())
        for t in _title_query_tokens("Brachiosaurus appearance")
    }
    scored = []
    for idx, leaf, span in chunks:
        d, t = _chunk_query_relevance(span, qstem)
        scored.append((idx, leaf, span, d, t))
    scored.sort(key=lambda r: (-r[3], -r[4], r[0]))

    # The first chunk after sorting MUST be the one containing
    # Brachiosaurus — even though it's not chunk_idx=0 in doc order.
    assert "Brachiosaurus" in scored[0][2]
    # Document-order first chunk should NOT be the top-ranked one
    # (otherwise the test fixture isn't exercising the reorder).
    assert scored[0][0] != 0


def test_lazy_anchor_ratio_one_when_all_share_pointer():
    em = build_evidence_map(_sample_chunks())
    # All three claims must textually overlap E1's span
    # ("Tyrannosaurus rex appears in the climactic scene.").
    answer = (
        "Tyrannosaurus rex appears. [E1]\n"
        "Tyrannosaurus rex roars. [E1]\n"
        "Tyrannosaurus rex broke out. [E1]\n"
    )
    # Test exercises the smell ratio counter; bare-name check is
    # orthogonal so opt out so all three claims reach EVIDENCE_LINKED
    # and populate pointer_distribution.
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    assert v["lazy_anchor_ratio"] == 1.0
    assert v["pointer_id_distribution"] == {"E1": 3}


def test_lazy_anchor_ratio_one_third_when_diversified():
    em = build_evidence_map(_sample_chunks())
    # Each claim's content tokens must overlap its cited span.
    answer = (
        "Tyrannosaurus rex appears. [E1]\n"
        "Velociraptors stalk workers. [E2]\n"
        "The climactic Tyrannosaurus moment. [E1]\n"
    )
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    # 2 cite E1, 1 cites E2 → max share 2/3.
    assert v["pointer_id_distribution"] == {"E1": 2, "E2": 1}
    assert abs(v["lazy_anchor_ratio"] - 2 / 3) < 1e-9


def test_lazy_anchor_ratio_zero_when_no_verified_pairs():
    em = build_evidence_map(_sample_chunks())
    # Unknown pointer → 0 verified pairs.
    answer = "Imaginary claim. [E99]\n"
    v = verify_claim_lattice(answer, em)
    assert v["lazy_anchor_ratio"] == 0.0
    assert v["pointer_id_distribution"] == {}


def test_citation_mismatch_when_claim_token_absent_from_evidence():
    """6th hard check: cited evidence span must textually contain at
    least one content token from the claim text."""
    em = build_evidence_map(_sample_chunks())
    # E1's span is "Tyrannosaurus rex appears in the climactic scene."
    # — the claim says "Brachiosaurus" (not present in the span).
    answer = "Brachiosaurus appears in the film. [E1]\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "UNGROUNDED"
    assert any(viol["kind"] == "CITATION_MISMATCH" for viol in v["violations"])
    assert v["claim_statuses"][0]["status"] == "CITATION_MISMATCH"
    assert v["n_verified"] == 0


def test_citation_mismatch_partial_when_one_pointer_overlaps():
    em = build_evidence_map(_sample_chunks())
    # E1 contains "Tyrannosaurus rex"; E2 contains "Velociraptors".
    # Claim mentions Tyrannosaurus → E1 overlaps, E2 does not.
    answer = "Tyrannosaurus rex appears. [E1,E2]\n"
    # Test exercises CITATION_MISMATCH on a single pointer; bare-name
    # check is orthogonal so opt out.
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    assert v["audit_mode"] == "HYBRID"
    assert v["n_verified"] == 1
    assert v["claim_statuses"][0]["status"] == "EVIDENCE_LINKED_PARTIAL"
    # E1 resolved, E2 mismatched.
    assert any(
        viol["kind"] == "CITATION_MISMATCH" and viol["pointer_id"] == "E2"
        for viol in v["violations"]
    )


def test_pure_stopword_claim_passes_overlap_check_vacuously():
    """A claim composed entirely of stopwords has no content tokens to
    check; the overlap rule returns True vacuously and other hard
    checks own that case."""
    em = build_evidence_map(_sample_chunks())
    # Tyrannosaurus rex appears... is in E1's span; the claim text
    # below has effectively no content tokens after stopword filter
    # (just function words), so the overlap check shouldn't fail it.
    answer = "It was so. [E1]\n"
    # Bare-name check would reject this for 0 content tokens; opt out
    # since this test is specifically about the overlap rule's
    # vacuous-True branch for claims with no content tokens.
    v = verify_claim_lattice(answer, em, min_claim_content_tokens=0)
    # Overlap check passed; claim is short but valid.
    assert v["claim_statuses"][0]["status"] == "EVIDENCE_LINKED"


def test_lazy_anchor_signals_not_in_run_dag_payload():
    """Sidecar invariant: pointer_id_distribution and lazy_anchor_ratio
    must NOT enter build_run_dag's verify_payload — they're soft
    signals recoverable from claim_statuses, and threading them in
    would make run_dag_root depend on the model's anchoring habits."""
    import inspect as _inspect
    from aborist.qa.dag import build_run_dag
    sig = _inspect.signature(build_run_dag)
    params = set(sig.parameters)
    assert "pointer_id_distribution" not in params
    assert "lazy_anchor_ratio" not in params


def test_per_chunk_evidence_map_uses_distinct_chunk_roots(tmp_path):
    """Chunk-level evidence_ids must be content-addressed per chunk —
    different chunks of the same source produce different evidence_ids
    even though they share source_root."""
    from typing import Iterator
    from aborist.document import Document
    from aborist.ingest import ingest_source
    from aborist.qa.query import _load_doc_chunks
    from aborist.source import Source
    from aborist.store import connect

    class FakeSource(Source):
        source_type = "test"
        def __init__(self, docs): self.docs = docs
        def iter_documents(self) -> Iterator[Document]:
            yield from self.docs

    txt = "para alpha. " * 200 + "para beta. " * 200 + "para gamma. " * 200
    shard = tmp_path / "shard.db"
    conn = connect(shard)
    try:
        ingest_source(conn, FakeSource([
            Document(uri="t://multi", content=txt, source_type="test", title="m")
        ]))
        doc_root = conn.execute(
            "SELECT document_root FROM documents WHERE document_uri='t://multi'"
        ).fetchone()["document_root"]
    finally:
        conn.close()

    chunks = _load_doc_chunks(str(shard), doc_root)
    assert chunks is not None
    assert len(chunks) > 1, "test fixture must produce multiple chunks"

    # Build a chunks_for_map dict like query() does, then verify each
    # chunk gets a distinct content-addressed evidence_id.
    cfm = [
        {
            "source_root": doc_root,
            "document_uri": "t://multi",
            "title": "m",
            "chunk_idx": idx,
            "chunk_root": leaf_hash,
            "span": span,
            "source_role": "primary_answer_source",
        }
        for idx, leaf_hash, span in chunks
    ]
    em = build_evidence_map(cfm)
    eids = [e.evidence_id for e in em]
    pids = [e.pointer_id for e in em]
    assert len(set(eids)) == len(eids), "evidence_ids must be unique per chunk"
    # Pointer ids are sequential.
    assert pids == [f"E{i + 1}" for i in range(len(em))]
    # All share the same source_root but different chunk_roots.
    assert len({e.source_root for e in em}) == 1
    assert len({e.chunk_root for e in em}) == len(em)


# ---------- claim-count ceiling --------------------------------------------


def test_too_many_claims_demotes_pointer_mode_to_hybrid():
    """Pointer-mode mirror of test_verify_json_too_many_claims_demotes_to_hybrid.
    13 well-formed pointer claims (default cap 12) trips TOO_MANY_CLAIMS
    even when each individually verifies. Demotes STRICT to HYBRID so
    'tell me all there is to know about X' runaway is operator-visible.
    """
    chunks = [
        {
            "source_root": "f" * 64,
            "document_uri": "https://example.org/runaway",
            "title": "Runaway Source",
            "chunk_idx": i,
            "chunk_root": f"{i:02x}" * 32,
            "span": f"Fact {i} appears in the runaway source span.",
            "source_role": "primary_answer_source",
        }
        for i in range(13)
    ]
    em = build_evidence_map(chunks)
    answer = "\n".join(
        f"Fact {i} appears in the runaway source span. [E{i + 1}]"
        for i in range(13)
    ) + "\n"
    v = verify_claim_lattice(answer, em)
    assert v["audit_mode"] == "HYBRID", \
        f"expected HYBRID (TOO_MANY_CLAIMS demote), got {v['audit_mode']}"
    assert any(vio["kind"] == "TOO_MANY_CLAIMS" for vio in v["violations"])
    # The cap doesn't truncate — every claim still verifies.
    assert v["n_verified"] == 13, \
        f"all 13 should still verify; got {v['n_verified']}"
