//! `weather_forecast` fetcher — Open-Meteo multi-day daily forecast for a fixed
//! (latitude, longitude). Sibling of `weather` which covers current conditions only.
//!
//! Safety::Safe — host is hardcoded; config supplies coordinates / units / day count, never
//! a URL. No API key.

use async_trait::async_trait;
use chrono::{Datelike, NaiveDate, Weekday};
use serde::Deserialize;

use crate::options::OptionSchema;
use crate::payload::{
    BadgeData, Bar, BarsData, Body, EntriesData, Entry, NumberSeriesData, Payload, PointSeries,
    PointSeriesData, RatioData, Status, TextBlockData, TextData, TimelineData, TimelineEvent,
};
use crate::render::Shape;

use super::super::github::common::cache_key;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::common::{
    API_BASE, Units, http, parse_options, payload, severity_rank, weather_badge,
    weather_description,
};

const DEFAULT_DAYS: u8 = 3;
const MIN_DAYS: u8 = 1;
const MAX_DAYS: u8 = 7;

const OPTION_SCHEMAS: &[OptionSchema] = &[
    OptionSchema {
        name: "latitude",
        type_hint: "float (degrees)",
        required: true,
        default: None,
        description: "Latitude of the location to query (e.g., `35.68` for Tokyo).",
    },
    OptionSchema {
        name: "longitude",
        type_hint: "float (degrees)",
        required: true,
        default: None,
        description: "Longitude of the location to query (e.g., `139.76` for Tokyo).",
    },
    OptionSchema {
        name: "units",
        type_hint: "\"metric\" | \"imperial\"",
        required: false,
        default: Some("\"metric\""),
        description: "Temperature unit system. Metric renders °C; imperial renders °F and reports precipitation in inches.",
    },
    OptionSchema {
        name: "days",
        type_hint: "int (1..=7)",
        required: false,
        default: Some("3"),
        description: "How many days of forecast to fetch. Clamped to 1..=7 — Open-Meteo serves longer windows, but accuracy drops past a week.",
    },
];

pub struct WeatherForecastFetcher;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WeatherForecastOptions {
    pub latitude: f64,
    pub longitude: f64,
    #[serde(default)]
    pub units: Option<Units>,
    #[serde(default)]
    pub days: Option<u8>,
}

#[async_trait]
impl Fetcher for WeatherForecastFetcher {
    fn name(&self) -> &str {
        "weather_forecast"
    }
    fn safety(&self) -> Safety {
        Safety::Safe
    }
    fn description(&self) -> &'static str {
        "Daily multi-day forecast for a fixed (latitude, longitude) via Open-Meteo. `TextBlock` / `Entries` / `Text` summarise highs / lows / precipitation per day; `Ratio` reports the worst precipitation probability in the window; `NumberSeries` carries per-day rainfall totals (tenths of mm or inch) for sparkline / histogram consumers; `Bars` carries per-day precipitation **probability** (%) so the bar chart stays informative even in dry forecasts; `PointSeries` carries high+low temperature curves across days; `Badge` flags the worst weather code; `Timeline` lays the days out chronologically. `days` defaults to 3 (range 1..=7), metric units by default, no API key required."
    }
    fn refresh_interval(&self) -> u64 {
        60 * 60
    }
    fn shapes(&self) -> &[Shape] {
        &[
            Shape::TextBlock,
            Shape::Text,
            Shape::Entries,
            Shape::Ratio,
            Shape::NumberSeries,
            Shape::PointSeries,
            Shape::Bars,
            Shape::Badge,
            Shape::Timeline,
        ]
    }
    fn option_schemas(&self) -> &[OptionSchema] {
        OPTION_SCHEMAS
    }
    fn cache_key(&self, ctx: &FetchContext) -> String {
        let extra = ctx
            .options
            .as_ref()
            .and_then(|v| toml::to_string(v).ok())
            .unwrap_or_default();
        cache_key(self.name(), ctx, &extra)
    }
    fn sample_body(&self, shape: Shape) -> Option<Body> {
        Some(body_for_shape(&Forecast::sample(), shape))
    }
    async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
        let opts: WeatherForecastOptions =
            parse_options(ctx.options.as_ref(), self.name()).map_err(FetchError::Failed)?;
        let days = resolve_days(opts.days).map_err(FetchError::Failed)?;
        let units = opts.units.unwrap_or_default();
        let forecast = fetch_forecast(opts.latitude, opts.longitude, units, days).await?;
        let shape = ctx.shape.unwrap_or(Shape::TextBlock);
        Ok(payload(body_for_shape(&forecast, shape)))
    }
}

fn body_for_shape(forecast: &Forecast, shape: Shape) -> Body {
    match shape {
        Shape::Text => text(forecast),
        Shape::Entries => entries(forecast),
        Shape::Ratio => ratio(forecast),
        Shape::NumberSeries => number_series(forecast),
        Shape::PointSeries => point_series(forecast),
        Shape::Bars => bars(forecast),
        Shape::Badge => badge(forecast),
        Shape::Timeline => timeline(forecast),
        _ => text_block(forecast),
    }
}

fn resolve_days(raw: Option<u8>) -> Result<u8, String> {
    let value = raw.unwrap_or(DEFAULT_DAYS);
    if !(MIN_DAYS..=MAX_DAYS).contains(&value) {
        return Err(format!(
            "weather_forecast `days` must be between {MIN_DAYS} and {MAX_DAYS}, got {value}"
        ));
    }
    Ok(value)
}

async fn fetch_forecast(
    lat: f64,
    lon: f64,
    units: Units,
    days: u8,
) -> Result<Forecast, FetchError> {
    let url = build_url(lat, lon, units, days);
    let res = http()
        .get(&url)
        .send()
        .await
        .map_err(|e| FetchError::Failed(format!("weather_forecast request failed: {e}")))?;
    let status = res.status();
    if !status.is_success() {
        return Err(FetchError::Failed(format!("weather_forecast {status}")));
    }
    let raw: ApiResponse = res
        .json()
        .await
        .map_err(|e| FetchError::Failed(format!("weather_forecast json parse: {e}")))?;
    Ok(Forecast {
        days: days_from(&raw.daily),
        units,
    })
}

fn build_url(lat: f64, lon: f64, units: Units, days: u8) -> String {
    let base = format!(
        "{API_BASE}?latitude={lat}&longitude={lon}\
         &daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,precipitation_probability_max\
         &forecast_days={days}\
         &timezone=auto"
    );
    match units {
        Units::Metric => base,
        Units::Imperial => format!("{base}&temperature_unit=fahrenheit&precipitation_unit=inch"),
    }
}

fn days_from(raw: &Daily) -> Vec<DayPoint> {
    let len = raw
        .time
        .len()
        .min(raw.temperature_2m_max.len())
        .min(raw.temperature_2m_min.len())
        .min(raw.weather_code.len());
    (0..len)
        .filter_map(|i| {
            let date = NaiveDate::parse_from_str(&raw.time[i], "%Y-%m-%d").ok()?;
            Some(DayPoint {
                date,
                weather_code: raw.weather_code[i],
                high: raw.temperature_2m_max[i],
                low: raw.temperature_2m_min[i],
                precipitation: raw.precipitation_sum.get(i).copied().unwrap_or(0.0),
                precip_probability: raw
                    .precipitation_probability_max
                    .get(i)
                    .copied()
                    .unwrap_or(0),
            })
        })
        .collect()
}

#[derive(Debug, Deserialize)]
struct ApiResponse {
    #[serde(default)]
    daily: Daily,
}

#[derive(Debug, Default, Deserialize)]
struct Daily {
    #[serde(default)]
    time: Vec<String>,
    #[serde(default)]
    temperature_2m_max: Vec<f64>,
    #[serde(default)]
    temperature_2m_min: Vec<f64>,
    #[serde(default)]
    weather_code: Vec<u16>,
    #[serde(default)]
    precipitation_sum: Vec<f64>,
    #[serde(default)]
    precipitation_probability_max: Vec<u8>,
}

struct Forecast {
    days: Vec<DayPoint>,
    units: Units,
}

#[derive(Debug, Clone, Copy)]
struct DayPoint {
    date: NaiveDate,
    weather_code: u16,
    high: f64,
    low: f64,
    precipitation: f64,
    precip_probability: u8,
}

impl Forecast {
    fn sample() -> Self {
        let base = NaiveDate::from_ymd_opt(2026, 5, 11).expect("valid date");
        let days = [
            (2u16, 22.4, 15.1, 0.0, 10u8),
            (63, 18.2, 12.8, 8.4, 80),
            (1, 20.5, 14.7, 0.0, 30),
        ]
        .into_iter()
        .enumerate()
        .map(|(i, (code, high, low, precip, prob))| DayPoint {
            date: base + chrono::Duration::days(i as i64),
            weather_code: code,
            high,
            low,
            precipitation: precip,
            precip_probability: prob,
        })
        .collect();
        Self {
            days,
            units: Units::Metric,
        }
    }
}

fn weekday_short(weekday: Weekday) -> &'static str {
    match weekday {
        Weekday::Mon => "Mon",
        Weekday::Tue => "Tue",
        Weekday::Wed => "Wed",
        Weekday::Thu => "Thu",
        Weekday::Fri => "Fri",
        Weekday::Sat => "Sat",
        Weekday::Sun => "Sun",
    }
}

fn temp_range_label(day: &DayPoint, units: Units) -> String {
    format!(
        "{:.0}°/{:.0}°{}",
        day.high,
        day.low,
        units.temperature_label().trim_start_matches('°')
    )
}

fn precip_label(day: &DayPoint, units: Units) -> Option<String> {
    if day.precipitation <= 0.0 {
        return None;
    }
    let precision = match units {
        Units::Metric => 1,
        Units::Imperial => 2,
    };
    Some(format!(
        "{:.*}{}",
        precision,
        day.precipitation,
        units.precipitation_label()
    ))
}

fn precip_summary(day: &DayPoint, units: Units) -> String {
    match precip_label(day, units) {
        Some(amount) => format!("💧{}% {amount}", day.precip_probability),
        None => format!("💧{}%", day.precip_probability),
    }
}

fn day_line(day: &DayPoint, units: Units) -> String {
    let (emoji, _) = weather_description(day.weather_code);
    format!(
        "{}  {emoji} {}  {}",
        weekday_short(day.date.weekday()),
        temp_range_label(day, units),
        precip_summary(day, units)
    )
}

fn text_block(forecast: &Forecast) -> Body {
    Body::TextBlock(TextBlockData {
        lines: forecast
            .days
            .iter()
            .map(|d| day_line(d, forecast.units))
            .collect(),
    })
}

fn text(forecast: &Forecast) -> Body {
    let summary = forecast
        .days
        .iter()
        .map(|d| {
            let (emoji, _) = weather_description(d.weather_code);
            format!("{emoji} {}", temp_range_label(d, forecast.units))
        })
        .collect::<Vec<_>>()
        .join(" → ");
    Body::Text(TextData { value: summary })
}

fn entries(forecast: &Forecast) -> Body {
    Body::Entries(EntriesData {
        items: forecast
            .days
            .iter()
            .map(|d| {
                let (emoji, _) = weather_description(d.weather_code);
                Entry {
                    key: format!("{} {emoji}", weekday_short(d.date.weekday())),
                    value: Some(format!(
                        "{}  {}",
                        temp_range_label(d, forecast.units),
                        precip_summary(d, forecast.units)
                    )),
                    status: None,
                }
            })
            .collect(),
    })
}

/// `Ratio` summarises the worst precipitation chance across the window. Pairs with
/// `gauge_circle` / `gauge_battery` for a "umbrella?" glance — the single highest probability
/// in the next few days, labelled with the day it falls on.
fn ratio(forecast: &Forecast) -> Body {
    let worst = forecast
        .days
        .iter()
        .max_by_key(|d| d.precip_probability)
        .copied();
    let (value, label) = match worst {
        Some(day) => (
            f64::from(day.precip_probability) / 100.0,
            Some(format!("{} rain risk", weekday_short(day.date.weekday()))),
        ),
        None => (0.0, None),
    };
    Body::Ratio(RatioData {
        value,
        label,
        denominator: Some(100),
    })
}

/// Per-day precipitation totals in tenths of mm (or inch for imperial). Same precision
/// strategy as `weather`'s hourly precipitation — survives the `u64` round-trip without
/// losing the sub-millimetre signal that distinguishes drizzle from "actually wet".
fn number_series(forecast: &Forecast) -> Body {
    Body::NumberSeries(NumberSeriesData {
        values: forecast
            .days
            .iter()
            .map(|d| (d.precipitation.max(0.0) * 10.0).round() as u64)
            .collect(),
    })
}

/// Two series — `high` and `low` temperatures — across days. `PointSeries` because
/// temperature can go negative; `NumberSeries` would silently clamp.
fn point_series(forecast: &Forecast) -> Body {
    let unit = forecast.units.temperature_label();
    let to_points = |pick: fn(&DayPoint) -> f64| -> Vec<(f64, f64)> {
        forecast
            .days
            .iter()
            .enumerate()
            .map(|(i, d)| (i as f64, pick(d)))
            .collect()
    };
    Body::PointSeries(PointSeriesData {
        series: vec![
            PointSeries {
                name: format!("high ({unit})"),
                points: to_points(|d| d.high),
            },
            PointSeries {
                name: format!("low ({unit})"),
                points: to_points(|d| d.low),
            },
        ],
    })
}

/// Per-day precipitation **probability** (0..=100) with weekday labels. Probability rather
/// than rainfall amount because most forecasts have wide stretches of `precipitation_sum =
/// 0mm` even when chance-of-rain varies meaningfully (15% Tue, 35% Wed, 10% Thu). The
/// amount sequence stays available on `NumberSeries` for sparkline / histogram consumers.
fn bars(forecast: &Forecast) -> Body {
    Body::Bars(BarsData {
        bars: forecast
            .days
            .iter()
            .map(|d| Bar {
                label: weekday_short(d.date.weekday()).into(),
                value: u64::from(d.precip_probability),
                value_label: None,
            })
            .collect(),
    })
}

fn badge(forecast: &Forecast) -> Body {
    let worst_code = forecast
        .days
        .iter()
        .max_by_key(|d| severity_rank(d.weather_code))
        .map(|d| d.weather_code)
        .unwrap_or(0);
    let mut data = weather_badge(worst_code);
    if data.status == Status::Ok {
        data = BadgeData {
            status: Status::Ok,
            label: "clear ahead".into(),
        };
    }
    Body::Badge(data)
}

fn timeline(forecast: &Forecast) -> Body {
    Body::Timeline(TimelineData {
        events: forecast
            .days
            .iter()
            .map(|d| {
                let (emoji, condition) = weather_description(d.weather_code);
                TimelineEvent {
                    timestamp: d
                        .date
                        .and_hms_opt(0, 0, 0)
                        .expect("midnight is always valid")
                        .and_utc()
                        .timestamp(),
                    title: format!("{emoji} {condition}"),
                    detail: Some(format!(
                        "{}  {}",
                        temp_range_label(d, forecast.units),
                        precip_summary(d, forecast.units)
                    )),
                    status: Some(weather_badge(d.weather_code).status),
                }
            })
            .collect(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn forecast_with(days: Vec<DayPoint>, units: Units) -> Forecast {
        Forecast { days, units }
    }

    fn day(code: u16, high: f64, low: f64, precip: f64, prob: u8, date: NaiveDate) -> DayPoint {
        DayPoint {
            date,
            weather_code: code,
            high,
            low,
            precipitation: precip,
            precip_probability: prob,
        }
    }

    #[test]
    fn build_url_metric_uses_timezone_auto_and_forecast_days() {
        let url = build_url(35.68, 139.76, Units::Metric, 3);
        assert!(url.contains("latitude=35.68"));
        assert!(url.contains("longitude=139.76"));
        assert!(url.contains("forecast_days=3"));
        assert!(url.contains("timezone=auto"));
        assert!(url.contains("temperature_2m_max"));
        assert!(url.contains("precipitation_probability_max"));
        assert!(!url.contains("fahrenheit"));
        assert!(!url.contains("precipitation_unit=inch"));
    }

    #[test]
    fn build_url_imperial_switches_temperature_and_precipitation_units() {
        let url = build_url(40.71, -74.0, Units::Imperial, 5);
        assert!(url.contains("forecast_days=5"));
        assert!(url.contains("temperature_unit=fahrenheit"));
        assert!(url.contains("precipitation_unit=inch"));
    }

    #[test]
    fn resolve_days_defaults_to_three() {
        assert_eq!(resolve_days(None).unwrap(), DEFAULT_DAYS);
    }

    #[test]
    fn resolve_days_accepts_bounds() {
        assert_eq!(resolve_days(Some(1)).unwrap(), 1);
        assert_eq!(resolve_days(Some(7)).unwrap(), 7);
    }

    #[test]
    fn resolve_days_rejects_out_of_range() {
        assert!(resolve_days(Some(0)).is_err());
        assert!(resolve_days(Some(8)).is_err());
    }

    #[test]
    fn parse_options_requires_coordinates() {
        let err: String = parse_options::<WeatherForecastOptions>(None, "weather_forecast")
            .expect_err("missing options must fail");
        assert!(err.contains("latitude"));
    }

    #[test]
    fn parse_options_accepts_full_form() {
        let raw: toml::Value =
            toml::from_str("latitude = 35.68\nlongitude = 139.76\nunits = \"imperial\"\ndays = 5")
                .unwrap();
        let opts: WeatherForecastOptions = parse_options(Some(&raw), "weather_forecast").unwrap();
        assert_eq!(opts.units, Some(Units::Imperial));
        assert_eq!(opts.days, Some(5));
    }

    #[test]
    fn parse_options_rejects_unknown_keys() {
        let raw: toml::Value =
            toml::from_str("latitude = 1.0\nlongitude = 2.0\nbogus = true").unwrap();
        assert!(parse_options::<WeatherForecastOptions>(Some(&raw), "weather_forecast").is_err());
    }

    #[test]
    fn daily_deserializes_open_meteo_payload() {
        let raw = r#"{"daily":{"time":["2026-05-12","2026-05-13"],"temperature_2m_max":[22.4,18.2],"temperature_2m_min":[15.1,12.8],"weather_code":[2,63],"precipitation_sum":[0.0,8.4],"precipitation_probability_max":[10,80]}}"#;
        let parsed: ApiResponse = serde_json::from_str(raw).unwrap();
        assert_eq!(parsed.daily.time.len(), 2);
        assert_eq!(parsed.daily.weather_code, vec![2, 63]);
        let days = days_from(&parsed.daily);
        assert_eq!(days.len(), 2);
        assert_eq!(days[1].weather_code, 63);
        assert_eq!(days[1].precip_probability, 80);
    }

    #[test]
    fn days_from_skips_invalid_dates() {
        let daily = Daily {
            time: vec!["2026-05-12".into(), "not-a-date".into()],
            temperature_2m_max: vec![22.0, 18.0],
            temperature_2m_min: vec![15.0, 12.0],
            weather_code: vec![2, 63],
            precipitation_sum: vec![],
            precipitation_probability_max: vec![],
        };
        let days = days_from(&daily);
        assert_eq!(days.len(), 1);
        assert_eq!(days[0].precipitation, 0.0);
        assert_eq!(days[0].precip_probability, 0);
    }

    #[test]
    fn text_block_emits_one_line_per_day() {
        let forecast = Forecast::sample();
        // Tue has 8.4mm precip → label includes the amount; Mon and Wed are dry → no mm suffix.
        assert!(matches!(
            text_block(&forecast),
            Body::TextBlock(data)
                if data.lines.len() == 3
                    && data.lines[0].starts_with("Mon  ")
                    && data.lines[0].contains("22°/15°")
                    && data.lines[1].contains("💧80%")
                    && data.lines[1].ends_with("8.4mm")
                    && !data.lines[0].contains("mm")
                    && !data.lines[2].contains("mm")
        ));
    }

    #[test]
    fn precip_label_formats_metric_and_imperial_with_appropriate_precision() {
        let date = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
        let wet = day(63, 18.0, 12.0, 8.4, 80, date);
        let dry = day(0, 22.0, 16.0, 0.0, 10, date);
        assert_eq!(precip_label(&wet, Units::Metric), Some("8.4mm".into()));
        assert_eq!(precip_label(&wet, Units::Imperial), Some("8.40in".into()));
        assert_eq!(precip_label(&dry, Units::Metric), None);
    }

    #[test]
    fn text_emits_arrow_joined_summary() {
        assert!(matches!(
            text(&Forecast::sample()),
            Body::Text(data)
                if data.value.contains("→")
                    && data.value.matches('→').count() == 2
                    && data.value.contains("22°/15°")
        ));
    }

    #[test]
    fn entries_emit_one_row_per_day_with_weekday_key() {
        assert!(matches!(
            entries(&Forecast::sample()),
            Body::Entries(data)
                if data.items.len() == 3
                    && data.items[0].key.starts_with("Mon ")
                    && data.items[0]
                        .value
                        .as_deref()
                        .is_some_and(|v| v.contains("💧10%"))
        ));
    }

    #[test]
    fn ratio_picks_max_precip_probability_and_names_the_day() {
        assert!(matches!(
            ratio(&Forecast::sample()),
            Body::Ratio(data)
                if (data.value - 0.80).abs() < 1e-9
                    && data.denominator == Some(100)
                    && data.label.as_deref().is_some_and(|s| s.starts_with("Tue "))
        ));
    }

    #[test]
    fn number_series_holds_per_day_precipitation_in_tenths() {
        assert!(matches!(
            number_series(&Forecast::sample()),
            Body::NumberSeries(data) if data.values == vec![0, 84, 0]
        ));
    }

    #[test]
    fn point_series_carries_high_and_low_with_unit_labels() {
        assert!(matches!(
            point_series(&Forecast::sample()),
            Body::PointSeries(data)
                if data.series.len() == 2
                    && data.series[0].name == "high (°C)"
                    && data.series[1].name == "low (°C)"
                    && data.series[0].points.len() == 3
                    && data.series[0].points[1] == (1.0, 18.2)
                    && data.series[1].points[2] == (2.0, 14.7)
        ));
    }

    #[test]
    fn point_series_preserves_negative_temperatures() {
        let date = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
        let forecast = forecast_with(vec![day(71, -2.0, -8.5, 0.4, 60, date)], Units::Metric);
        assert!(matches!(
            point_series(&forecast),
            Body::PointSeries(data)
                if data.series[0].points[0].1 == -2.0
                    && data.series[1].points[0].1 == -8.5
        ));
    }

    #[test]
    fn bars_carry_per_day_precipitation_probability_with_weekday_labels() {
        // Sample probabilities: Mon=10, Tue=80, Wed=30. Bars now expose probability so the
        // chart stays informative in dry forecasts where `precipitation_sum` is all zero.
        assert!(matches!(
            bars(&Forecast::sample()),
            Body::Bars(data)
                if data.bars.len() == 3
                    && data.bars[0].label == "Mon"
                    && data.bars[0].value == 10
                    && data.bars[1].value == 80
                    && data.bars[2].value == 30
        ));
    }

    #[test]
    fn badge_picks_worst_severity_across_window() {
        let date = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
        let forecast = forecast_with(
            vec![
                day(0, 22.0, 15.0, 0.0, 5, date),
                day(63, 18.0, 12.0, 6.0, 70, date + chrono::Duration::days(1)),
                day(95, 25.0, 17.0, 12.0, 90, date + chrono::Duration::days(2)),
            ],
            Units::Metric,
        );
        assert!(matches!(
            badge(&forecast),
            Body::Badge(data) if data.status == Status::Error && data.label == "thunderstorm"
        ));
    }

    #[test]
    fn badge_renames_all_clear_label_to_clear_ahead() {
        let date = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
        let forecast = forecast_with(
            vec![
                day(0, 22.0, 15.0, 0.0, 0, date),
                day(1, 23.0, 16.0, 0.0, 5, date + chrono::Duration::days(1)),
            ],
            Units::Metric,
        );
        assert!(matches!(
            badge(&forecast),
            Body::Badge(data) if data.status == Status::Ok && data.label == "clear ahead"
        ));
    }

    #[test]
    fn timeline_carries_one_event_per_day_with_midnight_utc_timestamps() {
        // 2026-05-11 midnight UTC.
        assert!(matches!(
            timeline(&Forecast::sample()),
            Body::Timeline(data)
                if data.events.len() == 3
                    && data.events[0].timestamp == 1_778_457_600
                    && data.events[1].timestamp - data.events[0].timestamp == 86_400
                    && data.events[0].title.contains("partly cloudy")
                    && data.events[1].status == Some(Status::Warn)
        ));
    }

    #[test]
    fn imperial_units_label_temperatures_in_fahrenheit() {
        let forecast = Forecast {
            units: Units::Imperial,
            ..Forecast::sample()
        };
        assert!(matches!(
            point_series(&forecast),
            Body::PointSeries(data) if data.series[0].name == "high (°F)"
        ));
        assert!(matches!(
            text_block(&forecast),
            Body::TextBlock(block) if block.lines[0].contains("22°/15°F")
        ));
    }

    #[test]
    fn single_day_window_collapses_all_shapes_cleanly() {
        let date = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
        let forecast = forecast_with(vec![day(2, 22.0, 15.0, 0.5, 20, date)], Units::Metric);
        assert!(matches!(
            text_block(&forecast),
            Body::TextBlock(tb) if tb.lines.len() == 1
        ));
        assert!(matches!(
            text(&forecast),
            Body::Text(t) if !t.value.contains('→')
        ));
        assert!(matches!(
            bars(&forecast),
            Body::Bars(b) if b.bars.len() == 1
        ));
    }

    #[test]
    fn fetcher_metadata_cache_key_and_samples_cover_supported_shapes() {
        let fetcher = WeatherForecastFetcher;
        let ctx = FetchContext {
            widget_id: "weather_forecast".into(),
            timeout: std::time::Duration::from_secs(1),
            shape: Some(Shape::TextBlock),
            ..Default::default()
        };
        let with_options = FetchContext {
            options: Some(
                toml::from_str("latitude = 35.68\nlongitude = 139.76\ndays = 5").unwrap(),
            ),
            ..ctx.clone()
        };
        assert_eq!(fetcher.name(), "weather_forecast");
        assert_eq!(fetcher.safety(), Safety::Safe);
        assert!(fetcher.description().contains("Open-Meteo"));
        assert_eq!(
            fetcher
                .option_schemas()
                .iter()
                .map(|s| s.name)
                .collect::<Vec<_>>(),
            vec!["latitude", "longitude", "units", "days"]
        );
        assert_eq!(fetcher.default_shape(), Shape::TextBlock);
        assert_ne!(fetcher.cache_key(&ctx), fetcher.cache_key(&with_options));
        for &shape in fetcher.shapes() {
            let body = fetcher
                .sample_body(shape)
                .expect("sample for declared shape");
            let ok = matches!(
                (shape, &body),
                (Shape::TextBlock, Body::TextBlock(_))
                    | (Shape::Text, Body::Text(_))
                    | (Shape::Entries, Body::Entries(_))
                    | (Shape::Ratio, Body::Ratio(_))
                    | (Shape::NumberSeries, Body::NumberSeries(_))
                    | (Shape::PointSeries, Body::PointSeries(_))
                    | (Shape::Bars, Body::Bars(_))
                    | (Shape::Badge, Body::Badge(_))
                    | (Shape::Timeline, Body::Timeline(_))
            );
            assert!(ok, "shape {shape:?} produced wrong body variant");
        }
    }

    #[tokio::test]
    async fn fetch_rejects_missing_options_before_network() {
        let ctx = FetchContext {
            widget_id: "weather_forecast".into(),
            timeout: std::time::Duration::from_secs(1),
            shape: Some(Shape::TextBlock),
            ..Default::default()
        };
        let err = WeatherForecastFetcher.fetch(&ctx).await.unwrap_err();
        assert!(matches!(err, FetchError::Failed(msg) if msg.contains("latitude")));
    }

    #[tokio::test]
    async fn fetch_rejects_out_of_range_days_before_network() {
        let ctx = FetchContext {
            widget_id: "weather_forecast".into(),
            timeout: std::time::Duration::from_secs(1),
            shape: Some(Shape::TextBlock),
            options: Some(
                toml::from_str("latitude = 35.68\nlongitude = 139.76\ndays = 30").unwrap(),
            ),
            ..Default::default()
        };
        let err = WeatherForecastFetcher.fetch(&ctx).await.unwrap_err();
        assert!(matches!(err, FetchError::Failed(msg) if msg.contains("days")));
    }

    #[test]
    fn weekday_short_returns_three_letter_label_for_every_weekday() {
        // `Forecast::sample()` only spans Mon..Wed, so the Thu..Sun arms only get hit
        // when we call `weekday_short` directly with each variant.
        let cases = [
            (Weekday::Mon, "Mon"),
            (Weekday::Tue, "Tue"),
            (Weekday::Wed, "Wed"),
            (Weekday::Thu, "Thu"),
            (Weekday::Fri, "Fri"),
            (Weekday::Sat, "Sat"),
            (Weekday::Sun, "Sun"),
        ];
        for (weekday, expected) in cases {
            assert_eq!(weekday_short(weekday), expected);
        }
    }

    #[test]
    fn ratio_with_empty_forecast_collapses_to_zero_with_no_label() {
        let forecast = forecast_with(Vec::new(), Units::Metric);
        assert!(matches!(
            ratio(&forecast),
            Body::Ratio(data)
                if data.value == 0.0
                    && data.label.is_none()
                    && data.denominator == Some(100)
        ));
    }

    /// Live smoke test — hits Open-Meteo. `#[ignore]` keeps CI offline-safe. Run with
    /// `cargo test -- --ignored fetcher::weather::forecast::tests::live` to verify the API
    /// shape and weekday rendering for "today + 2".
    #[tokio::test]
    #[ignore]
    async fn live_tokyo_forecast_populates_three_days() {
        let forecast = fetch_forecast(35.68, 139.76, Units::Metric, 3)
            .await
            .unwrap();
        assert_eq!(forecast.days.len(), 3);
        let Body::TextBlock(block) = text_block(&forecast) else {
            panic!("text block");
        };
        for line in &block.lines {
            eprintln!("{line}");
        }
    }
}
