"""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 in EXACTLY this structured format (three lines):

VERDICT: <LIKELY|UNLIKELY|UNCERTAIN>
EXPLANATION: <concise 2-3 sentence explanation without URLs>
LINK: <URL where the exact paper was found, or NONE if not found>

Verdicts:
  LIKELY    — this reference is probably fabricated
  UNLIKELY  — this reference is probably real despite the errors
  UNCERTAIN — cannot determine with confidence

The EXPLANATION must be a clean, readable sentence with no URLs embedded. \
Put the URL only in the LINK field."""

_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) AND the exact \
authors to check whether this SPECIFIC 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.

IMPORTANT: You must find the EXACT paper — same title, same authors, same \
general timeframe. Finding a *similar* paper with a different title or by \
different authors is NOT evidence the cited reference is real. AI-generated \
hallucinated references often resemble real papers but with wrong titles, \
swapped authors, or fabricated details.

Key signals of hallucination (any ONE of these is sufficient for LIKELY):
- 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
- Web search finds a SIMILAR but NOT IDENTICAL paper (different title, \
different authors, or different year by 2+) — this is evidence of \
hallucination, not evidence the reference is real
- The title appears truncated, garbled, or unnaturally worded
- Authors are malformed (e.g. "O. T. et al. Unke" instead of "O. T. Unke et al.")
- Authors are obviously fake or don't work in the cited field
- The ArXiv ID or DOI points to a completely different paper
- Multiple major metadata fields conflict with what databases found

Key signals that it is NOT hallucinated (verdict should be UNLIKELY):
- The EXACT paper (same title and same authors) was found via web search or \
verified in a database, even with minor metadata errors
- Year off-by-one with otherwise matching title and authors is NOT hallucination
- Venue abbreviation differences are NOT hallucination

Be strict: if the reference was not found in any academic database and web \
search only finds similar-but-different papers, the verdict should be LIKELY.

Reply in EXACTLY this format:

VERDICT: <LIKELY|UNLIKELY|UNCERTAIN>
EXPLANATION: <concise 2-3 sentence explanation — do NOT include URLs here>
LINK: <URL of the exact paper if found, or NONE>"""

_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, paper_link = 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,
            }
            # Note: we do NOT auto-override LIKELY just because web search
            # returned URLs. The URLs may point to similar-but-different
            # papers. The LLM's verdict already accounts for web results.

        # 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: only override LIKELY if the
        # explanation explicitly confirms the EXACT paper was found with
        # matching title and authors.  Phrases like "a similar paper was
        # published in" should NOT override the verdict.
        if verdict == 'LIKELY':
            explanation_lower = explanation.lower()
            # Only override for strong, unambiguous confirmation of the exact paper
            exact_match_signals = (
                'the exact paper was found',
                'this exact paper exists',
                'confirmed the exact reference',
            )
            if any(signal in explanation_lower for signal in exact_match_signals):
                verdict = 'UNLIKELY'
                explanation += ' (Verdict corrected: explanation confirms exact paper was found.)'

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

        return {
            'verdict': verdict,
            'explanation': explanation,
            'link': paper_link,
            '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, explanation, link).

        Expects structured format:
            VERDICT: LIKELY|UNLIKELY|UNCERTAIN
            EXPLANATION: ...
            LINK: <url> | NONE

        Falls back to unstructured parsing if the format isn't followed.
        """
        import re
        text = response.strip()

        verdict = 'UNCERTAIN'
        explanation = ''
        link = None

        # Try structured format first
        verdict_match = re.search(r'^VERDICT:\s*(LIKELY|UNLIKELY|UNCERTAIN)', text, re.IGNORECASE | re.MULTILINE)
        explanation_match = re.search(r'^EXPLANATION:\s*(.+?)(?=^LINK:|\Z)', text, re.IGNORECASE | re.MULTILINE | re.DOTALL)
        link_match = re.search(r'^LINK:\s*(.+)', text, re.IGNORECASE | re.MULTILINE)

        if verdict_match:
            verdict = verdict_match.group(1).strip().upper()

        if explanation_match:
            explanation = explanation_match.group(1).strip()
        else:
            # Fallback: use full text minus verdict/link lines as explanation
            lines = [l for l in text.splitlines()
                     if not re.match(r'^(VERDICT|LINK):', l, re.IGNORECASE)]
            explanation = ' '.join(lines).strip()

        if link_match:
            raw_link = link_match.group(1).strip()
            if raw_link.upper() != 'NONE':
                # Extract URL from markdown link syntax [text](url) or plain URL
                md_match = re.search(r'\[.*?\]\((https?://\S+?)\)', raw_link)
                if md_match:
                    link = md_match.group(1).rstrip('.)')
                else:
                    url_extract = re.search(r'(https?://\S+)', raw_link)
                    link = url_extract.group(1).rstrip('.)') if url_extract else None

        # Fallback verdict: if structured parse missed it, scan for keywords
        if not verdict_match:
            last_word = text.split()[-1].strip('.,;:!?').upper() if text else ''
            if last_word in ('UNLIKELY', 'LIKELY', 'UNCERTAIN'):
                verdict = last_word
            else:
                matches = re.findall(r'\b(UNLIKELY|LIKELY|UNCERTAIN)\b', text, re.IGNORECASE)
                verdict = matches[-1].upper() if matches else 'UNCERTAIN'

        if not explanation:
            explanation = re.sub(r'\s*(UNLIKELY|LIKELY|UNCERTAIN)\s*$', '', text, flags=re.IGNORECASE).strip()
            if not explanation:
                explanation = text

        return verdict, explanation, link
