"""Weather tool implementation using Open-Meteo API (free, no API key required)."""

import requests
from typing import Dict, Any, Optional
from ...debug import debug_log
from ...utils.location import get_location_info
from ..base import Tool, ToolContext
from ..types import ToolExecutionResult


# Sentinel strings an LLM extractor may emit to mean "no place mentioned".
# Matched case-insensitively as whole-value comparisons, not substrings.
_NO_PLACE_SENTINELS = frozenset({
    "none", "null", "no", "no place", "no location",
    "n/a", "na", "unknown", "unspecified",
})


def _extract_place_from_user_text(text: str, cfg) -> Optional[str]:
    """Ask a small LLM to pull a place name out of the user's utterance.

    Used as a last-ditch fallback when the tool-calling LLM didn't fill the
    ``location`` argument AND GeoIP auto-detect is unavailable. Small chat
    models (e.g. gemma4:e2b) regularly fail to propagate a city into tool
    args even when the user literally just said one — pulling the place
    straight from the user's text sidesteps that weakness so the user
    doesn't have to keep repeating themselves.

    Returns ``None`` when no place is named, the call fails, or the
    extractor gives back something that doesn't look like a place.
    """
    if not isinstance(text, str) or not text.strip():
        return None
    if cfg is None:
        return None

    model = (
        getattr(cfg, "tool_router_model", "")
        or getattr(cfg, "intent_judge_model", "")
        or getattr(cfg, "ollama_chat_model", "")
    )
    base_url = getattr(cfg, "ollama_base_url", "")
    if not model or not base_url:
        return None

    try:
        from ...llm import call_llm_direct
    except Exception:
        return None

    sys_prompt = (
        "You extract a single place name from a user's utterance so a weather "
        "tool can look it up. Reply with ONLY the place name (city, town, or "
        "country), with no punctuation, quotes, or explanation. If the user "
        "did not name any place, reply with exactly: none"
    )
    user_prompt = f"User utterance: {text}\n\nPlace:"

    try:
        resp = call_llm_direct(
            base_url, model, sys_prompt, user_prompt,
            timeout_sec=float(getattr(cfg, "llm_tools_timeout_sec", 8.0)),
        )
    except Exception as e:
        debug_log(f"    ⚠️ place extraction failed: {e}", "tools")
        return None

    if not resp or not isinstance(resp, str):
        return None

    # Strip punctuation and quotes the extractor might wrap around the name.
    place = resp.strip().strip("'\"`*.,:;!?()[]{}<>").split("\n", 1)[0].strip()
    if not place:
        return None
    if place.lower() in _NO_PLACE_SENTINELS:
        return None
    # Reject multi-sentence or overly long replies — those are almost always
    # the model explaining ("the user did not name a place") instead of
    # answering. Place names are at most a handful of words (e.g. "New York",
    # "Stratford-upon-Avon", "São Paulo"), so 5 words is a generous cap.
    if len(place) > 60 or "." in place or len(place.split()) > 5:
        return None
    return place


def _romanise_place(name: str, cfg) -> Optional[str]:
    """Romanise a non-Latin place name for the geocoder.

    Open-Meteo's geocoding API only matches Latin/English spellings, so a
    Korean (or other non-Latin) city name like ``서울`` returns zero results
    even though the place plainly exists. When OUTPUT_LANGUAGE locks replies to
    Korean the tool-calling model naturally fills ``location`` with the Korean
    name, which would otherwise dead-end. Ask the (already warm) small model for
    the common English exonym so geocoding can succeed on a retry.

    Returns the romanised name, or ``None`` if it is unavailable, unchanged, or
    doesn't look like a place.
    """
    if not isinstance(name, str) or not name.strip() or name.isascii():
        return None
    if cfg is None:
        return None

    model = (
        getattr(cfg, "tool_router_model", "")
        or getattr(cfg, "intent_judge_model", "")
        or getattr(cfg, "ollama_chat_model", "")
    )
    base_url = getattr(cfg, "ollama_base_url", "")
    if not model or not base_url:
        return None

    try:
        from ...llm import call_llm_direct
    except Exception:
        return None

    sys_prompt = (
        "You romanise place names for a geocoding API that only understands "
        "English/Latin spellings. Reply with ONLY the common English name of "
        "the place, no punctuation, quotes, or explanation. "
        "Examples: 서울 -> Seoul, 도쿄 -> Tokyo, 뮌헨 -> Munich."
    )
    user_prompt = f"Place: {name}\n\nEnglish name:"

    try:
        resp = call_llm_direct(
            base_url, model, sys_prompt, user_prompt,
            timeout_sec=float(getattr(cfg, "llm_tools_timeout_sec", 8.0)),
        )
    except Exception as e:
        debug_log(f"    ⚠️ place romanisation failed: {e}", "tools")
        return None

    if not resp or not isinstance(resp, str):
        return None
    out = resp.strip().strip("'\"`*.,:;!?()[]{}<>").split("\n", 1)[0].strip()
    if not out or out.lower() in _NO_PLACE_SENTINELS:
        return None
    if len(out) > 60 or len(out.split()) > 5 or out == name:
        return None
    return out


# WMO Weather interpretation codes
# https://open-meteo.com/en/docs
WMO_CODES = {
    0: "Clear sky",
    1: "Mainly clear",
    2: "Partly cloudy",
    3: "Overcast",
    45: "Foggy",
    48: "Depositing rime fog",
    51: "Light drizzle",
    53: "Moderate drizzle",
    55: "Dense drizzle",
    56: "Light freezing drizzle",
    57: "Dense freezing drizzle",
    61: "Slight rain",
    63: "Moderate rain",
    65: "Heavy rain",
    66: "Light freezing rain",
    67: "Heavy freezing rain",
    71: "Slight snow",
    73: "Moderate snow",
    75: "Heavy snow",
    77: "Snow grains",
    80: "Slight rain showers",
    81: "Moderate rain showers",
    82: "Violent rain showers",
    85: "Slight snow showers",
    86: "Heavy snow showers",
    95: "Thunderstorm",
    96: "Thunderstorm with slight hail",
    99: "Thunderstorm with heavy hail",
}

# Korean conditions for the concise spoken reply.
WMO_CODES_KO = {
    0: "맑음", 1: "대체로 맑음", 2: "구름 조금", 3: "흐림",
    45: "안개", 48: "서리 안개",
    51: "약한 이슬비", 53: "이슬비", 55: "강한 이슬비",
    56: "약한 어는 이슬비", 57: "강한 어는 이슬비",
    61: "약한 비", 63: "비", 65: "강한 비",
    66: "약한 어는 비", 67: "강한 어는 비",
    71: "약한 눈", 73: "눈", 75: "강한 눈", 77: "싸락눈",
    80: "약한 소나기", 81: "소나기", 82: "강한 소나기",
    85: "약한 눈소나기", 86: "강한 눈소나기",
    95: "천둥번개", 96: "우박 동반 천둥번개", 99: "강한 우박 천둥번개",
}


class WeatherTool(Tool):
    """Tool for getting current weather using Open-Meteo API."""

    @property
    def name(self) -> str:
        return "getWeather"

    @property
    def description(self) -> str:
        return (
            "Weather only (current + forecast). NOT for time-of-day, date, or "
            "location questions — those are already in the assistant's context. "
            "Use for ANY weather question: now, later today, tomorrow, this week. "
            "Call with {} — user location is auto-detected. Do NOT ask the user "
            "where they are or request a city; just call this tool with empty args."
        )

    @property
    def inputSchema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "description": "OPTIONAL. City name or location (e.g., 'London', 'New York', 'Tokyo'). Only set this if the user explicitly named a place different from their own location. If omitted, the tool auto-uses the user's current detected location — never ask the user for this argument."
                }
            },
            "required": []
        }

    def _get_user_location(self, context: ToolContext) -> Optional[Dict[str, Any]]:
        """Get user's current location from config/auto-detection.

        Returns dict with 'lat', 'lon', and 'display_name' keys, or None if unavailable.
        """
        try:
            location_info = get_location_info(
                config_ip=getattr(context.cfg, 'location_ip_address', None),
                auto_detect=getattr(context.cfg, 'location_auto_detect', True),
                resolve_cgnat_public_ip=getattr(context.cfg, 'location_cgnat_resolve_public_ip', True),
                location_cache_minutes=getattr(context.cfg, 'location_cache_minutes', 60),
            )

            if "error" in location_info:
                debug_log(f"    ⚠️ location detection failed: {location_info.get('error')}", "tools")
                return None

            # Use coordinates directly (avoids geocoding issues with district names)
            lat = location_info.get("latitude")
            lon = location_info.get("longitude")
            if lat is None or lon is None:
                return None

            # Build display name from available fields (handle None values)
            city = location_info.get("city") or ""
            region = location_info.get("region") or ""
            country = location_info.get("country") or ""

            # Prefer city, but fall back to region if city is a district
            display_parts = []
            if city:
                display_parts.append(city)
            if region and region != city:
                display_parts.append(region)
            if country:
                display_parts.append(country)

            display_name = ", ".join(display_parts) if display_parts else "your location"

            return {"lat": lat, "lon": lon, "display_name": display_name}
        except Exception as e:
            debug_log(f"    ⚠️ location detection error: {e}", "tools")
            return None

    def run(self, args: Optional[Dict[str, Any]], context: ToolContext) -> ToolExecutionResult:
        """Get current weather for a location."""
        context.user_print("🌤️ Checking weather...")

        try:
            # Get location from args, or fall back to user's detected location
            location_str = ""
            if args and isinstance(args, dict):
                raw_location = args.get("location")
                # Handle None values (LLM may pass location: null/None)
                location_str = str(raw_location).strip() if raw_location else ""

            # Determine coordinates and display name
            lat: Optional[float] = None
            lon: Optional[float] = None
            location_display: str = ""

            # Track whether we inferred the place name from the user's text
            # rather than receiving it from the caller — used only for the
            # debug log, doesn't change behaviour downstream.
            place_from_fallback = False

            if not location_str:
                # No location provided - try auto-detected coordinates first.
                user_loc = self._get_user_location(context)
                if user_loc:
                    lat = user_loc["lat"]
                    lon = user_loc["lon"]
                    location_display = user_loc["display_name"]
                    debug_log(
                        f"    📍 using detected location: {location_display} ({lat}, {lon})",
                        "tools",
                    )
                else:
                    # Auto-detect failed. Last resort: scrape a place name from
                    # the user's current utterance. Small tool-calling models
                    # often drop the city from tool args even when the user
                    # just said one, so doing this on the tool side stops the
                    # "I need it for London" → "please tell me which city"
                    # ping-pong loop.
                    user_text = getattr(context, "redacted_text", "") or ""
                    cfg = getattr(context, "cfg", None)
                    extracted = _extract_place_from_user_text(user_text, cfg)
                    if extracted:
                        debug_log(
                            f"    📍 auto-detect unavailable; extracted place from user text: '{extracted}'",
                            "tools",
                        )
                        location_str = extracted
                        place_from_fallback = True
                    else:
                        # Auto-detect genuinely failed and the user didn't name
                        # a place in this utterance. Asking is the right move.
                        return ToolExecutionResult(
                            success=False,
                            reply_text=(
                                "I couldn't auto-detect your location. "
                                "Please tell me which city to check the weather for."
                            ),
                        )

            if location_str:
                # User specified a location (or we pulled one from their text) — geocode it.
                debug_log(
                    f"    🌤️ geocoding location: '{location_str}'"
                    + (" (from user text fallback)" if place_from_fallback else ""),
                    "tools",
                )

                geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
                # Intentionally English — tool results are processed by the LLM,
                # not shown to the user.  All models handle English data well.
                geocode_params = {
                    "name": location_str,
                    "count": 1,
                    "language": "en",
                    "format": "json"
                }

                geo_response = requests.get(geocode_url, params=geocode_params, timeout=10)
                geo_response.raise_for_status()
                geo_data = geo_response.json()

                # Open-Meteo only matches Latin spellings, so a non-Latin name
                # (e.g. Korean "서울") returns nothing. Retry once with an
                # LLM-romanised name before giving up.
                if not geo_data.get("results"):
                    romanised = _romanise_place(location_str, getattr(context, "cfg", None))
                    if romanised:
                        debug_log(
                            f"    🌤️ geocode empty for '{location_str}'; retrying romanised '{romanised}'",
                            "tools",
                        )
                        geocode_params["name"] = romanised
                        geo_response = requests.get(geocode_url, params=geocode_params, timeout=10)
                        geo_response.raise_for_status()
                        geo_data = geo_response.json()
                        if geo_data.get("results"):
                            location_str = romanised

                if not geo_data.get("results"):
                    return ToolExecutionResult(
                        success=False,
                        reply_text=f"Could not find location '{location_str}'. Try a different city name or spelling."
                    )

                place = geo_data["results"][0]
                lat = place["latitude"]
                lon = place["longitude"]
                place_name = place.get("name", location_str)
                country = place.get("country", "")
                admin1 = place.get("admin1", "")  # State/region

                # Build display name
                location_display = place_name
                if admin1 and admin1 != place_name:
                    location_display += f", {admin1}"
                if country:
                    location_display += f", {country}"

                debug_log(f"    📍 resolved to {location_display} ({lat}, {lon})", "tools")

            # Step 2: Get current weather + forecast
            weather_url = "https://api.open-meteo.com/v1/forecast"
            weather_params = {
                "latitude": lat,
                "longitude": lon,
                "current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_gusts_10m",
                "hourly": "temperature_2m,weather_code",
                "daily": "weather_code,temperature_2m_max,temperature_2m_min",
                "forecast_days": 7,
                "temperature_unit": "celsius",
                "wind_speed_unit": "kmh",
                "timezone": "auto"
            }

            weather_response = requests.get(weather_url, params=weather_params, timeout=10)
            weather_response.raise_for_status()
            weather_data = weather_response.json()

            current = weather_data.get("current", {})
            if not current:
                return ToolExecutionResult(
                    success=False,
                    reply_text=f"Weather data temporarily unavailable for {location_display}."
                )

            # Extract current weather values
            temp_c = current.get("temperature_2m")
            feels_like_c = current.get("apparent_temperature")
            humidity = current.get("relative_humidity_2m")
            weather_code = current.get("weather_code", 0)
            wind_speed = current.get("wind_speed_10m")
            wind_gusts = current.get("wind_gusts_10m")

            # Convert to Fahrenheit as well
            temp_f = round(temp_c * 9/5 + 32, 1) if temp_c is not None else None
            feels_like_f = round(feels_like_c * 9/5 + 32, 1) if feels_like_c is not None else None

            # Get weather description
            weather_desc = WMO_CODES.get(weather_code, "Unknown conditions")

            # Concise, ready-to-speak Korean one-liner for the voice path. The
            # tool result is normally re-synthesised by the LLM, but a small
            # model rambles and leaks °F / CJK fragments, so we hand it a clean
            # Korean sentence it can echo verbatim (one-sentence system rule).
            _ko = WMO_CODES_KO.get(weather_code, weather_desc)
            _short_loc = location_display.split(",")[0].strip() or location_display
            _ko_parts = [f"지금 {_short_loc} 날씨는 {_ko}"]
            if temp_c is not None:
                _t = f"기온 {round(temp_c)}도"
                if feels_like_c is not None and round(feels_like_c) != round(temp_c):
                    _t += f"(체감 {round(feels_like_c)}도)"
                _ko_parts.append(_t)
            ko_sentence = ", ".join(_ko_parts) + "입니다."

            # The reply is the clean Korean sentence ONLY — no English/°C source
            # for the model to echo ("25도 Celsius"), no forecast firehose to
            # ramble over. The deterministic weather path in the engine returns
            # this verbatim; on the LLM path the model just echoes one sentence.
            lines = [ko_sentence]

            reply_text = "\n".join(lines)

            debug_log(f"    ✅ weather retrieved: {weather_desc}, {temp_c}°C", "tools")
            # Use first part of location_display for concise output
            short_name = location_display.split(",")[0].strip()
            context.user_print(f"✅ Weather for {short_name}: {weather_desc}, {temp_c}°C")

            return ToolExecutionResult(success=True, reply_text=reply_text)

        except requests.exceptions.Timeout:
            debug_log("weather request timed out", "tools")
            context.user_print("⚠️ Weather service timeout.")
            return ToolExecutionResult(
                success=False,
                reply_text="Weather service is taking too long to respond. Please try again."
            )
        except requests.exceptions.RequestException as e:
            debug_log(f"weather request failed: {e}", "tools")
            context.user_print("⚠️ Weather service unavailable.")
            return ToolExecutionResult(
                success=False,
                reply_text="Weather service is temporarily unavailable. Please try again later."
            )
        except Exception as e:
            debug_log(f"weather error: {e}", "tools")
            context.user_print("⚠️ Error getting weather.")
            return ToolExecutionResult(
                success=False,
                reply_text=f"Error getting weather: {e}"
            )
