"""Multi-source corpus Q&A: search → context → cache → Hermes.

Stub client only — no network. Validates:
- FTS5 finds the right docs across a small corpus
- context_root is deterministic (sorted source roots, then Merkle)
- cache hit on identical question returns same record without calling client
- different question -> different cache_key
"""

from __future__ import annotations

import json
from typing import Iterator

from aborist.document import Document
from aborist.ingest import ingest_source
from aborist.qa import query
from aborist.qa.client import StubClient
from aborist.source import Source
from aborist.store import connect


class FakeSource(Source):
    source_type = "test"

    def __init__(self, docs: list[Document]):
        self.docs = docs

    def iter_documents(self) -> Iterator[Document]:
        yield from self.docs


def _doc(uri: str, content: str) -> Document:
    return Document(uri=uri, content=content, source_type="test", title=uri.rsplit("/", 1)[-1])


# Three docs with distinguishable content so FTS5 can pick winners.
DOCS = [
    _doc(
        "test://anarchism",
        "Anarchism is a political philosophy that opposes the state. " * 12
        + "Mutual aid is central to anarchist theory. " * 8,
    ),
    _doc(
        "test://capitalism",
        "Capitalism is an economic system based on private ownership. " * 12
        + "Market exchange and capital accumulation drive growth. " * 8,
    ),
    _doc(
        "test://anarcho-capitalism",
        "Anarcho-capitalism combines anarchism's opposition to the state with capitalism's private property. " * 12
        + "Murray Rothbard developed many of its core ideas. " * 8,
    ),
]


def test_query_picks_relevant_docs_and_writes_record(tmp_path):
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    # Verbatim quote from DOCS[2] → audit_mode=STRICT.
    client = StubClient(
        answer=(
            'Per the source: '
            '"Anarcho-capitalism combines anarchism\'s opposition to the state '
            'with capitalism\'s private property"'
        )
    )
    result = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="test-model",
        single_db=main_db,
        top_k=3,
    )

    assert result["status"] == "cache_miss_then_written"
    assert result["audit_mode"] == "STRICT"
    assert "Anarcho-capitalism" in result["answer_text"]
    assert len(result["sources"]) >= 1
    assert any("anarcho-capitalism" in s["document_uri"] for s in result["sources"])

    # context_root is deterministic.
    sorted_roots = sorted(s["document_root"] for s in result["sources"])
    if len(sorted_roots) == 1:
        assert result["context_root"] == sorted_roots[0]
    # Repeat call with same question → cache hit, no LLM call.
    n_calls_before = len(client.calls)
    result2 = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="test-model",
        single_db=main_db,
        top_k=3,
    )
    assert result2["status"] == "cache_hit"
    assert result2["cache_key"] == result["cache_key"]
    assert len(client.calls) == n_calls_before  # no new call


def test_query_different_question_different_cache(tmp_path):
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    client = StubClient(answer="answer text")
    r1 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
    )
    r2 = query(
        question="What is capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
    )
    assert r1["cache_key"] != r2["cache_key"]
    assert len(client.calls) == 2  # both missed cache, both called client


def test_query_question_equivalence_class_dedups_cache(tmp_path):
    """Fox 2026-04-29 catch: 'who is batman?', 'who is batman', and
    'who is the batman?' should all hit the same cache. Question_hash
    canonicalizes correctly, but conversation_hash used to hash the
    LITERAL question text in the user message — so each variant got
    its own chash and missed cache. Fix: canonical_question form is
    substituted into the messages list used for conversation_hash,
    while the LLM still receives the verbatim question."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    captured: list = []

    def _capture(messages, **kw):
        captured.append(messages)
        return "stub answer"

    # First variant — populates cache.
    r1 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
    )
    # Second & third variants — must hit the same cache_key.
    r2 = query(
        question="What is anarchism",  # no trailing ?
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
    )
    r3 = query(
        question="what is the anarchism?",  # leading article
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
    )

    assert r1["cache_key"] == r2["cache_key"] == r3["cache_key"]
    assert r1["status"] == "cache_miss_then_written"
    assert r2["status"] == "cache_hit"
    assert r3["status"] == "cache_hit"
    # Only the first call reached the LLM.
    assert len(captured) == 1
    # And it received the verbatim question, not the canonical form.
    user_text = "\n".join(
        m["content"] for m in captured[0] if m["role"] == "user"
    )
    assert "What is anarchism?" in user_text


def test_query_strict_dedup_distinguishes_question_variants(tmp_path):
    """policy['question_dedup']='strict' makes every variant get its
    own cache_key. 'Who is X?' and 'who is the X?' write separate
    records under strict policy."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    strict_policy = dict(query.__globals__["DEFAULT_QUERY_POLICY"])
    strict_policy["question_dedup"] = "strict"

    r1 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="a"),
        model_id="m",
        single_db=main_db,
        policy=strict_policy,
        fidelity="strict",
    )
    r2 = query(
        question="what is anarchism",
        qa_db=qa_db,
        chat_client=StubClient(answer="b"),
        model_id="m",
        single_db=main_db,
        policy=strict_policy,
        fidelity="strict",
    )
    # Different cache_keys; both populated independently.
    assert r1["cache_key"] != r2["cache_key"]
    assert r1["status"] == r2["status"] == "cache_miss_then_written"
    assert r1["lookup_path"] == "miss"


def test_query_equivalence_class_fidelity_falls_back_across_dedup_modes(tmp_path):
    """A record written under equivalence_class policy gets reused by a
    later strict-policy lookup that asks for fidelity='equivalence_class'.
    Verifies the cross-silo fallback: strict ckey misses, alternate
    equivalence_class ckey hits, lookup_path reports the fallback."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    DEFAULT = query.__globals__["DEFAULT_QUERY_POLICY"]
    eq_policy = dict(DEFAULT)
    eq_policy["question_dedup"] = "equivalence_class"
    strict_policy = dict(DEFAULT)
    strict_policy["question_dedup"] = "strict"

    # Agent A writes equivalence-class record for 'what is anarchism?'.
    captured: list = []

    def _capture(messages, **kw):
        captured.append(messages)
        return "stub"

    r1 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
        policy=eq_policy,
    )
    assert r1["status"] == "cache_miss_then_written"

    # Agent B (strict policy, equivalence_class fidelity) asks the same
    # question. Strict ckey misses; the eq_class fallback hits A's record.
    r2 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
        policy=strict_policy,
        fidelity="equivalence_class",
    )
    assert r2["status"] == "cache_hit"
    assert r2["lookup_path"] == "equivalence_class_fallback"
    assert len(captured) == 1  # only A's call reached the LLM


def test_query_strict_fidelity_does_not_fall_back(tmp_path):
    """Audit-grade lookup: strict-fidelity refuses to read records from
    the other dedup mode's silo. Even if equivalence_class has a hit,
    strict fidelity reports cache miss."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    DEFAULT = query.__globals__["DEFAULT_QUERY_POLICY"]
    eq_policy = dict(DEFAULT)
    eq_policy["question_dedup"] = "equivalence_class"
    strict_policy = dict(DEFAULT)
    strict_policy["question_dedup"] = "strict"

    # Agent A writes under equivalence_class.
    query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="a"),
        model_id="m",
        single_db=main_db,
        policy=eq_policy,
    )

    # Agent B (strict policy + strict fidelity) asks same question.
    # Should NOT find A's record; runs LLM fresh.
    captured: list = []

    def _capture(messages, **kw):
        captured.append(messages)
        return "stub"

    r2 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
        policy=strict_policy,
        fidelity="strict",
    )
    assert r2["status"] == "cache_miss_then_written"
    assert r2["lookup_path"] == "miss"
    assert len(captured) == 1


def test_classify_source_role_separates_primary_from_noisy():
    """Direct unit test on the role classifier. JP film-score should be
    noisy_background; JP (film) should be primary; JP franchise should
    be secondary; The Lost World should be sequel; off-topic background.
    Catches the case where peripheral pages with strong title overlap
    used to share the primary slot."""
    from aborist.qa.query import _classify_source_role

    qstems = {"dinosaur", "jurassic", "park", "film"}
    assert _classify_source_role("Jurassic Park (film)", qstems) == "primary_answer_source"
    assert _classify_source_role("Jurassic Park (film score)", qstems) == "noisy_background_source"
    assert _classify_source_role("Jurassic Park video games", qstems) == "noisy_background_source"
    assert _classify_source_role("Jurassic Park (franchise)", qstems) == "secondary_context_source"
    assert _classify_source_role("List of Jurassic Park characters", qstems) == "secondary_context_source"
    assert _classify_source_role("The Lost World: Jurassic Park", qstems) == "sequel_background_source"
    # Off-topic title (no shared stems): falls through to background.
    assert _classify_source_role("Anarchism", qstems) == "background_source"


def test_query_role_weighted_budget_persists_role_on_sources(tmp_path):
    """Each source in the providence record's merkle_proof.sources gains
    a `source_role` field — verifies the role made it into the audit
    trail so an inspector can see which slot a source occupied."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()
    result = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="x"),
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    assert all("source_role" in s for s in result["sources"])


def test_query_persists_run_dag_root_on_record_and_result(tmp_path):
    """Per-run Merkle-DAG fingerprint surfaces on both the result dict
    & the persisted providence_cache row. Recomputing the root from
    the persisted blob matches what was stored."""
    from aborist.qa.dag import verify_run_dag

    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    result = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer='The source: "a political philosophy that opposes the state"'),
        model_id="m",
        single_db=main_db,
    )
    assert "run_dag_root" in result
    assert isinstance(result["run_dag_root"], str)
    assert len(result["run_dag_root"]) == 64  # sha256 hex

    qa_conn = connect(qa_db)
    try:
        row = qa_conn.execute(
            "SELECT run_dag_root, run_dag_blob FROM providence_cache "
            "WHERE cache_key = ?",
            (result["cache_key"],),
        ).fetchone()
    finally:
        qa_conn.close()
    assert row["run_dag_root"] == result["run_dag_root"]
    assert verify_run_dag(row["run_dag_blob"]) is True


def test_query_no_sources_when_empty_corpus(tmp_path):
    main_db = tmp_path / "empty.db"
    qa_db = tmp_path / "qa.db"
    connect(main_db).close()  # creates schema, no docs
    result = query(
        question="What is anything?",
        qa_db=qa_db,
        chat_client=StubClient(),
        model_id="m",
        single_db=main_db,
    )
    assert result["status"] == "no_sources"


def test_query_filter_requires_breadth_for_multi_token_queries(tmp_path):
    """Fox 2026-04-29 catch: 'supermans girlfriend' returned 7-of-8
    unrelated `Girlfriends`-titled articles because title-overlap
    accepted ANY single-token match. The fix tightens both title and
    body filters to require ALL query tokens (≤2-token queries) so a
    doc whose title only matches ONE of the two qtokens doesn't pass.

    Synthetic corpus pins the new behavior:

      - "Lois Lane"        — neither qtoken in title; body has both → keep
      - "Girlfriends"      — only "girlfriend" in title/body; no "superman" → drop
      - "Superman album"   — "superman" in title; body lacks "girlfriend" → drop
    """
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    docs = [
        _doc(
            "test://lois-lane",
            "Lois Lane is a fictional character who works for the Daily Planet. "
            "She is Superman's girlfriend and frequently appears in his stories. "
            * 5,
        ),
        _doc(
            "test://girlfriends-tv",
            "Girlfriends is a sitcom about four women in Los Angeles. " * 10,
        ),
        _doc(
            "test://superman-music",
            "Superman is an album of rock music recorded in Tokyo. " * 10,
        ),
    ]
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(docs))
    finally:
        conn.close()

    result = query(
        question="who is supermans girlfriend?",
        qa_db=qa_db,
        chat_client=StubClient(answer="x"),
        model_id="m",
        single_db=main_db,
        top_k=8,
    )
    src_uris = [s["document_uri"] for s in result["sources"]]
    # Lois Lane MUST be in the result set — it's the only doc with both
    # query tokens in its body.
    assert any("lois-lane" in u for u in src_uris), (
        f"breadth filter regressed: lois-lane not in {src_uris}"
    )


def test_query_filter_one_token_query_still_synonym_expands(tmp_path):
    """1-token queries keep the loose synonym-expanded any-match — pin
    that we didn't over-tighten the single-token case."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    docs = [
        _doc("test://anarchism", "Anarchism is a political philosophy. " * 30),
    ]
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(docs))
    finally:
        conn.close()
    result = query(
        question="anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="x"),
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    assert len(result["sources"]) == 1


def test_query_burn_existing_forces_fresh_inference(tmp_path):
    """Fox 2026-04-29: `make query Q=... BURN=1` busts any matching live
    cache record before lookup so a fresh inference runs.

    Sequence:
      1. First query → cache_miss_then_written, populates cache
      2. Second query, NO burn → cache_hit (no new LLM call)
      3. Third query, BURN=True → cache_miss_then_written (cache busted),
         result reports burned_existing=1
    """
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    client = StubClient(answer="anarchism is a thing")

    r1 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
    )
    assert r1["status"] == "cache_miss_then_written"
    assert r1["burned_existing"] == 0
    n_calls_after_first = len(client.calls)

    r2 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
    )
    assert r2["status"] == "cache_hit"
    assert r2["burned_existing"] == 0
    assert len(client.calls) == n_calls_after_first  # no new LLM call

    r3 = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
        burn_existing=True,
    )
    assert r3["status"] == "cache_miss_then_written"
    assert r3["burned_existing"] == 1
    assert len(client.calls) == n_calls_after_first + 1   # new LLM call after burn


def test_query_burn_existing_writes_audit_event(tmp_path):
    """Each --burn writes a providence_burn audit event so the chain
    records the bust. Verifies one event lands per burn."""
    from aborist.store import connect as _connect

    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()

    client = StubClient(answer="x")
    query(
        question="What is anarchism?",
        qa_db=qa_db, chat_client=client, model_id="m", single_db=main_db,
    )
    qc = _connect(qa_db)
    try:
        burns_before = qc.execute(
            "SELECT COUNT(*) FROM audit_events WHERE event_type='providence_burn'"
        ).fetchone()[0]
    finally:
        qc.close()

    query(
        question="What is anarchism?",
        qa_db=qa_db, chat_client=client, model_id="m", single_db=main_db,
        burn_existing=True,
    )
    qc = _connect(qa_db)
    try:
        burns_after = qc.execute(
            "SELECT COUNT(*) FROM audit_events WHERE event_type='providence_burn'"
        ).fetchone()[0]
    finally:
        qc.close()
    assert burns_after == burns_before + 1


def test_query_burn_existing_with_no_prior_record_is_noop(tmp_path):
    """First-time query with --burn: nothing to burn → burned_existing=0,
    proceeds to fresh inference normally."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()
    r = query(
        question="What is anarchism?",
        qa_db=qa_db, chat_client=StubClient(answer="x"),
        model_id="m", single_db=main_db,
        burn_existing=True,
    )
    assert r["status"] == "cache_miss_then_written"
    assert r["burned_existing"] == 0


def test_query_per_source_cap_prevents_huge_doc_monopoly(tmp_path):
    """Fox 2026-04-29 catch: a top-ranked huge document (e.g.
    List_of_Batman_comics, 80 KB+ bibliography) used to consume the
    entire 60 KB budget at hit #1, dropping every subsequent doc with
    char_budget <= 0. Now each of the top_k hits gets at most
    `max_context_chars / top_k` chars; multiple sources land in
    context even when hit #1 is huge."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"

    # Hit #1 is intentionally huge: every query token AND the most copies
    # so it FTS5-ranks first. Hits #2 and #3 are smaller but still
    # contain the query token.
    bulk_token = "anarchism " * 5000   # ~50 KB after canonicalize
    docs = [
        _doc("test://huge-bibliography", bulk_token),
        _doc("test://anarchism-bio", "Anarchism is a political philosophy. " * 30),
        _doc("test://anarchism-history", "Anarchism's history begins with Proudhon. " * 30),
    ]
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(docs))
    finally:
        conn.close()

    captured = {"messages": None}

    def _capture(messages, **kw):
        captured["messages"] = messages
        return "stub"

    result = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer=_capture),
        model_id="m",
        single_db=main_db,
        top_k=3,
        max_context_chars=60000,
    )
    # All three sources should be in the result, not just the huge one.
    assert len(result["sources"]) >= 2, (
        f"per-source cap broke: only {len(result['sources'])} sources reached "
        f"context (huge doc monopolized again)"
    )
    # Verify the user-turn context contains content from the smaller docs
    # too — not just a 60K slab of the bulk doc.
    user_text = "\n".join(m["content"] for m in captured["messages"] if m["role"] == "user")
    assert "anarchism-bio" in user_text or "anarchism-history" in user_text


def test_query_per_source_cap_respects_top_k(tmp_path):
    """top_k=1 → cap = max_context_chars (legacy behavior preserved when
    operator wants a single large source)."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    big = _doc("test://big", "Anarchism is a political philosophy. " * 2000)
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource([big]))
    finally:
        conn.close()
    result = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="x"),
        model_id="m",
        single_db=main_db,
        top_k=1,
        max_context_chars=60000,
    )
    # Single source allowed up to full budget.
    assert len(result["sources"]) == 1


def test_query_persists_audit_event(tmp_path):
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()
    query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=StubClient(answer="X"),
        model_id="m",
        single_db=main_db,
    )
    # qa.db should now have one providence_query event in its audit chain.
    qc = connect(qa_db)
    try:
        events = qc.execute(
            "SELECT event_type FROM audit_events ORDER BY seq"
        ).fetchall()
    finally:
        qc.close()
    types = [e["event_type"] for e in events]
    assert "providence_query" in types


# ---------------------------------------------------------------------------
# capacity metrics — prompt_chars + answer_chars surface
# ---------------------------------------------------------------------------


def _setup_corpus(tmp_path):
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
    finally:
        conn.close()
    return main_db, qa_db


def test_query_returns_prompt_chars_breakdown(tmp_path):
    """prompt_chars must contain the five expected keys and
    messages_total must equal the sum of system + reminder + user
    message lengths actually sent to the chat client."""
    main_db, qa_db = _setup_corpus(tmp_path)
    client = StubClient(answer="A short answer.")
    result = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
        top_k=3,
    )

    pc = result["prompt_chars"]
    assert set(pc.keys()) == {
        "system_prompt",
        "grounding_reminder",
        "user_question",
        "evidence_or_context",
        "messages_total",
    }
    # System prompt is from DEFAULT_QUERY_POLICY — non-empty.
    assert pc["system_prompt"] > 0
    # Question is exactly the chars passed in.
    assert pc["user_question"] == len("What is anarcho-capitalism?")
    # Evidence/context is the chunk text the model saw — non-trivial.
    assert pc["evidence_or_context"] > 0
    # messages_total equals the sum of message contents the stub saw.
    sent = client.calls[0]["messages"]
    actual_total = sum(len(m["content"]) for m in sent)
    assert pc["messages_total"] == actual_total


def test_query_answer_chars_matches_answer_text(tmp_path):
    main_db, qa_db = _setup_corpus(tmp_path)
    client = StubClient(answer="Exactly twenty-six chars!!")
    result = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
    )
    assert result["answer_chars"] == len(result["answer_text"])


def test_query_cache_hit_also_returns_capacity_metrics(tmp_path):
    """Cache-hit path must populate prompt_chars too — operators
    inspecting cached records still want the capacity breakdown."""
    main_db, qa_db = _setup_corpus(tmp_path)
    client = StubClient(answer="Same answer twice.")
    first = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    assert first["status"] == "cache_miss_then_written"
    second = query(
        question="What is anarcho-capitalism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    assert second["status"] == "cache_hit"
    assert "prompt_chars" in second
    assert second["prompt_chars"]["messages_total"] > 0
    assert second["answer_chars"] > 0


def test_query_evidence_chars_grows_with_topk(tmp_path):
    """Adding more sources to the context budget should increase
    `evidence_or_context`. Sanity check that the metric tracks
    actual context build, not a stale constant."""
    main_db, qa_db = _setup_corpus(tmp_path)

    qa_db_small = tmp_path / "qa_small.db"
    qa_db_big = tmp_path / "qa_big.db"

    small = query(
        question="What is anarchism?",
        qa_db=qa_db_small,
        chat_client=StubClient(answer="X"),
        model_id="m",
        single_db=main_db,
        top_k=1,
        max_context_chars=2000,
    )
    big = query(
        question="What is anarchism?",
        qa_db=qa_db_big,
        chat_client=StubClient(answer="X"),
        model_id="m",
        single_db=main_db,
        top_k=3,
        max_context_chars=20000,
    )
    assert big["prompt_chars"]["evidence_or_context"] >= small["prompt_chars"]["evidence_or_context"]


# ---------------------------------------------------------------------------
# --retrieval-keywords flag (operator-supplied retrieval augmentation)
# ---------------------------------------------------------------------------


def test_retrieval_keywords_does_not_alter_question_to_llm(tmp_path):
    """The LLM must see the original question string, not the
    augmented retrieval query. Cache_key must match the same call
    without retrieval_keywords."""
    main_db, qa_db = _setup_corpus(tmp_path)

    captured = {}

    def _record_answer(messages, **kwargs):
        captured["messages"] = messages
        return "Some answer."

    client = StubClient(answer=_record_answer)
    bare = query(
        question="What is anarchism?",
        qa_db=qa_db,
        chat_client=client,
        model_id="m",
        single_db=main_db,
        top_k=3,
    )

    # Same question, with retrieval keywords. New qa_db so cache lookup
    # under fresh state — both runs are cache-miss.
    qa_db_2 = tmp_path / "qa_with_keywords.db"
    client2 = StubClient(answer=_record_answer)
    augmented = query(
        question="What is anarchism?",
        qa_db=qa_db_2,
        chat_client=client2,
        model_id="m",
        single_db=main_db,
        top_k=3,
        retrieval_keywords="mutual aid theory",
    )

    # User question text is byte-identical between the two runs.
    bare_user_msg = [m["content"] for m in client.calls[0]["messages"]
                     if m["role"] == "user"][-1]
    aug_user_msg = [m["content"] for m in client2.calls[0]["messages"]
                    if m["role"] == "user"][-1]
    # Both end with "Question: What is anarchism?" — the keywords
    # never appear inside the LLM-facing question segment (they
    # only flow into FTS5 retrieval + title-filter token sets).
    assert "What is anarchism?" in bare_user_msg
    assert "What is anarchism?" in aug_user_msg
    assert "mutual aid theory" not in aug_user_msg.split("Question:")[-1]
    # System prompt unchanged across runs.
    bare_sys = [m["content"] for m in client.calls[0]["messages"]
                if m["role"] == "system"][0]
    aug_sys = [m["content"] for m in client2.calls[0]["messages"]
               if m["role"] == "system"][0]
    assert bare_sys == aug_sys


def test_retrieval_keywords_changes_retrieved_sources(tmp_path):
    """Different keywords surface different docs, even with the
    same question text. Same-keywords across runs is reproducible."""
    main_db, qa_db = _setup_corpus(tmp_path)
    qa_db_a = tmp_path / "a.db"
    qa_db_b = tmp_path / "b.db"
    a = query(
        question="define the system",
        qa_db=qa_db_a,
        chat_client=StubClient(answer="X"),
        model_id="m",
        single_db=main_db,
        top_k=3,
        retrieval_keywords="anarcho-capitalism rothbard",
    )
    b = query(
        question="define the system",
        qa_db=qa_db_b,
        chat_client=StubClient(answer="X"),
        model_id="m",
        single_db=main_db,
        top_k=3,
        retrieval_keywords="capitalism markets",
    )
    a_uris = sorted(s["document_uri"] for s in a["sources"])
    b_uris = sorted(s["document_uri"] for s in b["sources"])
    # The two retrieval-keyword sets pull different topical sources.
    # (Sanity: same question without keywords is the no-keyword
    # baseline; the assertion is that adding distinct keywords does
    # something to the result.)
    assert a_uris != b_uris or set(a_uris) != set(b_uris), (
        f"keywords should affect retrieval; got identical sources {a_uris}"
    )


# ---------------------------------------------------------------------------
# phrase-pattern retrieval route (allusion / verbatim sequence boost)
# ---------------------------------------------------------------------------


def test_question_phrases_returns_sliding_n_grams_no_stopword_strip():
    """`_question_phrases` extracts verbatim n-token windows. Function
    words are kept — diagnostic value of an allusion is the EXACT
    sequence ('always been at war' >> 'always war')."""
    from aborist.qa.query import _question_phrases
    out = _question_phrases("has oceania always been at war with east asia", n=4)
    # 9-token query, 4-gram window → 6 phrases, all preserved verbatim
    # (lowercase) and deduped.
    assert "has oceania always been" in out
    assert "always been at war" in out
    assert "war with east asia" in out
    # Stopwords ARE present — that's the design, not a bug.
    assert any("at" in p.split() for p in out)


def test_question_phrases_n_5_yields_five_token_phrases():
    """5-grams trade recall for precision; 'oceania always been at war'
    is a much stronger Orwell signal than 'always been at war' alone."""
    from aborist.qa.query import _question_phrases
    out = _question_phrases("has oceania always been at war with east asia", n=5)
    assert "oceania always been at war" in out
    assert "always been at war with" in out
    # Too short for 6-grams of just "war with east asia" alone.
    assert all(len(p.split()) == 5 for p in out)


def test_question_phrases_skips_when_question_shorter_than_n():
    """`who is X?` is too short to yield 4-grams. Empty output is the
    expected behavior (the body BM25 + title routes still cover it)."""
    from aborist.qa.query import _question_phrases
    assert _question_phrases("who is X?", n=4) == []
    assert _question_phrases("", n=4) == []


def test_question_phrases_drops_all_short_token_phrases():
    """A window of all 1-3 char tokens is boilerplate ('to be or not')
    — drops to avoid over-matching. The skip rule fires only when ALL
    tokens in the window are <4 chars."""
    from aborist.qa.query import _question_phrases
    # All ≤3-char tokens — drop.
    assert _question_phrases("to be or not", n=4) == []
    # Mixed: at least one ≥4-char token → keep.
    out = _question_phrases("to be or maybe", n=4)
    assert out == ["to be or maybe"]


def test_question_phrases_lowercases_and_dedupes():
    """Output is lowercase, deduped on string equality. Same
    sequence in different cases collapses to one phrase."""
    from aborist.qa.query import _question_phrases
    out = _question_phrases("Always been at war Always been at war", n=4)
    # Repeated sequence appears only once in the output.
    assert out.count("always been at war") == 1


def test_search_phrases_returns_empty_on_no_phrases(tmp_path):
    """Defensive: empty phrase list yields no rows, no exceptions."""
    from aborist.qa.query import _search_phrases
    main_db = tmp_path / "corpus.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
        rows = _search_phrases(conn, [], 10)
    finally:
        conn.close()
    assert rows == []


def test_search_phrases_skips_phrases_with_double_quotes(tmp_path):
    """Adversarial input safety: phrases containing `"` would break
    the FTS5 quoted-phrase syntax. The function silently drops them."""
    from aborist.qa.query import _search_phrases
    main_db = tmp_path / "corpus.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource(DOCS))
        # All phrases contain quotes — function returns empty.
        rows = _search_phrases(conn, ['has "embedded" quote', 'also "bad"'], 10)
    finally:
        conn.close()
    assert rows == []


def test_phrase_match_surfaces_topical_doc(tmp_path):
    """End-to-end: a query whose phrase verbatim-matches one doc's
    body should surface that doc even when title tokens don't
    overlap. Closes the 2026-05-01 Orwell case where the 1984
    article had zero token overlap with the question's title-tokens
    but matched the verbatim phrase 'always been at war'."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        # Two docs: one explicitly contains the diagnostic phrase
        # but its title doesn't overlap question tokens; the other
        # is a generic geography article.
        ingest_source(conn, FakeSource([
            _doc(
                "test://orwell-stub",
                # Title-irrelevant to the question; body contains
                # the diagnostic 5-gram.
                "The novel narrates that Oceania always been at war with "
                "Eastasia though the alliances had previously rotated. " * 6
            ),
            _doc(
                "test://geography-stub",
                "Geographic descriptions of regions called Oceania and East "
                "Asia. " * 12,
            ),
        ]))
    finally:
        conn.close()

    result = query(
        question="has oceania always been at war with east asia",
        qa_db=qa_db,
        chat_client=StubClient(answer="An answer."),
        model_id="m",
        single_db=main_db,
        top_k=5,
    )
    uris = [s["document_uri"] for s in result["sources"]]
    # Both docs surface; the phrase-route ensures the orwell-stub
    # doc isn't filtered out by the title-relevance gate.
    assert "test://orwell-stub" in uris


def test_phrase_route_skipped_when_question_shorter_than_min_n():
    """False-positive guard: a 4-token geography question lacks enough
    tokens to trigger the n=5/n=6 phrase route. Short conventional
    queries route through body-BM25 + title-LIKE only — phrase routing
    is structurally biased toward longer allusion-shape questions."""
    from aborist.qa.query import _question_phrases
    # 4 tokens after extraction → empty 5-gram and 6-gram outputs.
    assert _question_phrases("oceania east asia geography", n=5) == []
    assert _question_phrases("oceania east asia geography", n=6) == []
    # 5 tokens → exactly one 5-gram, zero 6-grams.
    out_5 = _question_phrases("oceania population east asia trade", n=5)
    assert len(out_5) == 1
    assert out_5[0] == "oceania population east asia trade"
    out_6 = _question_phrases("oceania population east asia trade", n=6)
    assert out_6 == []


def test_phrase_route_does_not_hijack_literal_geography_query(tmp_path):
    """Critical false-positive guard. A literal geography query about
    Oceania + East Asia must NOT pull in an Orwell-flavored stub doc
    just because both contain geographic tokens. The phrase route
    only fires for verbatim 5+ token sequences from the question;
    a different geography question shouldn't accidentally invoke it."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource([
            _doc(
                "test://orwell-stub",
                # Body has the diagnostic Orwell 5-gram, but the title
                # is title-irrelevant to a geography query.
                "The novel narrates that Oceania always been at war with "
                "Eastasia though the alliances had previously rotated. " * 6
            ),
            _doc(
                "test://geography-stub",
                "Geographic descriptions of regions called Oceania and East "
                "Asia. Topics: trade, population, climate, demographics. " * 12,
            ),
        ]))
    finally:
        conn.close()

    # Literal geography query — short, no Orwell phrase.
    result = query(
        question="oceania east asia geography",
        qa_db=qa_db,
        chat_client=StubClient(answer="A geography answer."),
        model_id="m",
        single_db=main_db,
        top_k=5,
    )
    uris = [s["document_uri"] for s in result["sources"]]
    # Geography stub should be present (literal query, literal source).
    assert "test://geography-stub" in uris
    # Orwell stub should NOT have been surfaced via phrase route on
    # a literal-geography query — the phrase route only activates on
    # verbatim 5+ token sequences from the question, and "oceania
    # east asia geography" is too short to produce any.


def test_filter_keeps_phrase_match_root_with_no_title_overlap():
    """Direct unit test for accept-path 4: a hit whose title shares
    zero content tokens with the question, but whose document_root is
    in `phrase_match_roots`, must pass the filter."""
    from aborist.qa.query import _Hit, _filter_by_title_relevance
    hits = [
        # Title shares NO content tokens with the question. Without
        # accept-path 4 (phrase_match_roots), it would be dropped.
        _Hit(
            document_root="bbb",
            document_uri="t://nineteen-eighty-four",
            title="Nineteen Eighty-Four",
            score=70.0,
            shard_path="x",
            chunk_idx=0,
        ),
    ]
    # Without phrase_match_roots, the hit is dropped (title-relevance
    # filter has no accept path that fires).
    kept_without = _filter_by_title_relevance(
        hits,
        "has oceania always been at war with east asia",
    )
    # Filter falls back to top-N when nothing accepts; accept the
    # fallback as 'kept' here too — what we care about is whether
    # accept-path 4 is the path firing when phrase_match_roots is set.
    kept_with = _filter_by_title_relevance(
        hits,
        "has oceania always been at war with east asia",
        phrase_match_roots={"bbb"},
    )
    kept_roots = {h.document_root for h in kept_with}
    assert "bbb" in kept_roots, (
        "phrase_match_roots accept-path 4 should keep titles with no "
        "token overlap when their body verbatim-matched a question phrase"
    )
    # And the fallback path doesn't suddenly fail when phrase_match_roots
    # is present — the keep is via accept-path 4, not via the fallback.
    _ = kept_without  # documents that fallback may also keep, but via different path


# ---------------------------------------------------------------------------
# _search_titles long-query regression (2026-05-02)
# ---------------------------------------------------------------------------


def _build_titled_corpus(tmp_path) -> tuple:
    """Test corpus shaped to exercise _search_titles. Returns
    (single_db_path, qa_db_path)."""
    main_db = tmp_path / "corpus.db"
    qa_db = tmp_path / "qa.db"
    conn = connect(main_db)
    try:
        ingest_source(conn, FakeSource([
            _doc(
                "test://neurotech",
                # Long body so retrieval has something to grade.
                "Neurotechnology covers brain-computer interfaces. " * 30
                + "Transcranial magnetic stimulation reads brain "
                "activity. " * 30
            ),
            _doc(
                "test://decoy",
                "Generic technology overview text. " * 60,
            ),
        ]))
    finally:
        conn.close()
    return main_db, qa_db


def test_unit_search_titles_handles_long_question_without_crash(tmp_path):
    """Regression: 2026-05-02 fox-surfaced SQLite error on a
    31-content-token query. The earlier SQL chained N CASE WHEN
    expressions joined by `+`, blowing past SQLite's default
    expression-tree depth limit (1000). The simplified SQL —
    OR-chain + ORDER BY LENGTH(title) — handles arbitrary token
    counts. Cap at MAX_TITLE_LIKE_TOKENS bounds the OR-chain
    growth defensively."""
    from aborist.qa.query import _search_titles

    main_db, _qa_db = _build_titled_corpus(tmp_path)
    conn = connect(main_db)
    try:
        # 50-token query — well past anything that would have hit
        # the 1000-depth cap, but the bounded SQL handles it.
        long_tokens = [
            "what", "technology", "are", "currently", "or", "soon",
            "available", "which", "may", "enable", "one", "person",
            "to", "reconstruct", "and", "understand", "some", "portion",
            "of", "another", "persons", "thoughts", "or", "ideas",
            "without", "speaking", "or", "sign", "language",
            "and", "make", "a", "summary", "we", "can", "use",
            "to", "improve", "communication", "between", "humans",
            "across", "the", "globe", "even", "if", "they", "speak",
            "different", "tongues",
        ]
        # Should not raise sqlite3.OperationalError; should return
        # SOME rows (the test corpus has 2 docs, one will match).
        rows = _search_titles(conn, long_tokens, 32)
        assert isinstance(rows, list)
    finally:
        conn.close()


def test_unit_search_titles_handles_zero_tokens():
    """Defensive: zero tokens → empty list, no SQL executed."""
    from aborist.qa.query import _search_titles

    rows = _search_titles(None, [], 32)
    assert rows == []


def test_unit_search_titles_caps_or_chain_at_max_tokens(tmp_path):
    """The OR-chain is bounded at MAX_TITLE_LIKE_TOKENS so
    extreme-length queries (100+ tokens) don't cascade SQL
    expression-tree growth. Beyond the cap, additional tokens
    are dropped at the SQL layer; the post-filter in the caller
    sees them all and does the actual filtering."""
    from aborist.qa.query import _search_titles

    main_db, _qa_db = _build_titled_corpus(tmp_path)
    conn = connect(main_db)
    try:
        # 200-token pathological query — should not crash.
        many_tokens = [f"tok{i}" for i in range(200)]
        rows = _search_titles(conn, many_tokens, 32)
        assert isinstance(rows, list)
    finally:
        conn.close()


def test_integration_query_completes_on_long_question(tmp_path):
    """End-to-end: a long discursive question with many content
    tokens runs through query() to completion (StubClient — no
    LLM) without the SQLite expression-tree overflow that the
    2026-05-02 regression triggered."""
    main_db, qa_db = _build_titled_corpus(tmp_path)
    long_q = (
        "what technology are currently or soon available which may "
        "enable one person to reconstruct and understand some or a "
        "portion of another persons thoughts or ideas without "
        "speaking or sign language"
    )
    result = query(
        question=long_q,
        qa_db=qa_db,
        chat_client=StubClient(answer="A short stub answer."),
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    # Pre-fix this raised sqlite3.OperationalError before reaching
    # the LLM call. Post-fix: query() returns a normal result dict.
    assert result["status"] in (
        "cache_miss_then_written",
        "cache_hit",
        "no_sources",
    )
    # Status field present means the pipeline completed.
    assert "audit_mode" in result or result["status"] == "no_sources"


def test_functional_long_question_returns_sources(tmp_path):
    """Functional check: the long-question retrieval path actually
    surfaces relevant sources (not just empty). The corpus has a
    'neurotech' doc whose title and body share tokens with the
    long question; it should appear in the top-K despite the
    question's length."""
    main_db, qa_db = _build_titled_corpus(tmp_path)
    long_q = (
        "what technology may enable one person to reconstruct and "
        "understand another persons thoughts or ideas without "
        "speaking or sign language using neurotechnology and "
        "transcranial brain reading"
    )
    result = query(
        question=long_q,
        qa_db=qa_db,
        chat_client=StubClient(answer="answer"),
        model_id="m",
        single_db=main_db,
        top_k=3,
    )
    # Pipeline completed; if any sources were found at all, the
    # neurotech doc (richest body match) should be among them.
    if result["status"] != "no_sources":
        uris = [s["document_uri"] for s in result.get("sources") or []]
        # Neurotech doc has matching tokens; should appear.
        assert any("neurotech" in u for u in uris), (
            f"neurotech doc missing from sources {uris}"
        )
