"""Second-pass verification ("counter run") over every generated digest document.

A synthesis pass can hallucinate a plausible-sounding quote, figure, or
theoretical connection even when its own prompt explicitly forbids it (see the
"No speculation" section of prompts/political.md, and similar language in the
other two prompts). Asking the same model "did you actually see this" in a
fresh call, with the source context in front of it again, catches a real
fraction of that which the generation prompt's instructions alone do not. This
is that second call — the final filter before a document is written to
output/, not a replacement for the instructions in the generation prompts.

Deliberately standalone rather than importing from llm_client.py: the two
modules would otherwise import each other (llm_client would need this module
to filter its own output; this module needs llm_client's Ollama-calling and
fallback-document helpers), and untangling that into a third shared module was
more churn than duplicating about a dozen lines of a plain HTTP POST.

Fails safe in the direction of showing LESS to the user, never more. Three
distinct failure modes, three distinct responses:
  - This pass cannot run at all (Ollama unreachable a second time, unparseable
    verdict) -> keep the original document, but mark it `unverified` rather
    than silently passing it through unchecked. Blanking the section entirely
    here would make a transient failure in THIS pass as damaging as the whole
    digest being down, which is a worse trade than a visibly-unverified
    document.
  - Specific windows/narration are flagged ungrounded -> drop just those, keep
    the rest.
  - Every window in a document is flagged -> withhold the section with an
    honest placeholder, the same shape generate_section() already uses when
    the LLM host doesn't respond at all.
"""

from __future__ import annotations

import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path

import requests

LOG = logging.getLogger(__name__)

PROMPT_PATH = Path(__file__).parent / "prompts" / "counter_run.md"


def _now_iso():
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def _withheld_document(document):
    section = document.get("section", "")
    return {
        "generated_at": _now_iso(),
        "detail_level": document.get("detail_level"),
        "section": section,
        "windows": [
            {
                "id": f"{section}-withheld",
                "title": f"{str(section).title()} — withheld pending verification",
                "kind": "text",
                "content": (
                    "Nothing in this run's draft for this section could be verified "
                    "against its sources, so it was withheld rather than shown "
                    "unverified."
                ),
            }
        ],
        "narration": "",
        "withheld": True,
    }


def _call_ollama(prompt, temperature):
    host = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
    # A separate override, not a reuse of OLLAMA_MODEL by force: verification is a
    # different task from synthesis, and a household running a second, larger model
    # for it (or the same one) should be able to say so without the two being coupled.
    model = os.environ.get("COUNTER_RUN_MODEL") or os.environ.get("OLLAMA_MODEL", "qwen2.5:14b-instruct")
    timeout = float(os.environ.get("OLLAMA_TIMEOUT", "600"))

    payload = {
        "model": model,
        "prompt": prompt,
        "stream": False,
        "format": "json",
        "options": {"temperature": temperature},
    }
    response = requests.post(f"{host}/api/generate", json=payload, timeout=timeout)
    response.raise_for_status()
    return response.json().get("response", "")


def _build_prompt(document, context):
    prompt_text = PROMPT_PATH.read_text(encoding="utf-8")
    return (
        f"{prompt_text}\n\n"
        "## Source context\n\n```json\n"
        f"{json.dumps(context, indent=2, ensure_ascii=False, default=str)}\n"
        "```\n\n"
        "## Document to verify\n\n```json\n"
        f"{json.dumps(document, indent=2, ensure_ascii=False, default=str)}\n"
        "```\n"
    )


def _normalize(text):
    return " ".join(str(text).split()).strip().lower()


def _window_haystack(window):
    content = window.get("content")
    if isinstance(content, list):
        return _normalize(" ".join(str(item) for item in content))
    return _normalize(content or "")


def _ground_sources(container, context_blob):
    """Strips citations that don't trace back to the context, in place.

    A `sources` block is the strongest claim the digest makes — a URL and a quote
    together assert "this exists and says this" — and it is also the easiest thing
    for a model to compose out of thin air, because a plausible URL looks exactly
    like a real one. Both halves are checked mechanically here rather than being
    left to the verifying call's own judgement, for the same reason the quote check
    below exists: substring presence in the context is a fact, not an opinion, and
    no second opinion improves on it.

    A source with a fabricated URL is dropped whole. A real source carrying an
    invented quote keeps the source and loses the quote — the citation is still
    true, only the excerpt was not.
    """
    sources = container.get("sources")
    if not isinstance(sources, list):
        return

    kept = []
    for source in sources:
        if not isinstance(source, dict):
            continue
        url = str(source.get("url") or "").strip()
        if url and _normalize(url) not in context_blob:
            LOG.warning("counter-run: dropping a source whose URL is not in the context: %r", url[:120])
            continue
        quote = str(source.get("quote") or "").strip()
        if quote and _normalize(quote) not in context_blob:
            LOG.warning("counter-run: clearing a source quote not found in the context: %r", quote[:80])
            source = {key: value for key, value in source.items() if key != "quote"}
        kept.append(source)

    if kept:
        container["sources"] = kept
    else:
        container.pop("sources", None)


def verify_document(document, context):
    """Returns a possibly-filtered copy of `document`. Never raises."""
    if document.get("degraded"):
        # Already a system-generated "the LLM host did not respond" placeholder, not
        # model output — nothing here could be hallucinated, and the context it was
        # meant to be grounded in is exactly what it's reporting it never got.
        return document

    windows = document.get("windows") or []
    if not windows:
        return document

    temperature = float(os.environ.get("COUNTER_RUN_TEMPERATURE", "0.1"))

    try:
        raw = _call_ollama(_build_prompt(document, context), temperature)
        verdict = json.loads(raw)
        if not isinstance(verdict, dict):
            raise ValueError("verdict was not a JSON object")
    except Exception:
        LOG.warning(
            "counter-run: verification call failed for %s/%s, keeping the original "
            "document but marking it unverified",
            document.get("section"),
            document.get("detail_level"),
            exc_info=True,
        )
        result = dict(document)
        result["unverified"] = True
        return result

    window_verdicts = {
        str(entry.get("window_id")): bool(entry.get("grounded", True))
        for entry in (verdict.get("window_verdicts") or [])
        if isinstance(entry, dict)
    }

    # A quote the model claims is grounded gets checked mechanically too: this is
    # the one claim type a substring search can verify without another LLM call
    # trusting itself, so it's the one place a lenient (or dishonest) verdict from
    # the counter-run call itself still gets overridden.
    context_blob = _normalize(json.dumps(context, ensure_ascii=False, default=str))
    quote_failed_windows = set()
    for entry in verdict.get("quotes_checked") or []:
        if not isinstance(entry, dict):
            continue
        text = str(entry.get("text") or "")
        normalized = _normalize(text)
        if not normalized:
            continue
        actually_found = normalized in context_blob
        if entry.get("found_in_context", True) and not actually_found:
            LOG.warning(
                "counter-run: quote marked found_in_context but not located in "
                "context, overriding: %r",
                text[:80],
            )
        if not actually_found:
            for window in windows:
                if normalized in _window_haystack(window):
                    quote_failed_windows.add(str(window.get("id")))

    kept, dropped = [], []
    for window in windows:
        window_id = str(window.get("id"))
        grounded = window_verdicts.get(window_id, True) and window_id not in quote_failed_windows
        if grounded:
            kept.append(window)
        else:
            dropped.append(window_id)

    if dropped:
        LOG.warning(
            "counter-run: dropped %d ungrounded window(s) from %s/%s: %s",
            len(dropped),
            document.get("section"),
            document.get("detail_level"),
            dropped,
        )

    if not kept:
        return _withheld_document(document)

    # Citations are checked after the window verdicts, not before: there is no point
    # grounding the sources of a window that is about to be dropped whole. Markers
    # carry their own sources (the globe briefs), so they are walked too.
    for window in kept:
        _ground_sources(window, context_blob)
        for marker in window.get("globe_markers") or []:
            if isinstance(marker, dict):
                _ground_sources(marker, context_blob)

    result = dict(document)
    result["windows"] = kept
    if not bool(verdict.get("narration_grounded", True)):
        LOG.warning(
            "counter-run: narration for %s/%s flagged ungrounded, clearing it",
            document.get("section"),
            document.get("detail_level"),
        )
        result["narration"] = ""
    result["counter_run_checked"] = True
    return result


def verify_all(documents, section_contexts):
    """`documents` is {"compact": [doc, ...], "full": [doc, ...]} from
    llm_client.generate_all(); each doc's own `section` field looks up its context."""
    verified = {}
    for detail_level, docs in documents.items():
        verified[detail_level] = [
            verify_document(doc, section_contexts.get(doc.get("section"), {})) for doc in docs
        ]
    return verified
