"""
LLM wrapper using Anthropic Claude.

Enhanced with:
- Better image recognition and product extraction
- Structured comparison outputs
- Strict no-hallucination guardrails
- Collective learning from all conversations
- Richer prompting for relevance filtering
"""

import json
import logging
import base64
from typing import Dict, List, Optional, Tuple

from django.conf import settings

logger = logging.getLogger(__name__)

try:
    import anthropic
    from anthropic import BadRequestError
except ImportError:
    anthropic = None
    BadRequestError = Exception  # type: ignore

UNAVAILABLE_MESSAGE = (
    "I'm temporarily unable to process your request — our AI service is "
    "currently unavailable. Please try again in a few minutes."
)

# Strict system instruction injected into every call to prevent hallucination
ANTI_HALLUCINATION_GUARD = """\
CRITICAL RULES — NEVER VIOLATE:
1. You ONLY recommend products that exist in the search data provided to you.
2. NEVER suggest external platforms (Google, Jumia, Jiji, Amazon, Alibaba, AliExpress, eBay, 
   Konga, Tonaton, Facebook Marketplace, or any other service outside ACA).
3. If no matching products are found, say clearly that nothing was found on ACA right now 
   and suggest the user try broader terms or check back later. Never redirect elsewhere.
4. NEVER invent product names, prices, brands, or descriptions.
5. NEVER change or round prices — show them exactly as given.
6. Prices and availability come ONLY from the data you receive, never from your training.
"""


class LLMClient:
    """Stateless helper that calls Claude via the Anthropic SDK."""

    def __init__(self):
        self._load_config()

    def _load_config(self):
        api_key = getattr(settings, "ANTHROPIC_API_KEY", "")
        model = getattr(settings, "LLM_MODEL", "claude-haiku-4-5-20251001")
        max_tokens = getattr(settings, "LLM_MAX_TOKENS", 1024)

        try:
            from stores.models import ACASettings
            db_settings = ACASettings.load()
            if not api_key and db_settings.anthropic_api_key:
                api_key = db_settings.anthropic_api_key
            if db_settings.llm_model:
                model = db_settings.llm_model
            if db_settings.llm_max_tokens:
                max_tokens = db_settings.llm_max_tokens
        except Exception as exc:
            logger.warning("Could not load ACA settings from DB: %s", exc)

        self.api_key = api_key
        self.model = model
        # Leave headroom so output isn't only thinking blocks (when enabled server-side).
        self.max_tokens = max(max_tokens, 2048)

        if anthropic and self.api_key:
            self.client = anthropic.Anthropic(api_key=self.api_key)
        else:
            self.client = None

    def _create_message(self, **kwargs):
        """
        Call Messages API with extended thinking disabled so we get user-visible
        TextBlock output. Some models otherwise return only ThinkingBlock until
        max_tokens is hit, which looks like 'empty text' to callers.
        """
        if not self.client:
            raise RuntimeError("Anthropic client not configured")
        try:
            return self.client.messages.create(
                **kwargs,
                thinking={"type": "disabled"},
            )
        except BadRequestError:
            # Older endpoints may not accept thinking config — retry plain call.
            return self.client.messages.create(**kwargs)

    @staticmethod
    def _response_text(response) -> str:
        """
        Extract assistant-visible text from a Messages API response.
        Avoids IndexError when content is empty or the first block is not text
        (e.g. tool_use), which caused 'list index out of range'.
        """
        try:
            from anthropic.types.text_block import TextBlock as TextBlockType
        except ImportError:
            TextBlockType = None

        blocks = getattr(response, "content", None) or []
        parts = []
        for block in blocks:
            if TextBlockType is not None and isinstance(block, TextBlockType):
                t = (getattr(block, "text", None) or "").strip()
                if t:
                    parts.append(t)
                continue
            if isinstance(block, dict) and block.get("type") == "text":
                t = (block.get("text") or "").strip()
                if t:
                    parts.append(t)
                continue
            btype = getattr(block, "type", None)
            if hasattr(btype, "value"):
                btype = btype.value
            if btype == "text":
                t = (getattr(block, "text", None) or "").strip()
                if t:
                    parts.append(t)

        text = "\n".join(parts).strip()
        if text:
            return text

        # Fallback: raw model_dump (handles rare SDK / API parsing mismatches)
        try:
            dump = (
                response.model_dump()
                if hasattr(response, "model_dump")
                else response.dict()
            )
            raw_parts = []
            for block in dump.get("content") or []:
                if isinstance(block, dict) and block.get("type") == "text":
                    t = (block.get("text") or "").strip()
                    if t:
                        raw_parts.append(t)
            text = "\n".join(raw_parts).strip()
            if text:
                return text
        except Exception as exc:
            logger.debug("Claude _response_text model_dump fallback: %s", exc)

        if blocks:
            types = []
            for block in blocks:
                bt = getattr(block, "type", None)
                types.append(
                    getattr(bt, "value", bt) if bt is not None else type(block).__name__
                )
            sr = getattr(response, "stop_reason", None)
            logger.warning(
                "Claude response has no extractable text; block types: %s; stop_reason=%s",
                types,
                sr,
            )
        return ""

    @property
    def available(self) -> bool:
        self._load_config()
        return self.client is not None

    def chat(self, messages: List[dict], temperature: float = 0.7) -> str:
        self._load_config()
        if not self.client:
            return UNAVAILABLE_MESSAGE

        system_text, claude_messages = self._convert_messages(messages)
        # Always inject the anti-hallucination guard into the system prompt
        system_text = ANTI_HALLUCINATION_GUARD + "\n\n" + system_text

        try:
            max_out = self.max_tokens
            response = self._create_message(
                model=self.model,
                max_tokens=max_out,
                temperature=temperature,
                system=system_text,
                messages=claude_messages,
            )
            text = self._response_text(response)
            if not text:
                sr = getattr(response, "stop_reason", None)
                usage = getattr(response, "usage", None)
                logger.warning(
                    "Claude empty text (first try); stop_reason=%s; usage=%s",
                    sr,
                    usage,
                )
                # Retry with more output budget — long WhatsApp histories can push
                # the model to hit max_tokens before emitting visible text.
                if sr == "max_tokens" or sr is None:
                    retry_tokens = min(max(max_out * 2, 4096), 16384)
                    if retry_tokens > max_out:
                        response = self._create_message(
                            model=self.model,
                            max_tokens=retry_tokens,
                            temperature=temperature,
                            system=system_text,
                            messages=claude_messages,
                        )
                        text = self._response_text(response)
            if not text:
                logger.warning("Claude returned empty text content after extraction")
                return UNAVAILABLE_MESSAGE
            return text
        except Exception as exc:
            logger.error("Claude API call failed: %s", exc)
            return UNAVAILABLE_MESSAGE

    def extract_json(self, messages: List[dict]) -> Optional[dict]:
        self._load_config()
        if not self.client:
            return None

        system_text, claude_messages = self._convert_messages(messages)
        system_text = ANTI_HALLUCINATION_GUARD + "\n\n" + system_text
        system_text += "\n\nYou MUST respond with valid JSON only. No other text, no markdown fences."

        try:
            response = self._create_message(
                model=self.model,
                max_tokens=self.max_tokens,
                temperature=0.0,
                system=system_text,
                messages=claude_messages,
            )
            text = self._response_text(response)
            if not text:
                return None
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            return json.loads(text)
        except Exception as exc:
            logger.error("Claude JSON extraction failed: %s", exc)
            return None

    def extract_image_query(
        self,
        image_bytes: bytes,
        media_type: str,
        user_text: str = "",
    ) -> Optional[dict]:
        """
        Enhanced image analysis — extracts rich shopping attributes from product images.
        Handles apparel, accessories, electronics, bags, shoes, and more.
        Returns a structured query dict suitable for the aggregator.
        """
        self._load_config()
        if not self.client or not image_bytes:
            return None

        prompt = (
            "You are an expert product image analyser for a shopping assistant. "
            "Study the image carefully and extract EVERY visible or strongly implied product attribute. "
            "Return ONLY valid JSON with these fields (include only fields you can determine):\n\n"
            "- category: specific product type (e.g. 'sneakers', 'maxi dress', 'analog watch', "
            "  'leather handbag', 'wireless earbuds', 'polo shirt', 'crossbody bag')\n"
            "- brand: brand name if visible on logo, label, or iconic design\n"
            "- color: primary color(s) — be specific (e.g. 'navy blue', 'off-white', 'burgundy')\n"
            "- gender: 'male', 'female', or 'unisex' only if clearly determinable\n"
            "- material: visible material (e.g. 'leather', 'denim', 'canvas', 'suede', 'nylon')\n"
            "- style: design style (e.g. 'streetwear', 'formal', 'casual', 'athletic', 'vintage')\n"
            "- pattern: if applicable (e.g. 'striped', 'plaid', 'solid', 'floral', 'camo')\n"
            "- size_type: if visible (e.g. 'oversized', 'fitted', 'slim fit', 'relaxed')\n"
            "- closure_type: for bags/shoes (e.g. 'zipper', 'lace-up', 'buckle', 'slip-on')\n"
            "- strap_type: for bags (e.g. 'crossbody', 'top handle', 'backpack straps')\n"
            "- heel_type: for footwear if applicable (e.g. 'flat', 'block heel', 'stiletto')\n"
            "- occasion: likely use (e.g. 'office', 'casual', 'sport', 'evening', 'outdoor')\n"
            "- q: 3-8 keywords describing the item for text search "
            "  (focus on searchable terms a store might use)\n\n"
            "Be specific and accurate. If uncertain about any field, omit it. "
            f"Return {{}} if no clear product is visible.\n\n"
            f"User context: {user_text or 'No additional context.'}"
        )

        encoded = base64.b64encode(image_bytes).decode("utf-8")

        try:
            response = self._create_message(
                model=self.model,
                max_tokens=800,
                temperature=0.0,
                system="Return valid JSON only. No markdown. No explanation.",
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "image",
                                "source": {
                                    "type": "base64",
                                    "media_type": media_type,
                                    "data": encoded,
                                },
                            },
                            {"type": "text", "text": prompt},
                        ],
                    }
                ],
            )
            text = self._response_text(response)
            if not text:
                return None
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            data = json.loads(text)
            if not isinstance(data, dict):
                return None
            # Filter out empty/null values and keep only useful fields
            allowed = {
                "category", "brand", "color", "gender", "material", "style",
                "pattern", "size_type", "closure_type", "strap_type", "heel_type",
                "occasion", "q"
            }
            return {k: v for k, v in data.items() if k in allowed and v not in (None, "", [])}
        except Exception as exc:
            logger.error("Claude image query extraction failed: %s", exc)
            return None

    def rank_by_image_similarity(
        self,
        reference_b64: str,
        candidate_urls: List[str],
        top_k: int = 8,
    ) -> Optional[List[int]]:
        """
        Enhanced image similarity ranking. Compares reference image against
        candidate product images and returns indices sorted by visual similarity.
        """
        self._load_config()
        if not self.client or not reference_b64 or not candidate_urls:
            return None

        import requests as req_lib
        imgs = []
        for i, url in enumerate(candidate_urls[:10]):
            try:
                r = req_lib.get(url, timeout=5)
                if r.ok and r.content:
                    b64 = base64.b64encode(r.content).decode("utf-8")
                    imgs.append((i, b64))
            except Exception:
                continue
        if not imgs:
            return None

        content = [
            {
                "type": "image",
                "source": {"type": "base64", "media_type": "image/jpeg", "data": reference_b64},
            },
            {
                "type": "text",
                "text": (
                    "This is the REFERENCE image the user is looking for. "
                    "Study it carefully: note the exact product type, silhouette, color, "
                    "material, design details, and style."
                )
            },
        ]

        for idx, b64 in imgs:
            content.append(
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": "image/jpeg", "data": b64},
                }
            )
            content.append({"type": "text", "text": f"Candidate {idx}:"})

        content.append({
            "type": "text",
            "text": (
                f"Rank the candidates by visual similarity to the REFERENCE image. "
                f"Consider: product type match (most important), color similarity, "
                f"style/silhouette, material, and overall appearance. "
                f"Return a JSON array of candidate indices ordered from most to least similar. "
                f"Only include candidates that are genuinely the same product type. "
                f"Return at most {top_k} indices. Example: [2, 0, 3]"
            ),
        })

        try:
            response = self._create_message(
                model=self.model,
                max_tokens=1024,
                temperature=0.0,
                system="Return valid JSON array only. No markdown.",
                messages=[{"role": "user", "content": content}],
            )
            text = self._response_text(response)
            if not text:
                return None
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            data = json.loads(text)
            if isinstance(data, list):
                out = []
                for v in data:
                    try:
                        out.append(int(v))
                    except Exception:
                        continue
                return out[:top_k] if out else None
        except Exception as exc:
            logger.error("Claude image similarity ranking failed: %s", exc)
        return None

    @staticmethod
    def _absolute_product_image_url(url: str) -> str:
        if not url:
            return ""
        if url.startswith(("http://", "https://")):
            return url
        base = getattr(settings, "SITE_URL", "https://aca.mirjy.com").rstrip("/")
        return f"{base}{url}"

    def filter_products_by_reference_image(
        self,
        reference_b64: str,
        products: List[dict],
        max_items: int = 3,
        pool_limit: int = 12,
    ) -> Tuple[List[dict], str, str]:
        """
        Visually compare the user's reference photo to candidate product images.
        Excludes unrelated product types. Returns at most ``max_items`` products.

        Returns:
            (filtered_products, match_quality, user_notice)
            match_quality: "strong" | "similar" | "none"
            user_notice: short text to show on web/WhatsApp (may be empty).
        """
        self._load_config()
        if not self.client or not reference_b64 or not products:
            return ([], "none", "")

        import requests as req_lib

        pool = products[:pool_limit]
        indexed: List[Tuple[int, dict, str, str]] = []
        for i, p in enumerate(pool):
            raw = (
                p.get("image_url")
                or p.get("product_image")
                or (p.get("image_urls") or [None])[0]
                or (p.get("images") or [None])[0]
                or ""
            )
            url = self._absolute_product_image_url(str(raw))
            if not url:
                continue
            try:
                r = req_lib.get(url, timeout=10)
                if not r.ok or not r.content:
                    continue
                ct = (r.headers.get("Content-Type") or "image/jpeg").split(";")[0].strip()
                if "png" in ct.lower():
                    mt = "image/png"
                elif "webp" in ct.lower():
                    mt = "image/webp"
                else:
                    mt = "image/jpeg"
                b64 = base64.b64encode(r.content).decode("utf-8")
                indexed.append((i, p, b64, mt))
            except Exception as exc:
                logger.debug("Skip candidate image %s: %s", url[:80], exc)
                continue

        if not indexed:
            notice = "I could not load enough product photos to compare with your image right now."
            return [], "none", notice

        content: List[dict] = [
            {
                "type": "image",
                "source": {
                    "type": "base64",
                    "media_type": "image/jpeg",
                    "data": reference_b64,
                },
            },
            {
                "type": "text",
                "text": (
                    "REFERENCE: the user's photo. Compare each following candidate image "
                    "to this reference. Decide if it is the SAME kind of product "
                    "(e.g. watch vs watch, sneaker vs sneaker). Reject candidates that are "
                    "clearly a different product category than the reference."
                ),
            },
        ]

        local_idx_to_pool = []
        for j, (_pi, _p, b64, mt) in enumerate(indexed):
            content.append(
                {
                    "type": "image",
                    "source": {"type": "base64", "media_type": mt, "data": b64},
                }
            )
            content.append(
                {
                    "type": "text",
                    "text": f"Candidate index {j}: {(_p.get('name') or '')[:80]}",
                }
            )

        content.append(
            {
                "type": "text",
                "text": (
                    "Return ONLY valid JSON with this exact shape:\n"
                    '{"indices":[...], "match_quality":"strong"|"similar"|"none"}\n'
                    "- indices: at most "
                    f"{max_items} values, each is a candidate index number from 0 to "
                    f"{len(indexed) - 1} that are the SAME product type as the reference "
                    "and the best visual match. Omit unrelated or wrong-category items entirely.\n"
                    '- match_quality: "strong" if at least one candidate is a close match; '
                    '"similar" if same category but only loose resemblance; '
                    '"none" if no candidate is the same product type as the reference.\n'
                    "If nothing qualifies, use indices: [] and match_quality: \"none\"."
                ),
            }
        )

        try:
            response = self._create_message(
                model=self.model,
                max_tokens=1024,
                temperature=0.0,
                system="Return valid JSON only. No markdown.",
                messages=[{"role": "user", "content": content}],
            )
            text = self._response_text(response)
            if not text:
                raise ValueError("empty response")
            if text.startswith("```"):
                text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
            data = json.loads(text)
            if not isinstance(data, dict):
                raise ValueError("not a dict")

            raw_indices = data.get("indices") or data.get("show_indices") or []
            quality = (data.get("match_quality") or "similar").lower().strip()
            if quality not in ("strong", "similar", "none"):
                quality = "similar"

            picked: List[dict] = []
            seen = set()
            for v in raw_indices:
                try:
                    li = int(v)
                except (TypeError, ValueError):
                    continue
                if li < 0 or li >= len(indexed):
                    continue
                if li in seen:
                    continue
                seen.add(li)
                _orig_i, prod, _b, _m = indexed[li]
                picked.append(prod)
                if len(picked) >= max_items:
                    break

            if picked:
                if quality == "strong":
                    notice = (
                        "Here are the best visual matches to your photo from our partner stores."
                    )
                else:
                    notice = (
                        "These items are in a similar category to your photo. "
                        "They may not be an exact visual match."
                    )
                return picked, quality, notice

            notice = (
                "I could not find a close visual match in our catalog from this image."
            )
            return [], "none", notice

        except Exception as exc:
            logger.warning("filter_products_by_reference_image failed: %s", exc)
            notice = "I could not finish the visual comparison right now. Please try again."
            return [], "none", notice

    def generate_comparison(
        self,
        products: List[dict],
        user_query: str = "",
    ) -> str:
        """
        Generate a structured head-on product comparison in plain text.
        Only uses data from the provided products — no hallucination.
        """
        self._load_config()
        if not self.client or not products:
            return ""

        # Build a clean product summary for the LLM
        product_lines = []
        for i, p in enumerate(products, 1):
            attrs = [
                f"Name: {p.get('name', 'N/A')}",
                f"Price: {p.get('currency', '$')}{p.get('price', '?')}",
                f"Store: {p.get('store_name', 'N/A')}",
                f"Brand: {p.get('brand', 'N/A')}",
                f"Category: {p.get('category', 'N/A')}",
                f"In Stock: {p.get('in_stock', '?')}",
            ]
            if p.get("description"):
                attrs.append(f"Description: {p['description'][:200]}")
            product_lines.append(f"Product {i}:\n" + "\n".join(f"  {a}" for a in attrs))

        products_text = "\n\n".join(product_lines)

        messages = [
            {
                "role": "system",
                "content": (
                    ANTI_HALLUCINATION_GUARD + "\n\n"
                    "You are a shopping assistant writing a concise product comparison. "
                    "ONLY use the product data provided — never add information from outside. "
                    "NEVER mention external platforms. "
                    "Format rules: plain text only, NO markdown, NO bullet points, NO bold, NO emojis. "
                    "Write natural paragraphs. Be helpful and objective. "
                    "Compare on: price, brand, key features visible in the data, availability. "
                    "End with a brief recommendation based on what the user seems to want."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"The user wants to compare these products"
                    f"{f' for: {user_query}' if user_query else ''}.\n\n"
                    f"{products_text}\n\n"
                    f"Write a clear, helpful comparison using ONLY the data above."
                ),
            },
        ]

        try:
            system_text, claude_messages = self._convert_messages(messages)
            response = self._create_message(
                model=self.model,
                max_tokens=min(self.max_tokens, 1500),
                temperature=0.3,
                system=system_text,
                messages=claude_messages,
            )
            return self._response_text(response)
        except Exception as exc:
            logger.error("Claude comparison generation failed: %s", exc)
            return ""

    def classify_comparison_intent(self, history: List[dict], user_text: str) -> bool:
        """
        Detect if the user wants a head-on comparison of specific products.
        Returns True if comparison is clearly requested.
        """
        if not self.client:
            text = (user_text or "").lower()
            return any(k in text for k in [
                "compare", "comparison", "vs", "versus", "difference between",
                "which is better", "which one", "side by side"
            ])

        messages = [
            {
                "role": "system",
                "content": (
                    "Determine if the user's latest message is a request for a detailed "
                    "comparison between specific products. "
                    "Return 'yes' ONLY if the user is clearly asking to compare 2+ specific items. "
                    "Return 'no' for general browsing or vague requests. "
                    "Respond with ONLY 'yes' or 'no'."
                ),
            },
            *history[-4:],
            {"role": "user", "content": user_text},
            {"role": "user", "content": "Is this a comparison request?"},
        ]
        try:
            result = self.chat(messages, temperature=0.0).strip().lower()
            return result.startswith("yes")
        except Exception:
            return False

    @staticmethod
    def _convert_messages(messages: List[dict]):
        """
        Separate system messages from conversation turns.
        Ensures no consecutive same-role messages (Anthropic requirement).
        Returns (system_text, claude_messages).
        """
        system_parts = []
        claude_msgs = []

        for m in messages:
            role = m.get("role", "user")
            content = m.get("content", "")
            if role == "system":
                system_parts.append(content)
            else:
                mapped_role = "user" if role == "user" else "assistant"
                if claude_msgs and claude_msgs[-1]["role"] == mapped_role:
                    claude_msgs[-1]["content"] += "\n\n" + content
                else:
                    claude_msgs.append({"role": mapped_role, "content": content})

        if not claude_msgs or claude_msgs[0]["role"] != "user":
            claude_msgs.insert(0, {"role": "user", "content": "Hello"})

        return "\n\n".join(system_parts), claude_msgs