"""Tests for weather tool."""

import pytest
from unittest.mock import Mock, patch
import requests

from src.jarvis.tools.builtin.weather import (
    WeatherTool,
    WMO_CODES,
    _extract_place_from_user_text,
    _romanise_place,
)
from src.jarvis.tools.base import ToolContext
from src.jarvis.tools.types import ToolExecutionResult


class TestWeatherTool:
    """Test weather tool functionality."""

    def setup_method(self):
        """Set up test fixtures."""
        self.tool = WeatherTool()
        self.context = Mock(spec=ToolContext)
        self.context.user_print = Mock()
        self.context.cfg = Mock()
        # Default to empty user text + empty ollama config so the auto-detect
        # fallback path short-circuits the LLM-backed place extractor. Tests
        # that want to exercise the extractor override these.
        self.context.redacted_text = ""
        self.context.cfg.ollama_base_url = ""
        self.context.cfg.ollama_chat_model = ""
        self.context.cfg.tool_router_model = ""
        self.context.cfg.intent_judge_model = ""

    def test_tool_properties(self):
        """Test tool metadata properties."""
        assert self.tool.name == "getWeather"
        assert "weather" in self.tool.description.lower()
        assert self.tool.inputSchema["type"] == "object"
        # Location is optional - uses user's detected location as fallback
        assert "location" in self.tool.inputSchema["properties"]
        assert self.tool.inputSchema["required"] == []

    @patch('requests.get')
    def test_run_success(self, mock_get):
        """Test successful weather retrieval with current + forecast data."""
        # First call: geocoding
        geo_response = Mock()
        geo_response.status_code = 200
        geo_response.json.return_value = {
            "results": [{
                "latitude": 51.5074,
                "longitude": -0.1278,
                "name": "London",
                "country": "United Kingdom",
                "admin1": "England"
            }]
        }
        geo_response.raise_for_status = Mock()

        # Second call: weather (now includes hourly + daily forecast)
        weather_response = Mock()
        weather_response.status_code = 200
        weather_response.json.return_value = {
            "current": {
                "time": "2026-04-08T14:00",
                "temperature_2m": 15.5,
                "apparent_temperature": 14.0,
                "relative_humidity_2m": 65,
                "weather_code": 2,
                "wind_speed_10m": 12.0,
                "wind_gusts_10m": 20.0
            },
            "hourly": {
                "time": [f"2026-04-08T{h:02d}:00" for h in range(24)],
                "temperature_2m": [10 + h * 0.5 for h in range(24)],
                "weather_code": [2] * 24,
            },
            "daily": {
                "time": [f"2026-04-{8+d:02d}" for d in range(7)],
                "weather_code": [2, 3, 61, 0, 1, 2, 3],
                "temperature_2m_max": [16, 14, 12, 17, 18, 15, 13],
                "temperature_2m_min": [8, 7, 5, 9, 10, 8, 6],
            },
        }
        weather_response.raise_for_status = Mock()

        mock_get.side_effect = [geo_response, weather_response]

        args = {"location": "London"}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is True
        assert "London" in result.reply_text
        assert "15.5°C" in result.reply_text
        assert "Partly cloudy" in result.reply_text  # WMO code 2
        assert "65%" in result.reply_text  # humidity
        # Verify forecast sections are present
        assert "Today's forecast" in result.reply_text
        assert "7-day forecast" in result.reply_text
        self.context.user_print.assert_called()

    @patch('src.jarvis.tools.builtin.weather._romanise_place')
    @patch('requests.get')
    def test_run_non_latin_location_romanises_and_retries(self, mock_get, mock_romanise):
        """A Korean city name Open-Meteo can't geocode is romanised and retried.

        Open-Meteo's geocoder only matches Latin spellings, so ``서울`` returns
        no results. The tool must romanise to ``Seoul`` and retry rather than
        dead-ending with "could not find location" (the failure the Korean-
        locked deployment hit on every weather request)."""
        mock_romanise.return_value = "Seoul"

        geo_empty = Mock()
        geo_empty.raise_for_status = Mock()
        geo_empty.json.return_value = {}  # no "results"

        geo_seoul = Mock()
        geo_seoul.raise_for_status = Mock()
        geo_seoul.json.return_value = {
            "results": [{
                "latitude": 37.566,
                "longitude": 126.9784,
                "name": "Seoul",
                "country": "South Korea",
                "admin1": "Seoul",
            }]
        }

        weather_response = Mock()
        weather_response.raise_for_status = Mock()
        weather_response.json.return_value = {
            "current": {
                "time": "2026-06-12T14:00",
                "temperature_2m": 24.0,
                "apparent_temperature": 25.0,
                "relative_humidity_2m": 55,
                "weather_code": 0,
                "wind_speed_10m": 6.0,
                "wind_gusts_10m": 10.0,
            },
            "hourly": {
                "time": [f"2026-06-12T{h:02d}:00" for h in range(24)],
                "temperature_2m": [20 + h * 0.2 for h in range(24)],
                "weather_code": [0] * 24,
            },
            "daily": {
                "time": [f"2026-06-{12+d:02d}" for d in range(7)],
                "weather_code": [0, 1, 2, 3, 1, 0, 2],
                "temperature_2m_max": [25, 26, 24, 23, 27, 28, 25],
                "temperature_2m_min": [16, 17, 15, 14, 18, 19, 16],
            },
        }

        mock_get.side_effect = [geo_empty, geo_seoul, weather_response]

        result = self.tool.run({"location": "서울"}, self.context)

        assert result.success is True
        assert "Seoul" in result.reply_text
        mock_romanise.assert_called_once()
        # The romanised name must be what the second geocode actually queried.
        assert mock_get.call_args_list[1].kwargs["params"]["name"] == "Seoul"

    def test_romanise_place_skips_ascii_names(self):
        """ASCII names already geocode, so no LLM round-trip is spent on them."""
        cfg = Mock()
        cfg.ollama_base_url = "http://x"
        cfg.ollama_chat_model = "qwen2.5:3b"
        cfg.tool_router_model = ""
        cfg.intent_judge_model = ""
        assert _romanise_place("Seoul", cfg) is None

    @patch('requests.get')
    def test_run_location_not_found(self, mock_get):
        """Test weather with unknown location."""
        geo_response = Mock()
        geo_response.status_code = 200
        geo_response.json.return_value = {"results": []}  # No results
        geo_response.raise_for_status = Mock()

        mock_get.return_value = geo_response

        args = {"location": "Nonexistent Place XYZ"}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        assert "could not find" in result.reply_text.lower()

    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_run_empty_location_uses_fallback(self, mock_location):
        """Test weather with empty location uses user's detected location as fallback."""
        # When location detection fails, should return error
        mock_location.return_value = {"error": "Location not available"}

        args = {"location": ""}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        assert result.reply_text and any(kw in result.reply_text.lower() for kw in ("location", "city"))

    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_run_none_location_uses_fallback(self, mock_location):
        """Test weather with location=None uses user's detected location (not geocode 'None')."""
        # When location detection fails, should return error - NOT try to geocode "None"
        mock_location.return_value = {"error": "Location not available"}

        # LLM may pass location: null/None instead of omitting the field
        args = {"location": None}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        # Should use fallback, not geocode the string "None"
        assert result.reply_text and any(kw in result.reply_text.lower() for kw in ("location", "city"))
        # Verify location detection was called (fallback was attempted)
        mock_location.assert_called_once()

    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_run_no_args_uses_fallback(self, mock_location):
        """Test weather with no arguments uses user's detected location as fallback."""
        # When location detection fails, should return error
        mock_location.return_value = {"error": "Location not available"}

        result = self.tool.run(None, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        assert result.reply_text and any(kw in result.reply_text.lower() for kw in ("location", "city"))

    @patch('requests.get')
    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_run_no_location_with_successful_fallback(self, mock_location, mock_get):
        """Test weather with no location but successful user location detection."""
        # Mock successful location detection with coordinates (no geocoding needed)
        mock_location.return_value = {
            "city": "London",
            "region": "England",
            "country": "United Kingdom",
            "latitude": 51.5074,
            "longitude": -0.1278
        }

        # Mock weather response (no geocoding call needed - we use coordinates directly)
        weather_response = Mock()
        weather_response.status_code = 200
        weather_response.json.return_value = {
            "current": {
                "temperature_2m": 15.5,
                "apparent_temperature": 14.0,
                "relative_humidity_2m": 65,
                "weather_code": 2,
                "wind_speed_10m": 12.0,
                "wind_gusts_10m": 20.0
            }
        }
        weather_response.raise_for_status = Mock()

        mock_get.return_value = weather_response

        # Call with no location - should use fallback coordinates directly
        result = self.tool.run({}, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is True
        assert "London" in result.reply_text
        # Verify location detection was called
        mock_location.assert_called_once()
        # Verify only one request (weather, not geocoding)
        assert mock_get.call_count == 1

    @patch('requests.get')
    @patch('src.jarvis.tools.builtin.weather._extract_place_from_user_text')
    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_auto_detect_fail_falls_back_to_user_text(
        self, mock_location, mock_extract, mock_get,
    ):
        """When auto-detect fails but the user's utterance names a city, the
        tool must pull that city from the text and fetch weather for it — not
        ask the user to repeat themselves. Regression for the "I need it for
        London" → "please tell me which city" ping-pong loop.
        """
        mock_location.return_value = {"error": "Location not available"}
        mock_extract.return_value = "London"

        geo_response = Mock()
        geo_response.status_code = 200
        geo_response.json.return_value = {
            "results": [{
                "latitude": 51.5074,
                "longitude": -0.1278,
                "name": "London",
                "country": "United Kingdom",
                "admin1": "England",
            }]
        }
        geo_response.raise_for_status = Mock()

        weather_response = Mock()
        weather_response.status_code = 200
        weather_response.json.return_value = {
            "current": {
                "time": "2026-04-20T14:00",
                "temperature_2m": 12.0,
                "apparent_temperature": 10.0,
                "relative_humidity_2m": 70,
                "weather_code": 2,
                "wind_speed_10m": 8.0,
                "wind_gusts_10m": 12.0,
            }
        }
        weather_response.raise_for_status = Mock()

        mock_get.side_effect = [geo_response, weather_response]

        self.context.redacted_text = "I need it for London"

        # No location in args, auto-detect fails, extractor recovers "London".
        result = self.tool.run({}, self.context)

        assert result.success is True
        assert "London" in result.reply_text
        mock_extract.assert_called_once()
        # The extractor must have seen the user's utterance, not the args.
        called_text = mock_extract.call_args[0][0]
        assert "London" in called_text

    @patch('src.jarvis.tools.builtin.weather._extract_place_from_user_text')
    @patch('src.jarvis.tools.builtin.weather.get_location_info')
    def test_auto_detect_fail_and_no_place_in_text_asks_user(
        self, mock_location, mock_extract,
    ):
        """If auto-detect fails AND the user's utterance doesn't name a place,
        the tool should still ask for one — extraction is a best-effort
        fallback, not a silent guess."""
        mock_location.return_value = {"error": "Location not available"}
        mock_extract.return_value = None

        self.context.redacted_text = "what's the weather"

        result = self.tool.run({}, self.context)

        assert result.success is False
        assert result.reply_text and any(
            kw in result.reply_text.lower() for kw in ("location", "city")
        )

    @patch('requests.get')
    def test_run_network_timeout(self, mock_get):
        """Test weather with network timeout."""
        mock_get.side_effect = requests.exceptions.Timeout("Connection timed out")

        args = {"location": "London"}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        assert "timeout" in result.reply_text.lower() or "taking too long" in result.reply_text.lower()

    @patch('requests.get')
    def test_run_network_error(self, mock_get):
        """Test weather with network error."""
        mock_get.side_effect = requests.exceptions.ConnectionError("Network error")

        args = {"location": "London"}
        result = self.tool.run(args, self.context)

        assert isinstance(result, ToolExecutionResult)
        assert result.success is False
        assert "unavailable" in result.reply_text.lower()

    def test_wmo_codes_coverage(self):
        """Test that WMO codes dictionary has expected entries."""
        # Check some key weather codes
        assert WMO_CODES[0] == "Clear sky"
        assert WMO_CODES[3] == "Overcast"
        assert WMO_CODES[61] == "Slight rain"
        assert WMO_CODES[95] == "Thunderstorm"
        # Ensure there are many codes covered
        assert len(WMO_CODES) >= 20

    @patch('requests.get')
    def test_forecast_includes_hourly_and_daily(self, mock_get):
        """Test that forecast data includes today's hourly and 7-day daily sections."""
        geo_response = Mock()
        geo_response.status_code = 200
        geo_response.json.return_value = {
            "results": [{
                "latitude": 41.6938,
                "longitude": 44.8015,
                "name": "Tbilisi",
                "country": "Georgia",
                "admin1": "Tbilisi"
            }]
        }
        geo_response.raise_for_status = Mock()

        weather_response = Mock()
        weather_response.status_code = 200
        weather_response.json.return_value = {
            "current": {
                "time": "2026-04-08T10:00",
                "temperature_2m": 12.0,
                "apparent_temperature": 10.0,
                "relative_humidity_2m": 70,
                "weather_code": 61,
                "wind_speed_10m": 8.0,
                "wind_gusts_10m": 15.0
            },
            "hourly": {
                "time": [f"2026-04-08T{h:02d}:00" for h in range(24)],
                "temperature_2m": [8, 8, 7, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 16, 15, 14, 13, 12, 11, 10, 9, 9, 8],
                "weather_code": [61] * 12 + [2] * 12,
            },
            "daily": {
                "time": [f"2026-04-{8+d:02d}" for d in range(7)],
                "weather_code": [61, 3, 0, 1, 2, 61, 0],
                "temperature_2m_max": [16, 18, 20, 19, 17, 14, 21],
                "temperature_2m_min": [7, 8, 10, 9, 8, 6, 11],
            },
        }
        weather_response.raise_for_status = Mock()

        mock_get.side_effect = [geo_response, weather_response]

        result = self.tool.run({"location": "Tbilisi"}, self.context)

        assert result.success is True
        # Current conditions
        assert "12" in result.reply_text
        assert "Slight rain" in result.reply_text
        # Hourly forecast for remaining hours (every 3 hours after hour 10)
        assert "Today's forecast" in result.reply_text
        assert "12:00" in result.reply_text
        assert "15:00" in result.reply_text
        # Daily forecast
        assert "7-day forecast" in result.reply_text
        assert "2026-04-09" in result.reply_text
        assert "2026-04-14" in result.reply_text

    @patch('requests.get')
    def test_temperature_conversion(self, mock_get):
        """Test that both Celsius and Fahrenheit are shown."""
        geo_response = Mock()
        geo_response.status_code = 200
        geo_response.json.return_value = {
            "results": [{
                "latitude": 40.7128,
                "longitude": -74.0060,
                "name": "New York",
                "country": "United States",
                "admin1": "New York"
            }]
        }
        geo_response.raise_for_status = Mock()

        weather_response = Mock()
        weather_response.status_code = 200
        weather_response.json.return_value = {
            "current": {
                "temperature_2m": 20.0,  # 68°F
                "apparent_temperature": 18.0,
                "relative_humidity_2m": 50,
                "weather_code": 0,
                "wind_speed_10m": 5.0,
                "wind_gusts_10m": None
            }
        }
        weather_response.raise_for_status = Mock()

        mock_get.side_effect = [geo_response, weather_response]

        args = {"location": "New York"}
        result = self.tool.run(args, self.context)

        assert result.success is True
        assert "20" in result.reply_text  # Celsius
        assert "68" in result.reply_text  # Fahrenheit


class TestExtractPlaceFromUserText:
    """Unit tests for the small-model fallback place extractor."""

    def _cfg(self):
        cfg = Mock()
        cfg.ollama_base_url = "http://localhost:11434"
        cfg.ollama_chat_model = "gemma4:e2b"
        cfg.tool_router_model = ""
        cfg.intent_judge_model = ""
        cfg.llm_tools_timeout_sec = 8.0
        return cfg

    def test_empty_text_returns_none(self):
        assert _extract_place_from_user_text("", self._cfg()) is None
        assert _extract_place_from_user_text("   ", self._cfg()) is None

    def test_none_cfg_returns_none(self):
        assert _extract_place_from_user_text("weather in London", None) is None

    def test_unconfigured_model_returns_none(self):
        cfg = Mock()
        cfg.ollama_base_url = ""
        cfg.ollama_chat_model = ""
        cfg.tool_router_model = ""
        cfg.intent_judge_model = ""
        assert _extract_place_from_user_text("weather in London", cfg) is None

    @patch("src.jarvis.tools.builtin.weather.call_llm_direct", create=True)
    def test_extracts_clean_place_name(self, _mock_direct):
        """Patch the import inside the function by intercepting call_llm_direct."""
        from src.jarvis.llm import call_llm_direct as real_fn  # noqa: F401

        with patch("src.jarvis.llm.call_llm_direct", return_value="London"):
            got = _extract_place_from_user_text("I need it for London", self._cfg())
        assert got == "London"

    def test_strips_quotes_and_punctuation(self):
        with patch("src.jarvis.llm.call_llm_direct", return_value="'Paris'."):
            got = _extract_place_from_user_text("weather paris?", self._cfg())
        assert got == "Paris"

    def test_none_sentinel_returns_none(self):
        for sentinel in ("none", "None", "NONE", "n/a", "unknown"):
            with patch("src.jarvis.llm.call_llm_direct", return_value=sentinel):
                assert _extract_place_from_user_text(
                    "what's the weather", self._cfg()
                ) is None

    def test_sentence_response_rejected(self):
        """If the model explains instead of answering, treat it as no-place."""
        with patch(
            "src.jarvis.llm.call_llm_direct",
            return_value="The user did not name a place.",
        ):
            got = _extract_place_from_user_text("weather today", self._cfg())
        assert got is None

    def test_overlong_response_rejected(self):
        with patch("src.jarvis.llm.call_llm_direct", return_value="x" * 200):
            got = _extract_place_from_user_text("weather", self._cfg())
        assert got is None
