import json
import html
import random
import re
from typing import Dict, Any, Optional
import structlog

from ...shared.clients.openai_client import openai_client
from ...shared.utils.metrics import OperationMetrics
from ...shared.exceptions.base import WinePairingException
from .config import WinePairingServiceConfig
from .models.requests import WinePairingRequest
from .models.responses import WinePairingResponse

logger = structlog.get_logger("wine_pairing.service")

VERSION = "2.0.0"


class WinePairingService:
    def __init__(self, config: WinePairingServiceConfig):
        self.config = config
        self._rng = random.SystemRandom()

    @staticmethod
    def _analytics_context(
        app_service: str,
        city: str = "City not set",
        zavedenia_id: int = -1,
        **data_input: Any
    ) -> Dict[str, Any]:
        return {
            "city": city,
            "zavedenia_id": zavedenia_id,
            "data_input": {
                "app_service": app_service,
                **{key: value for key, value in data_input.items() if value is not None}
            }
        }

    async def _lookup_vivino_rating(self, wine_name: str, model_override: Optional[str] = None) -> Optional[str]:
        """Lookup Vivino rating for a wine"""
        metrics = OperationMetrics("wine_pairing_vivino_lookup")
        metrics.set_items_count(1)

        model_name = model_override or self.config.vivino_lookup_model

        prompt = (
            "You are a sommelier assistant with reliable access to Vivino's community ratings.\n"
            "Provide the Vivino rating for the wine listed below.\n\n"
            f"Wine: {wine_name}\n\n"
            "Return ONLY valid JSON in this format:\n"
            '{"vivino_rating": "<rating number like 4.2 or Unknown if not found>"}\n\n'
            "Rules:\n"
            "- Use one decimal place when a rating exists.\n"
            "- If there is no reliable rating, respond with Unknown."
        )

        try:
            result, openai_metrics = await openai_client.json_completion(
                api_key=self.config.openai_api_key,
                model=model_name,
                prompt=prompt,
                operation_id="vivino_rating_lookup",
                service="vivino_lookup",
                max_tokens=self.config.max_tokens,
                temperature=self.config.temperature,
                timeout=self.config.timeout,
                analytics_context=self._analytics_context(
                    "vivino_lookup",
                    "City not set",
                    -1,
                    wine_name=wine_name,
                )
            )

            metrics.set_tokens(
                openai_metrics.get("input_tokens", 0),
                openai_metrics.get("output_tokens", 0)
            )
            metrics.set_cost(openai_metrics.get("cost", 0.0))
            metrics.set_model(openai_metrics.get("model", ""))
            metrics.log_completion()

            rating = result.get("vivino_rating")
            if isinstance(rating, str):
                return rating.strip()
            return None

        except Exception as exc:
            metrics.set_error(str(exc))
            metrics.log_completion()
            logger.warning(
                "vivino_rating_lookup_failed",
                wine=wine_name,
                error=str(exc),
                model=model_name
            )
            return None

    async def match_wines(self, request: WinePairingRequest) -> Dict[str, Any]:
        """Match wines to food dishes"""
        metrics = OperationMetrics("wine_pairing_match")
        metrics.set_items_count(len(request.foods))

        request_model = request.model.strip() if request.model and request.model.strip() else None
        pairing_model = request_model or self.config.wine_pairing_model
        vivino_model = request_model or self.config.vivino_lookup_model

        user_prompt_text = (request.user_prompt or "").strip()

        logger.info(
            "wine_pairing_started",
            language=request.language,
            foods=len(request.foods),
            wine_candidates=len(request.wines),
            model=pairing_model,
            user_prompt_provided=bool(user_prompt_text)
        )

        try:
            def normalize_wine_name(value: Any) -> str:
                if not isinstance(value, str):
                    return ""
                normalized = html.unescape(value).strip()
                normalized = re.sub(r"\s+", " ", normalized)
                return normalized.casefold()

            wines_payload = [wine.model_dump(exclude_none=True) for wine in request.wines]
            self._rng.shuffle(wines_payload)

            prompt_payload = {
                "language": request.language,
                "foods": [food.model_dump(exclude_none=True) for food in request.foods],
                "wines": wines_payload,
            }

            wine_price_map = {
                normalize_wine_name(wine.get("name")): wine.get("price")
                for wine in prompt_payload["wines"]
                if wine.get("name")
            }

            vivino_rating_cache: Dict[str, Optional[str]] = {}
            diversity_seed = self._rng.randint(1, 1_000_000)

            prompt_sections = [
                "You are a master sommelier crafting wine pairings for a restaurant menu. "
                "Respect the diner's preferred language when writing your explanations.\n\n",
                f"Output language: {request.language}.\n\n",
                f"Foods to pair: {json.dumps(prompt_payload['foods'], ensure_ascii=False, separators=(',', ':'))}\n\n",
                f"Wine list (only choose from these for matches): {json.dumps(prompt_payload['wines'], ensure_ascii=False, separators=(',', ':'))}\n\n",
                f"Variation seed: {diversity_seed}. When several wines are equally suitable, use this seed to rotate your choices.\n",
                "Recommend between three and five wines per food; if fewer than three wines truly fit, leave matches empty. "
                "If diner instructions ask for a different quantity, override this default and satisfy their request.\n",
                "Always include at least one value-friendly and one premium-priced option when pricing data exists.\n\n",
            ]

            if user_prompt_text:
                prompt_sections.append(
                    "Additional diner instructions (interpret and apply these as explicit pairing constraints):\n"
                    f"{user_prompt_text}\n"
                    "Translate the natural language guidance into actionable rules and honor them when possible. "
                    "Keep the tone and structure identical to the standard response; do not add meta commentary or restate the instructions verbatim in notes. "
                    "Only reflect the guidance when it naturally fits within the usual reasoning, and explicitly adjust the number of matches when requested.\n\n"
                )

            prompt_sections.append(
                "Return ONLY valid JSON in this exact structure:\n"
                "{\n"
                f'  "language": "{request.language}",\n'
                '  "results": [\n'
                '    {\n'
                f'      "food": "<dish name in {request.language}>",\n'
                '      "matches": [\n'
                '        {\n'
                '          "wine": "<wine name from list>",\n'
                '          "reason": "<why they match>",\n'
                '          "vivino_rating": "Unknown",\n'
                '          "price": "Unknown"\n'
                '        }\n'
                '      ],\n'
                '      "suggestions": [],\n'
                '      "notes": "<optional summary>"\n'
                '    }\n'
                '  ]\n'
                '}\n\n'
                "Rules:\n"
                "- Use only wines from the provided list for matches.\n"
                "- Keep matches array empty when no listed wine fits.\n"
                "- Set vivino_rating and price to Unknown (will be filled later).\n"
                f"- All explanations must be in {request.language}."
            )

            prompt = "".join(prompt_sections)

            result, openai_metrics = await openai_client.json_completion(
                api_key=self.config.openai_api_key,
                model=pairing_model,
                prompt=prompt,
                operation_id="wine_pairing",
                service="wine_pairing",
                max_tokens=self.config.max_tokens,
                temperature=self.config.temperature,
                timeout=self.config.timeout,
                analytics_context=self._analytics_context(
                    "wine_pairing",
                    request.city,
                    request.zavedenia_id,
                    request_body=request.model_dump(exclude_none=True),
                    language=request.language,
                    foods_count=len(request.foods),
                    wines_count=len(request.wines),
                    user_prompt=user_prompt_text or None,
                )
            )

            metrics.set_tokens(openai_metrics.get("input_tokens", 0), openai_metrics.get("output_tokens", 0))
            metrics.set_cost(openai_metrics.get("cost", 0.0))
            metrics.set_model(openai_metrics.get("model", ""))
            metrics.log_completion()

            logger.info(
                "wine_pairing_completed",
                language=request.language,
                foods=len(request.foods),
                model=openai_metrics.get("model")
            )

            # Post-process results
            result.setdefault("results", [])
            for item in result["results"]:
                item.setdefault("matches", [])
                matches = item.get("matches") or []

                # Limit to 2 random matches from pool of up to 5
                selection_pool = matches[:min(len(matches), 5)]
                if user_prompt_text:
                    item["matches"] = selection_pool
                else:
                    if len(selection_pool) >= 2:
                        selected = self._rng.sample(selection_pool, min(2, len(selection_pool)))
                        self._rng.shuffle(selected)
                        item["matches"] = selected
                    else:
                        item["matches"] = []

                # Fill in prices and Vivino ratings
                matches = item.get("matches") or []
                for match in matches:
                    match.setdefault("vivino_rating", None)
                    normalized_wine = normalize_wine_name(match.get("wine"))

                    # Set price
                    price_value = wine_price_map.get(normalized_wine)
                    match["price"] = price_value if price_value else None

                    # Lookup Vivino rating if enabled
                    rating_value = match.get("vivino_rating")
                    rating_is_unknown = (
                        rating_value is None or
                        (isinstance(rating_value, str) and rating_value.strip().lower() == "unknown")
                    )

                    if rating_is_unknown and normalized_wine and self.config.vivino_lookup_enabled:
                        cached_rating = vivino_rating_cache.get(normalized_wine)
                        if cached_rating is None:
                            fetched_rating = await self._lookup_vivino_rating(match.get("wine", ""), model_override=vivino_model)
                            cached_rating = fetched_rating.strip() if isinstance(fetched_rating, str) and fetched_rating.strip() else "Unknown"
                            vivino_rating_cache[normalized_wine] = cached_rating

                        if cached_rating and cached_rating.strip().lower() != "unknown":
                            match["vivino_rating"] = cached_rating
                        elif match.get("vivino_rating") is None:
                            match["vivino_rating"] = cached_rating

                # Remove suggestions if matches exist
                if item.get("matches"):
                    item.pop("suggestions", None)
                if item.get("suggestions") is None:
                    item.pop("suggestions", None)

            return {
                "language": result.get("language", request.language),
                "results": result.get("results", [])
            }

        except Exception as exc:
            metrics.set_error(str(exc))
            metrics.log_completion()
            logger.error("wine_pairing_failed", language=request.language, error=str(exc))
            raise WinePairingException(f"Wine pairing generation failed: {exc}")
