"""LLM-based hallucination verifier for reference checking.

The LLM receives the full reference metadata plus all validation errors
detected by the checkers, and determines whether the reference is likely
fabricated (LIKELY), genuine (UNLIKELY), or unclear (UNCERTAIN).

Supports any configured LLM provider (OpenAI, Anthropic, Google, Azure,
vLLM).  When using OpenAI without a custom endpoint, the verifier uses
the Responses API with the ``web_search_preview`` tool so the LLM can
search the web during its assessment.  For other providers the verifier
falls back to plain chat completions.
"""

from __future__ import annotations

import logging
import os
from typing import Any, Dict, List, Optional

from refchecker.config.settings import resolve_api_key, resolve_endpoint

logger = logging.getLogger(__name__)


_ASSESSMENT_SYSTEM_PROMPT = """\
You are an academic-integrity assistant that determines whether a cited \
reference is likely **hallucinated** (fabricated by an AI).

IMPORTANT: Use web search to look up the paper title and authors before \
rendering your verdict. Search for the exact title in quotes on the web \
to check whether the paper actually exists. This is critical — do NOT \
rely solely on the metadata provided; verify it against the open web.

Reply with EXACTLY one of these verdicts on the FIRST line:
  LIKELY    — this reference is probably fabricated
  UNLIKELY  — this reference is probably real despite the errors
  UNCERTAIN — cannot determine with confidence

Then on the following lines, give a concise explanation (2-3 sentences max). \
If you found the paper via web search, include the URL(s) where it appears."""

_ASSESSMENT_PROMPT = """\
Today's date is {today}.

## Reference metadata
Title:   {title}
Authors: {authors}
Venue:   {venue}
Year:    {year}
URL:     {url}

## Validation results from automated checkers
{validation_summary}

## Instructions
First, search the web for the exact paper title (in quotes) to check whether \
this paper actually exists. Then, based on both the web search results AND \
the validation errors above, determine whether this reference is a \
hallucinated (fabricated) citation.

Key signals of hallucination:
- The reference could not be found in ANY academic database (Semantic Scholar, \
OpenAlex, CrossRef, DBLP, arXiv)
- A web search for the exact title returns no matching results
- Authors are obviously fake ("John Doe", "Jane Smith") or don't work in the \
cited field
- The ArXiv ID or DOI points to a completely different paper
- The title sounds generic/buzzwordy with no specific contribution
- Multiple major metadata fields conflict with what databases found

Key signals that it is NOT hallucinated:
- The paper was found via web search or verified in a database, even with \
minor metadata errors
- Year off-by-one, venue abbreviation differences, or author name formatting \
differences are common in real citations and NOT signs of hallucination
- Author count mismatches where the names mostly overlap are NOT hallucination

Provide your concise explanation (2-3 sentences max), then end your \
response with your verdict as the VERY LAST word: LIKELY, UNLIKELY, or UNCERTAIN."""

_WEB_SEARCH_DECISION_PROMPT = """\
Given this reference that could not be verified by academic databases:
Title:   {title}
Authors: {authors}
Year:    {year}

Errors: {errors}

Would a web search provide useful additional signal to determine if this is \
a hallucinated reference? Answer YES or NO on the first line.
Only answer YES for references that are unverified or have major conflicts. \
Minor author-name or year differences do NOT warrant a search."""


class LLMHallucinationVerifier:
    """LLM-based hallucination verifier.

    Supports OpenAI, Anthropic, Google, Azure, and vLLM providers.
    When using OpenAI (without a custom endpoint), the Responses API with
    ``web_search_preview`` is used so the LLM can verify references against
    the live web.  All other providers use a standard chat completion.
    """

    # Default models per provider (used when caller doesn't specify)
    _DEFAULT_MODELS: Dict[str, str] = {
        'openai': 'gpt-4.1-mini',
        'anthropic': 'claude-sonnet-4-20250514',
        'google': 'gemini-2.0-flash',
        'azure': 'gpt-4o',
        'vllm': 'gpt-4.1-mini',
    }

    def __init__(
        self,
        provider: Optional[str] = None,
        api_key: Optional[str] = None,
        endpoint: Optional[str] = None,
        model: Optional[str] = None,
    ):
        # Resolve provider — fall back to 'openai' if not specified
        self.provider = (provider or 'openai').lower()
        self.api_key = api_key or resolve_api_key(self.provider)
        self.endpoint = endpoint or resolve_endpoint(self.provider)
        self.model = model or self._DEFAULT_MODELS.get(self.provider, 'gpt-4.1-mini')
        self.client = None
        self._use_responses_api = False

        if not self.api_key:
            logger.debug('No API key for hallucination verifier (provider=%s)', self.provider)
            return

        try:
            if self.provider == 'anthropic':
                self._init_anthropic()
            elif self.provider == 'google':
                self._init_google()
            else:
                # OpenAI, Azure, vLLM all use the OpenAI client
                self._init_openai()
        except ImportError as exc:
            logger.warning('Provider package not installed for hallucination verifier: %s', exc)
        except Exception as exc:
            logger.warning('Failed to init hallucination verifier: %s', exc)

    def _init_openai(self) -> None:
        import openai
        kwargs: Dict[str, Any] = {'api_key': self.api_key}
        if self.endpoint:
            base = self.endpoint
            for suffix in ('/chat/completions', '/completions'):
                if base.endswith(suffix):
                    base = base[: -len(suffix)]
            kwargs['base_url'] = base
        self.client = openai.OpenAI(**kwargs)
        # Use Responses API (with web_search_preview) only for vanilla OpenAI
        if not self.endpoint and self.provider == 'openai':
            self._use_responses_api = True
        logger.debug(
            'Hallucination verifier initialized (provider=%s, model=%s, web_search=%s)',
            self.provider, self.model, self._use_responses_api,
        )

    def _init_anthropic(self) -> None:
        import anthropic
        self.client = anthropic.Anthropic(api_key=self.api_key)
        logger.debug(
            'Hallucination verifier initialized (provider=anthropic, model=%s)',
            self.model,
        )

    def _init_google(self) -> None:
        import google.generativeai as genai
        genai.configure(api_key=self.api_key)
        self.client = genai.GenerativeModel(self.model)
        logger.debug(
            'Hallucination verifier initialized (provider=google, model=%s)',
            self.model,
        )

    @property
    def available(self) -> bool:
        return self.client is not None

    # ------------------------------------------------------------------
    # OpenAI call paths
    # ------------------------------------------------------------------

    def _call_openai_with_web_search(self, system_prompt: str, user_prompt: str) -> tuple:
        """OpenAI Responses API with web_search_preview tool."""
        resp = self.client.responses.create(
            model=self.model,
            instructions=system_prompt,
            tools=[{'type': 'web_search_preview'}],
            input=user_prompt,
        )

        text_parts: List[str] = []
        web_urls: List[str] = []
        seen_urls: set = set()

        for item in resp.output:
            content = getattr(item, 'content', None)
            if not content:
                continue
            for block in content:
                text = getattr(block, 'text', '') or ''
                if text:
                    text_parts.append(text)
                for ann in getattr(block, 'annotations', []):
                    url = getattr(ann, 'url', '') or ''
                    if url and url not in seen_urls:
                        seen_urls.add(url)
                        web_urls.append(url)

        return '\n'.join(text_parts).strip(), web_urls

    def _call_openai_chat(self, system_prompt: str, user_prompt: str) -> tuple:
        """OpenAI / Azure / vLLM chat completions (no web search)."""
        resp = self.client.chat.completions.create(
            model=self.model,
            messages=[
                {'role': 'system', 'content': system_prompt},
                {'role': 'user', 'content': user_prompt},
            ],
            max_tokens=300,
            temperature=0.0,
        )
        return (resp.choices[0].message.content or '').strip(), []

    # ------------------------------------------------------------------
    # Anthropic call paths
    # ------------------------------------------------------------------

    def _call_anthropic_with_web_search(self, system_prompt: str, user_prompt: str) -> tuple:
        """Anthropic with web_search tool (Citations API)."""
        resp = self.client.messages.create(
            model=self.model,
            max_tokens=1024,
            system=system_prompt,
            tools=[{
                'type': 'web_search_20250305',
                'name': 'web_search',
                'max_uses': 3,
            }],
            messages=[{'role': 'user', 'content': user_prompt}],
        )

        text_parts: List[str] = []
        web_urls: List[str] = []
        seen_urls: set = set()

        for block in resp.content:
            if getattr(block, 'type', '') == 'text':
                text = getattr(block, 'text', '') or ''
                if text:
                    text_parts.append(text)
                # Extract cited URLs from citations
                for citation in getattr(block, 'citations', []):
                    url = getattr(citation, 'url', '') or ''
                    if url and url not in seen_urls:
                        seen_urls.add(url)
                        web_urls.append(url)

        return '\n'.join(text_parts).strip(), web_urls

    def _call_anthropic_chat(self, system_prompt: str, user_prompt: str) -> tuple:
        """Anthropic without web search."""
        resp = self.client.messages.create(
            model=self.model,
            max_tokens=300,
            system=system_prompt,
            messages=[{'role': 'user', 'content': user_prompt}],
        )
        text = ''
        for block in resp.content:
            if getattr(block, 'type', '') == 'text':
                text += getattr(block, 'text', '')
        return text.strip(), []

    # ------------------------------------------------------------------
    # Google call paths
    # ------------------------------------------------------------------

    def _call_google_with_web_search(self, system_prompt: str, user_prompt: str) -> tuple:
        """Google Gemini with google_search tool."""
        from google.generativeai.types import Tool

        google_search_tool = Tool(google_search={})
        resp = self.client.generate_content(
            f"{system_prompt}\n\n{user_prompt}",
            tools=[google_search_tool],
        )

        text = resp.text or ''
        web_urls: List[str] = []
        # Extract grounding URLs from metadata if available
        for candidate in getattr(resp, 'candidates', []):
            grounding = getattr(candidate, 'grounding_metadata', None)
            if grounding:
                for chunk in getattr(grounding, 'grounding_chunks', []):
                    web_info = getattr(chunk, 'web', None)
                    if web_info:
                        url = getattr(web_info, 'uri', '') or ''
                        if url:
                            web_urls.append(url)

        return text.strip(), web_urls

    def _call_google_chat(self, system_prompt: str, user_prompt: str) -> tuple:
        """Google Gemini without web search."""
        resp = self.client.generate_content(f"{system_prompt}\n\n{user_prompt}")
        return (resp.text or '').strip(), []

    # ------------------------------------------------------------------
    # Unified dispatch
    # ------------------------------------------------------------------

    def _call(self, system_prompt: str, user_prompt: str) -> tuple:
        """Call the configured LLM, using web search when available.

        Returns (response_text, web_urls).
        """
        if self.provider == 'anthropic':
            try:
                return self._call_anthropic_with_web_search(system_prompt, user_prompt)
            except Exception as exc:
                logger.debug('Anthropic web search failed, falling back to chat: %s', exc)
                return self._call_anthropic_chat(system_prompt, user_prompt)

        if self.provider == 'google':
            try:
                return self._call_google_with_web_search(system_prompt, user_prompt)
            except Exception as exc:
                logger.debug('Google web search failed, falling back to chat: %s', exc)
                return self._call_google_chat(system_prompt, user_prompt)

        # OpenAI, Azure, vLLM
        if self._use_responses_api:
            try:
                return self._call_openai_with_web_search(system_prompt, user_prompt)
            except Exception as exc:
                logger.debug('Responses API failed, falling back to chat: %s', exc)
                self._use_responses_api = False
        return self._call_openai_chat(system_prompt, user_prompt)

    def assess(
        self,
        error_entry: Dict[str, Any],
        web_searcher: Optional[Any] = None,
    ) -> Dict[str, Any]:
        """Assess whether a reference is likely hallucinated.

        Parameters
        ----------
        error_entry : dict
            Consolidated error entry with reference metadata and errors.
        web_searcher : optional
            Web search checker; used only if the LLM recommends a search.

        Returns
        -------
        dict with verdict, explanation, and optional web_search results.
        """
        if not self.available:
            return {
                'verdict': 'UNCERTAIN',
                'explanation': 'LLM not available.',
                'web_search': None,
            }

        title = error_entry.get('ref_title', '')
        authors = error_entry.get('ref_authors_cited', '')
        orig = error_entry.get('original_reference', {})
        venue = orig.get('venue', orig.get('journal', '')) or ''
        year = error_entry.get('ref_year_cited') or ''
        # Never send "0" as year to the LLM — treat it as unknown
        if str(year).strip() in ('0', ''):
            year = '(unknown)'
        url = error_entry.get('ref_url_cited', '')

        # Build a human-readable summary of validation errors
        validation_lines = self._build_validation_summary(error_entry)

        import datetime
        today = datetime.date.today().isoformat()

        user_prompt = _ASSESSMENT_PROMPT.format(
            title=title,
            authors=authors,
            venue=venue,
            year=year,
            url=url or '(none)',
            today=today,
            validation_summary=validation_lines or 'No specific errors detected.',
        )

        try:
            response, web_urls = self._call(_ASSESSMENT_SYSTEM_PROMPT, user_prompt)
            verdict, explanation = self._parse_verdict(response)
        except Exception as exc:
            logger.warning(f'LLM hallucination assessment failed: {exc}')
            return {
                'verdict': 'UNCERTAIN',
                'explanation': f'LLM call failed: {exc}',
                'web_search': None,
            }

        # Build web_result from inline web search URLs if the LLM found any
        web_result = None
        if web_urls:
            web_result = {
                'found': bool(web_urls),
                'academic_urls': web_urls[:5],
                'provider': self.provider,
            }
            # If the LLM's own web search returned real URLs but the LLM
            # still said LIKELY, the grounded evidence outweighs the guess.
            if verdict == 'LIKELY':
                verdict = 'UNLIKELY'
                explanation += (' (Web search returned real URLs for this '
                                'reference, overriding hallucination verdict.)')

        # Fallback: use separate web searcher if the LLM didn't do its own
        # web search (e.g. non-OpenAI endpoint) and verdict is ambiguous
        if not web_urls and web_searcher and web_searcher.available and verdict != 'UNLIKELY':
            if self._should_web_search(error_entry):
                try:
                    web_result = web_searcher.check_reference_exists(error_entry)
                    # Let web search influence the verdict — grounded URL
                    # evidence is stronger than an LLM guess.
                    web_verdict = web_result.get('verdict', '')
                    if web_verdict == 'EXISTS' and verdict in ('UNCERTAIN', 'LIKELY'):
                        verdict = 'UNLIKELY'
                        explanation += ' (Web search found the paper.)'
                    elif web_verdict == 'NOT_FOUND' and verdict == 'UNCERTAIN':
                        verdict = 'LIKELY'
                        explanation += ' (Web search also found no evidence this paper exists.)'
                except Exception as exc:
                    logger.debug(f'Web search during assessment failed: {exc}')

        # Post-hoc consistency check: if the explanation says the paper
        # exists/is real/was found, override a contradictory LIKELY verdict.
        if verdict == 'LIKELY':
            explanation_lower = explanation.lower()
            real_signals = (
                'exists on arxiv', 'exists on arXiv',
                'is a well-known', 'is well-known',
                'is a real', 'paper exists', 'paper is real',
                'reference is valid', 'reference is real',
                'available on arxiv', 'available on arXiv',
                'found on arxiv', 'found on arXiv',
                'found the paper', 'found via web',
                'found in google scholar', 'found in semantic scholar',
                'published in', 'appeared in',
            )
            if any(signal in explanation_lower for signal in real_signals):
                verdict = 'UNLIKELY'
                explanation += ' (Verdict corrected: explanation indicates paper is real.)'

        logger.debug(
            'Hallucination assessment: title=%r verdict=%s explanation=%s',
            title[:60], verdict, explanation[:100],
        )

        return {
            'verdict': verdict,
            'explanation': explanation,
            'web_search': web_result,
        }

    def _build_validation_summary(self, error_entry: Dict[str, Any]) -> str:
        """Build a human-readable summary of validation errors for the prompt."""
        error_type = error_entry.get('error_type', '')
        error_details = error_entry.get('error_details', '')

        lines = []
        if error_type == 'unverified':
            lines.append('- Reference could NOT be found in any academic database '
                         '(Semantic Scholar, OpenAlex, CrossRef, DBLP, arXiv)')
        elif error_type == 'multiple':
            lines.append('- Multiple issues detected:')
            for detail in error_details.split('\n'):
                detail = detail.strip()
                if detail.startswith('- '):
                    lines.append(f'  {detail}')
                elif detail:
                    lines.append(f'  - {detail}')
        elif error_type and error_details:
            lines.append(f'- {error_type}: {error_details}')

        return '\n'.join(lines)

    def _should_web_search(self, error_entry: Dict[str, Any]) -> bool:
        """Decide if a web search would provide useful signal.

        Skip searches for minor mismatches (author formatting, year off-by-one).
        """
        error_type = (error_entry.get('error_type') or '').lower()
        if error_type == 'unverified':
            return True
        if error_type in ('doi', 'arxiv_id', 'arxiv'):
            return True
        if error_type == 'multiple':
            details = (error_entry.get('error_details') or '').lower()
            if 'title' in details or 'not found' in details:
                return True
        return False

    @staticmethod
    def _parse_verdict(response: str) -> tuple:
        """Parse the LLM response into verdict and explanation.

        The prompt asks the LLM to put the verdict as the very last word.
        We extract it from there.
        """
        import re
        text = response.strip()

        # Grab the last word and check if it's a verdict keyword
        last_word = text.split()[-1].strip('.,;:!?').upper() if text else ''
        if last_word == 'UNLIKELY':
            verdict = 'UNLIKELY'
        elif last_word == 'LIKELY':
            verdict = 'LIKELY'
        elif last_word == 'UNCERTAIN':
            verdict = 'UNCERTAIN'
        else:
            # Fallback: find the last occurrence of a verdict keyword anywhere
            matches = re.findall(r'\b(UNLIKELY|LIKELY|UNCERTAIN)\b', text, re.IGNORECASE)
            verdict = matches[-1].upper() if matches else 'UNCERTAIN'

        # Everything except the trailing verdict word is the explanation
        explanation = re.sub(r'\s*(UNLIKELY|LIKELY|UNCERTAIN)\s*$', '', text, flags=re.IGNORECASE).strip()
        if not explanation:
            explanation = text
        return verdict, explanation
