"""Weather service: geocoding, Open-Meteo 7-day forecast, caching, change detection."""

import logging
from datetime import datetime, timezone

import httpx
from sqlalchemy import select

from fabledassistant.models import async_session
from fabledassistant.models.weather_cache import WeatherCache

logger = logging.getLogger(__name__)

NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
OPEN_METEO_DAILY = (
    "temperature_2m_max,temperature_2m_min,precipitation_sum,"
    "precipitation_probability_max,weathercode,windspeed_10m_max"
)
OPEN_METEO_HOURLY = "precipitation_probability"

# WMO weather code → description (subset; covers the most common codes)
_WMO_CODES: dict[int, str] = {
    0: "Clear sky",
    1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Fog", 48: "Depositing rime fog",
    51: "Drizzle: light", 53: "Drizzle: moderate", 55: "Drizzle: dense",
    61: "Rain: slight", 63: "Rain: moderate", 65: "Rain: heavy",
    71: "Snow: slight", 73: "Snow: moderate", 75: "Snow: heavy",
    77: "Snow grains",
    80: "Rain showers: slight", 81: "Rain showers: moderate", 82: "Rain showers: violent",
    85: "Snow showers: slight", 86: "Snow showers: heavy",
    95: "Thunderstorm",
    96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
}


def describe_weathercode(code: int) -> str:
    return _WMO_CODES.get(code, f"Unknown (code {code})")


async def get_temp_unit(user_id: int) -> str:
    """Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
    from fabledassistant.services.settings import get_setting
    raw = await get_setting(user_id, "temp_unit", "C")
    return raw if raw in ("C", "F") else "C"


def parse_forecast(raw: dict) -> list[dict]:
    """Convert Open-Meteo daily response into a clean list of day dicts."""
    daily = raw.get("daily", {})
    dates = daily.get("time", [])
    precip_prob = daily.get("precipitation_probability_max", [])
    return [
        {
            "date": dates[i],
            "temp_max": daily.get("temperature_2m_max", [])[i],
            "temp_min": daily.get("temperature_2m_min", [])[i],
            "precip_mm": daily.get("precipitation_sum", [])[i],
            "precip_probability": precip_prob[i] if i < len(precip_prob) else None,
            "weathercode": daily.get("weathercode", [])[i],
            "description": describe_weathercode(daily.get("weathercode", [])[i]),
            "windspeed_max": daily.get("windspeed_10m_max", [])[i],
        }
        for i in range(len(dates))
    ]


def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
    """Return human-readable change strings where the forecast has meaningfully shifted."""
    old_by_date = {d["date"]: d for d in old_days}
    changes = []
    for day in new_days:
        date = day["date"]
        old = old_by_date.get(date)
        if not old:
            continue
        notes = []
        # Weather condition changed
        if day["weathercode"] != old["weathercode"]:
            notes.append(
                f"conditions changed from '{describe_weathercode(old['weathercode'])}' "
                f"to '{describe_weathercode(day['weathercode'])}'"
            )
        # Precipitation appeared or disappeared (threshold: 1mm)
        was_dry = old["precip_mm"] < 1.0
        now_wet = day["precip_mm"] >= 1.0
        was_wet = old["precip_mm"] >= 1.0
        now_dry = day["precip_mm"] < 1.0
        if was_dry and now_wet:
            notes.append(f"rain now expected ({day['precip_mm']:.1f}mm)")
        elif was_wet and now_dry:
            notes.append("rain no longer expected")
        # Temperature shifted by 4°C or more
        temp_shift = abs(day["temp_max"] - old["temp_max"])
        if temp_shift >= 4:
            direction = "warmer" if day["temp_max"] > old["temp_max"] else "colder"
            notes.append(f"high temperature {direction} by {temp_shift:.0f}°C")
        if notes:
            changes.append(f"{date}: " + "; ".join(notes))
    return changes


def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
    """Build a human-readable precipitation summary from (hour, probability) pairs.

    Returns None when no significant precipitation is expected.
    """
    wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
    if not wet_hours:
        return None

    peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
    daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
    if not daytime_hours:
        return None

    wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
    if len(wet_daytime) >= 10:
        return f"Rain likely all day (up to {peak_prob}%)"

    if not wet_daytime:
        return None

    def _fmt_hour(h: int) -> str:
        if h == 0 or h == 24:
            return "12 AM"
        if h == 12:
            return "12 PM"
        return f"{h} AM" if h < 12 else f"{h - 12} PM"

    start = wet_daytime[0]
    end = wet_daytime[-1]
    if start == end:
        return f"{peak_prob}% chance around {_fmt_hour(start)}"
    return f"Rain likely {_fmt_hour(start)}–{_fmt_hour(end + 1)} (up to {peak_prob}%)"


def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
    """Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
    hourly = raw.get("hourly", {})
    times = hourly.get("precipitation_probability", [])
    time_labels = hourly.get("time", [])
    pairs: list[tuple[int, int]] = []
    prefix = date_str + "T"
    for i, t in enumerate(time_labels):
        if t.startswith(prefix) and i < len(times) and times[i] is not None:
            hour = int(t[11:13])
            pairs.append((hour, times[i]))
    return pairs


def parse_weather_card_data(
    cache_row,
    temp_unit: str = "C",
) -> dict | None:
    """
    Parse a WeatherCache row into the metadata.weather card schema.
    Returns None if the cache row is missing or unparseable. Stale data is
    returned as-is — the frontend uses `fetched_at` to convey freshness, and
    WeatherCard handles missing today/forecast fields gracefully.
    """
    from datetime import date, timedelta

    if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
        return None

    raw = cache_row.forecast_json or {}
    current_weather = raw.get("current_weather", {})
    days = parse_forecast(raw)

    today_str = date.today().isoformat()
    yesterday_str = (date.today() - timedelta(days=1)).isoformat()

    today_day = next((d for d in days if d["date"] == today_str), None)
    yesterday_day = next((d for d in days if d["date"] == yesterday_str), None)
    future_days = [d for d in days if d["date"] > today_str][:5]

    imperial = temp_unit == "F"

    def to_temp(c: float) -> int:
        return round(c * 9 / 5 + 32) if imperial else round(c)

    def to_wind(kmh: float) -> int:
        return round(kmh * 0.621371) if imperial else round(kmh)

    def day_label(date_str: str) -> str:
        from datetime import date as _date
        try:
            return _date.fromisoformat(date_str).strftime("%a")
        except ValueError:
            return date_str

    wind_unit = "mph" if imperial else "km/h"

    today_hourly = _extract_hourly_precip_for_date(raw, today_str)
    today_precip_summary = _summarize_precip(today_hourly)

    def _forecast_day(d: dict) -> dict:
        entry: dict = {
            "day": day_label(d["date"]),
            "condition": d["description"],
            "high": to_temp(d["temp_max"]),
            "low": to_temp(d["temp_min"]),
            "precip_probability": d["precip_probability"],
            "precip_mm": d["precip_mm"],
            "windspeed_max": to_wind(d["windspeed_max"]),
        }
        hourly = _extract_hourly_precip_for_date(raw, d["date"])
        summary = _summarize_precip(hourly)
        if summary:
            entry["precip_summary"] = summary
        if hourly:
            peak = max(hourly, key=lambda x: x[1])
            if peak[1] >= 30:
                h = peak[0]
                entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
        return entry

    return {
        "location": getattr(cache_row, "location_label", ""),
        "fetched_at": cache_row.fetched_at.isoformat(),
        "current_temp": to_temp(current_weather.get("temperature", 0)),
        "condition": describe_weathercode(current_weather.get("weathercode", 0)),
        "today_high": to_temp(today_day["temp_max"]) if today_day else None,
        "today_low": to_temp(today_day["temp_min"]) if today_day else None,
        "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
        "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
        "wind_unit": wind_unit,
        "precip_summary": today_precip_summary,
        "forecast": [_forecast_day(d) for d in future_days],
    }


async def get_cached_weather_rows(
    user_id: int,
    valid_keys: set[str] | None = None,
) -> list:
    """Return raw WeatherCache ORM rows for a user (for card parsing).

    If ``valid_keys`` is provided, only rows whose ``location_key`` is in the
    set are returned. This is how callers drop orphaned cache rows whose
    location is no longer in the user's ``journal_config.locations`` (e.g.
    leftovers from the briefing era, or a location that's been removed).
    Passing an empty set returns no rows.
    """
    async with async_session() as session:
        stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
        if valid_keys is not None:
            stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys)))
        result = await session.execute(stmt)
        return list(result.scalars().all())


async def geocode(query: str) -> tuple[float, float, str]:
    """Return (lat, lon, display_name) for a place name using Nominatim."""
    async with httpx.AsyncClient(timeout=10.0, headers={"User-Agent": "FabledAssistant/1.0"}) as client:
        resp = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1})
        resp.raise_for_status()
        results = resp.json()
        if not results:
            raise ValueError(f"No geocoding result for: {query!r}")
        r = results[0]
        return float(r["lat"]), float(r["lon"]), r.get("display_name", query)


async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
    """Fetch current temperature + conditions + next 3 hours precipitation.

    Lightweight call — no daily forecast, no caching needed.
    Returns dict with: temperature, windspeed, weathercode, description,
    and precip_next_3h (list of hourly precip probabilities).
    """
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(OPEN_METEO_URL, params={
                "latitude": lat,
                "longitude": lon,
                "current_weather": "true",
                "hourly": "precipitation_probability",
                "forecast_days": 1,
                "timezone": "auto",
            })
            resp.raise_for_status()
            data = resp.json()

        current = data.get("current_weather", {})
        hourly = data.get("hourly", {})
        hourly_times = hourly.get("time", [])
        hourly_precip = hourly.get("precipitation_probability", [])

        # Find the current hour index and get next 3 hours of precip
        current_time = current.get("time", "")
        precip_next_3h = []
        try:
            idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
            precip_next_3h = hourly_precip[idx:idx + 3]
        except (StopIteration, IndexError):
            pass

        code = current.get("weathercode", 0)
        return {
            "temperature": current.get("temperature"),
            "windspeed": current.get("windspeed"),
            "weathercode": code,
            "description": describe_weathercode(code),
            "time": current_time,
            "precip_next_3h": precip_next_3h,
        }
    except Exception:
        logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
        return None


async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
    """Fetch today's hourly precipitation probabilities.

    Returns a dict of ISO hour string → probability percentage, e.g.:
    {"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
    """
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            resp = await client.get(OPEN_METEO_URL, params={
                "latitude": lat,
                "longitude": lon,
                "hourly": "precipitation_probability",
                "forecast_days": 1,
                "timezone": "auto",
            })
            resp.raise_for_status()
            data = resp.json()
        hourly = data.get("hourly", {})
        times = hourly.get("time", [])
        probs = hourly.get("precipitation_probability", [])
        return {t: p for t, p in zip(times, probs) if p is not None}
    except Exception:
        logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
        return {}


async def _fetch_open_meteo(lat: float, lon: float) -> dict:
    """Fetch 7-day forecast from Open-Meteo with current conditions, hourly precip, and yesterday's data."""
    async with httpx.AsyncClient(timeout=15.0) as client:
        resp = await client.get(OPEN_METEO_URL, params={
            "latitude": lat,
            "longitude": lon,
            "daily": OPEN_METEO_DAILY,
            "hourly": OPEN_METEO_HOURLY,
            "current_weather": "true",
            "past_days": 1,
            "timezone": "auto",
            "forecast_days": 7,
        })
        resp.raise_for_status()
        return resp.json()


async def refresh_location_cache(
    user_id: int, location_key: str, location_label: str, lat: float, lon: float
) -> WeatherCache:
    """Fetch fresh forecast and update the cache row for one location."""
    raw = await _fetch_open_meteo(lat, lon)
    async with async_session() as session:
        result = await session.execute(
            select(WeatherCache).where(
                WeatherCache.user_id == user_id,
                WeatherCache.location_key == location_key,
            )
        )
        cache = result.scalars().first()
        if cache is None:
            cache = WeatherCache(
                user_id=user_id,
                location_key=location_key,
                location_label=location_label,
            )
            session.add(cache)
        # Rotate: current becomes previous before overwriting
        cache.previous_json = cache.forecast_json
        cache.forecast_json = raw
        cache.location_label = location_label
        cache.fetched_at = datetime.now(timezone.utc)
        await session.commit()
        await session.refresh(cache)
        return cache


async def get_cached_weather(user_id: int) -> list[dict]:
    """Return all cached weather entries for the user, with parsed days and change notes."""
    async with async_session() as session:
        result = await session.execute(
            select(WeatherCache).where(WeatherCache.user_id == user_id)
        )
        rows = list(result.scalars().all())

    out = []
    for row in rows:
        current_days = parse_forecast(row.forecast_json) if row.forecast_json else []
        previous_days = parse_forecast(row.previous_json) if row.previous_json else []
        changes = detect_changes(previous_days, current_days) if previous_days else []
        out.append({
            "location_key": row.location_key,
            "location_label": row.location_label,
            "fetched_at": row.fetched_at.isoformat(),
            "days": current_days,
            "changes_since_last_fetch": changes,
        })
    return out
