//! `crypto_watchlist` — CoinGecko price snapshot for a configurable set of coins.
//!
//! Safety::Safe because the host (`api.coingecko.com`) is hardcoded: the user supplies coin
//! ids and a quote currency, both of which become query parameters on that fixed host. No API
//! key is required and no token leaves the machine.

use std::sync::OnceLock;
use std::time::Duration;

use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;

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

use super::github::common::cache_key;
use super::{FetchContext, FetchError, Fetcher, Safety};

const API_BASE: &str = "https://api.coingecko.com/api/v3/coins/markets";
const COIN_PAGE_BASE: &str = "https://www.coingecko.com/en/coins";
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_BYTES: usize = 5 * 1024 * 1024;

const DEFAULT_COINS: &[&str] = &["bitcoin", "ethereum"];
const DEFAULT_VS_CURRENCY: &str = "usd";
/// Cap the coin list so a misconfig can't fan out arbitrarily large requests.
const MAX_COINS: usize = 20;
/// Cap chart series so multi-coin `PointSeries` stays readable in a typical widget slot.
const MAX_SERIES_COINS: usize = 5;
/// `Bars` carry abs(% change) × 100 (basis points) since `Bar.value` is `u64`.
const PCT_TO_BP: f64 = 100.0;
/// Volatility threshold (%) for badge / entry status flip from Ok to Warn. Direction-neutral
/// because a watchlist isn't sentiment-bearing — the user might be long or short any coin.
const VOLATILITY_THRESHOLD: f64 = 5.0;

const SHAPES: &[Shape] = &[
    Shape::Entries,
    Shape::Text,
    Shape::TextBlock,
    Shape::MarkdownTextBlock,
    Shape::LinkedTextBlock,
    Shape::NumberSeries,
    Shape::PointSeries,
    Shape::Bars,
    Shape::Badge,
];

const OPTION_SCHEMAS: &[OptionSchema] = &[
    OptionSchema {
        name: "coins",
        type_hint: "list of strings (CoinGecko coin ids)",
        required: false,
        default: Some("[\"bitcoin\", \"ethereum\"]"),
        description: "CoinGecko coin ids to include (e.g. `[\"bitcoin\", \"ethereum\", \"solana\"]`). Capped at 20 entries.",
    },
    OptionSchema {
        name: "vs_currency",
        type_hint: "string (lowercase 2-5 letter code)",
        required: false,
        default: Some("\"usd\""),
        description: "Quote currency code passed to CoinGecko (e.g. `usd`, `jpy`, `eur`, `btc`). Letters only.",
    },
];

pub struct CryptoWatchlistFetcher;

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
    #[serde(default)]
    pub coins: Option<Vec<String>>,
    #[serde(default)]
    pub vs_currency: Option<String>,
}

#[async_trait]
impl Fetcher for CryptoWatchlistFetcher {
    fn name(&self) -> &str {
        "crypto_watchlist"
    }
    fn safety(&self) -> Safety {
        Safety::Safe
    }
    fn description(&self) -> &'static str {
        "CoinGecko price snapshot for a configurable list of coins. `Entries` (default) / `Text` / `TextBlock` / `MarkdownTextBlock` / `LinkedTextBlock` summarise spot price + 24h change; `NumberSeries` carries the first coin's 7-day hourly price as cents above the period low (so high-magnitude assets like BTC don't flatten the sparkline); `PointSeries` carries one series per coin for line / scatter charts; `Bars` ranks coins by 24h |% change| (basis points); `Badge` flags the top mover. No API key required."
    }
    fn refresh_interval(&self) -> u64 {
        60 * 5
    }
    fn shapes(&self) -> &[Shape] {
        SHAPES
    }
    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> {
        body_for_shape(&sample_snapshot(), shape)
    }
    async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
        let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
        let coins = resolve_coins(opts.coins.as_deref())?;
        let vs_currency = resolve_vs_currency(opts.vs_currency.as_deref())?;
        let snapshot = fetch_snapshot(&coins, &vs_currency).await?;
        let shape = ctx.shape.unwrap_or(Shape::Entries);
        let body = body_for_shape(&snapshot, shape).unwrap_or_else(|| entries_body(&snapshot));
        Ok(payload(body))
    }
}

fn body_for_shape(snapshot: &Snapshot, shape: Shape) -> Option<Body> {
    Some(match shape {
        Shape::Entries => entries_body(snapshot),
        Shape::Text => Body::Text(TextData {
            value: text_value(snapshot),
        }),
        Shape::TextBlock => text_block_body(snapshot),
        Shape::MarkdownTextBlock => markdown_block_body(snapshot),
        Shape::LinkedTextBlock => linked_text_block_body(snapshot),
        Shape::NumberSeries => number_series_body(snapshot),
        Shape::PointSeries => point_series_body(snapshot),
        Shape::Bars => bars_body(snapshot),
        Shape::Badge => Body::Badge(badge_for(snapshot)),
        _ => return None,
    })
}

fn resolve_coins(raw: Option<&[String]>) -> Result<Vec<String>, FetchError> {
    let supplied = match raw {
        Some(items) if !items.is_empty() => {
            items.iter().map(|s| s.to_lowercase()).collect::<Vec<_>>()
        }
        _ => DEFAULT_COINS.iter().map(|s| (*s).to_string()).collect(),
    };
    let coins: Vec<String> = supplied
        .iter()
        .filter_map(|c| sanitise_coin_id(c))
        .take(MAX_COINS)
        .collect();
    if coins.is_empty() {
        return Err(FetchError::Failed(
            "crypto_watchlist: `coins` must contain at least one CoinGecko coin id".into(),
        ));
    }
    Ok(coins)
}

fn resolve_vs_currency(raw: Option<&str>) -> Result<String, FetchError> {
    let value = raw
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_lowercase)
        .unwrap_or_else(|| DEFAULT_VS_CURRENCY.to_string());
    let valid_len = (2..=5).contains(&value.len());
    if !valid_len || !value.chars().all(|c| c.is_ascii_lowercase()) {
        return Err(FetchError::Failed(format!(
            "crypto_watchlist: invalid vs_currency `{value}` (lowercase 2-5 letter code)"
        )));
    }
    Ok(value)
}

fn sanitise_coin_id(raw: &str) -> Option<String> {
    let id = raw.trim().to_lowercase();
    let valid_len = (1..=64).contains(&id.len());
    let valid_chars = id
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    (valid_len && valid_chars).then_some(id)
}

async fn fetch_snapshot(coins: &[String], vs_currency: &str) -> Result<Snapshot, FetchError> {
    let url = build_url(coins, vs_currency);
    let res = http()
        .get(&url)
        .send()
        .await
        .map_err(|e| FetchError::Failed(format!("crypto_watchlist request failed: {e}")))?;
    let status = res.status();
    if !status.is_success() {
        return Err(FetchError::Failed(format!("crypto_watchlist {status}")));
    }
    let bytes = res
        .bytes()
        .await
        .map_err(|e| FetchError::Failed(format!("crypto_watchlist read body: {e}")))?;
    if bytes.len() > MAX_BYTES {
        return Err(FetchError::Failed(format!(
            "crypto_watchlist response too large ({} bytes, cap {MAX_BYTES})",
            bytes.len()
        )));
    }
    let raw: Vec<ApiCoin> = serde_json::from_slice(&bytes)
        .map_err(|e| FetchError::Failed(format!("crypto_watchlist json parse: {e}")))?;
    Ok(Snapshot {
        vs_currency: vs_currency.to_string(),
        coins: raw.into_iter().filter_map(CoinPoint::from_api).collect(),
    })
}

fn build_url(coins: &[String], vs_currency: &str) -> String {
    let ids = coins.join(",");
    format!(
        "{API_BASE}?vs_currency={vs_currency}&ids={ids}\
         &sparkline=true&order=market_cap_desc&per_page={count}&page=1",
        count = coins.len(),
    )
}

fn http() -> &'static Client {
    static CLIENT: OnceLock<Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        Client::builder()
            .user_agent(USER_AGENT)
            .timeout(REQUEST_TIMEOUT)
            .gzip(true)
            .build()
            .expect("reqwest client should build with default config")
    })
}

#[derive(Debug, Deserialize)]
struct ApiCoin {
    id: String,
    symbol: String,
    #[allow(dead_code)]
    name: String,
    #[serde(default)]
    current_price: Option<f64>,
    #[serde(default)]
    price_change_percentage_24h: Option<f64>,
    #[serde(default)]
    sparkline_in_7d: Option<ApiSparkline>,
}

#[derive(Debug, Deserialize)]
struct ApiSparkline {
    #[serde(default)]
    price: Vec<f64>,
}

#[derive(Debug, Clone)]
struct Snapshot {
    vs_currency: String,
    coins: Vec<CoinPoint>,
}

#[derive(Debug, Clone)]
struct CoinPoint {
    id: String,
    symbol: String,
    price: f64,
    change_24h: f64,
    sparkline: Vec<f64>,
}

impl CoinPoint {
    fn from_api(c: ApiCoin) -> Option<Self> {
        let price = c.current_price?;
        let symbol = c.symbol.to_uppercase();
        let change = c.price_change_percentage_24h.unwrap_or(0.0);
        let sparkline = c.sparkline_in_7d.map(|s| s.price).unwrap_or_default();
        Some(Self {
            id: c.id,
            symbol,
            price,
            change_24h: change,
            sparkline,
        })
    }

    fn arrow(&self) -> char {
        if self.change_24h > 0.0 {
            '▲'
        } else if self.change_24h < 0.0 {
            '▼'
        } else {
            '·'
        }
    }
}

fn entries_body(snapshot: &Snapshot) -> Body {
    Body::Entries(EntriesData {
        items: snapshot
            .coins
            .iter()
            .map(|c| Entry {
                key: c.symbol.clone(),
                value: Some(format!(
                    "{} ({})",
                    format_price(c.price, &snapshot.vs_currency),
                    format_change(c.change_24h),
                )),
                status: Some(status_for_change(c.change_24h)),
            })
            .collect(),
    })
}

fn text_value(snapshot: &Snapshot) -> String {
    match top_mover(snapshot) {
        Some(c) => format!(
            "{} {} {} ({})",
            c.arrow(),
            c.symbol,
            format_price(c.price, &snapshot.vs_currency),
            format_change(c.change_24h),
        ),
        None => "no coins".into(),
    }
}

fn text_block_body(snapshot: &Snapshot) -> Body {
    Body::TextBlock(TextBlockData {
        lines: snapshot
            .coins
            .iter()
            .map(|c| line_for(c, &snapshot.vs_currency))
            .collect(),
    })
}

fn markdown_block_body(snapshot: &Snapshot) -> Body {
    let value = snapshot
        .coins
        .iter()
        .map(|c| {
            format!(
                "- **{}** {} ({})",
                c.symbol,
                format_price(c.price, &snapshot.vs_currency),
                format_change(c.change_24h),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    Body::MarkdownTextBlock(MarkdownTextBlockData { value })
}

fn linked_text_block_body(snapshot: &Snapshot) -> Body {
    Body::LinkedTextBlock(LinkedTextBlockData {
        items: snapshot
            .coins
            .iter()
            .map(|c| LinkedLine {
                text: line_for(c, &snapshot.vs_currency),
                url: Some(format!("{COIN_PAGE_BASE}/{}", c.id)),
            })
            .collect(),
    })
}

/// First coin's 7-day hourly price as cents *above the period minimum*.
/// `chart_sparkline` normalises against the series max with an implicit zero floor, so emitting
/// raw cents flattens the trace for any high-magnitude asset — BTC at \$40k with a 1 % daily
/// swing collapses into a band glued to the top of the slot. Subtract the period minimum so the
/// variation occupies the full bar height; the unit becomes "cents above the 7d low" rather
/// than "cents", which is the right framing for a glance-at-the-trend sparkline anyway.
fn number_series_body(snapshot: &Snapshot) -> Body {
    let values: Vec<u64> = snapshot
        .coins
        .first()
        .map(|c| {
            let baseline = c
                .sparkline
                .iter()
                .copied()
                .filter(|p| p.is_finite())
                .fold(f64::INFINITY, f64::min);
            let baseline = if baseline.is_finite() { baseline } else { 0.0 };
            c.sparkline
                .iter()
                .map(|p| ((*p - baseline).max(0.0) * 100.0).round() as u64)
                .collect()
        })
        .unwrap_or_default();
    Body::NumberSeries(NumberSeriesData { values })
}

fn point_series_body(snapshot: &Snapshot) -> Body {
    Body::PointSeries(PointSeriesData {
        series: snapshot
            .coins
            .iter()
            .take(MAX_SERIES_COINS)
            .map(|c| PointSeries {
                name: c.symbol.clone(),
                points: c
                    .sparkline
                    .iter()
                    .enumerate()
                    .map(|(i, p)| (i as f64, *p))
                    .collect(),
            })
            .collect(),
    })
}

fn bars_body(snapshot: &Snapshot) -> Body {
    Body::Bars(BarsData {
        bars: snapshot
            .coins
            .iter()
            .map(|c| Bar {
                label: format!("{} {}", c.arrow(), c.symbol),
                value: (c.change_24h.abs() * PCT_TO_BP).round() as u64,
                value_label: None,
            })
            .collect(),
    })
}

fn badge_for(snapshot: &Snapshot) -> BadgeData {
    match top_mover(snapshot) {
        Some(c) => BadgeData {
            status: status_for_change(c.change_24h),
            label: format!("{} {} {}", c.arrow(), c.symbol, format_change(c.change_24h)),
        },
        None => BadgeData {
            status: Status::Warn,
            label: "no coins".into(),
        },
    }
}

fn line_for(c: &CoinPoint, vs_currency: &str) -> String {
    format!(
        "{} {}  {}  {}",
        c.arrow(),
        c.symbol,
        format_price(c.price, vs_currency),
        format_change(c.change_24h),
    )
}

fn top_mover(snapshot: &Snapshot) -> Option<&CoinPoint> {
    snapshot.coins.iter().max_by(|a, b| {
        a.change_24h
            .abs()
            .partial_cmp(&b.change_24h.abs())
            .unwrap_or(std::cmp::Ordering::Equal)
    })
}

/// Volatility-coded status — large absolute moves flip Ok → Warn so the badge stands out.
/// Direction itself is encoded in the arrow / sign in the label, leaving the status agnostic
/// to whether the user is long or short.
fn status_for_change(change_pct: f64) -> Status {
    if change_pct.abs() >= VOLATILITY_THRESHOLD {
        Status::Warn
    } else {
        Status::Ok
    }
}

fn format_price(value: f64, vs_currency: &str) -> String {
    let amount = format_amount(value);
    match currency_symbol(vs_currency) {
        Some(sym) => format!("{sym}{amount}"),
        None => format!("{amount} {}", vs_currency.to_uppercase()),
    }
}

fn currency_symbol(code: &str) -> Option<&'static str> {
    match code {
        "usd" => Some("$"),
        "eur" => Some("€"),
        "jpy" => Some("¥"),
        "gbp" => Some("£"),
        "krw" => Some("₩"),
        _ => None,
    }
}

/// Locale-agnostic "1,234.56" with adaptive precision: ≥ 1 → 2 decimals, ≥ 0.01 → 4 decimals,
/// otherwise → 6 decimals. Keeps meme-coin sub-cent prices from collapsing to `0.00`.
fn format_amount(value: f64) -> String {
    let abs = value.abs();
    let formatted = if abs == 0.0 {
        "0.00".to_string()
    } else if abs < 0.01 {
        format!("{value:.6}")
    } else if abs < 1.0 {
        format!("{value:.4}")
    } else {
        format!("{value:.2}")
    };
    add_thousands_separators(&formatted)
}

fn add_thousands_separators(formatted: &str) -> String {
    let (int_part, fractional) = formatted.split_once('.').unwrap_or((formatted, ""));
    let (sign, digits) = match int_part.strip_prefix('-') {
        Some(rest) => ("-", rest),
        None => ("", int_part),
    };
    let with_sep: String = digits
        .as_bytes()
        .rchunks(3)
        .rev()
        .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
        .collect::<Vec<_>>()
        .join(",");
    if fractional.is_empty() {
        format!("{sign}{with_sep}")
    } else {
        format!("{sign}{with_sep}.{fractional}")
    }
}

fn format_change(pct: f64) -> String {
    let sign = if pct >= 0.0 { "+" } else { "" };
    format!("{sign}{pct:.2}%")
}

fn parse_options<T: serde::de::DeserializeOwned + Default>(
    raw: Option<&toml::Value>,
) -> Result<T, String> {
    match raw {
        None => Ok(T::default()),
        Some(value) => value
            .clone()
            .try_into::<T>()
            .map_err(|e| format!("invalid options: {e}")),
    }
}

fn payload(body: Body) -> Payload {
    Payload {
        icon: None,
        status: None,
        format: None,
        body,
    }
}

fn sample_snapshot() -> Snapshot {
    let make_sparkline = |start: f64, drift: f64| -> Vec<f64> {
        (0..168u32)
            .map(|i| start + drift * (i as f64 / 24.0) + ((i as f64 / 8.0).sin() * start * 0.01))
            .collect()
    };
    Snapshot {
        vs_currency: "usd".into(),
        coins: vec![
            CoinPoint {
                id: "bitcoin".into(),
                symbol: "BTC".into(),
                price: 42_150.32,
                change_24h: 2.34,
                sparkline: make_sparkline(41_000.0, 8.0),
            },
            CoinPoint {
                id: "ethereum".into(),
                symbol: "ETH".into(),
                price: 2_280.18,
                change_24h: -1.21,
                sparkline: make_sparkline(2_300.0, -1.0),
            },
        ],
    }
}

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

    fn snapshot_with(changes: &[(f64, f64)]) -> Snapshot {
        Snapshot {
            vs_currency: "usd".into(),
            coins: changes
                .iter()
                .enumerate()
                .map(|(i, (price, change))| CoinPoint {
                    id: format!("coin-{i}"),
                    symbol: format!("C{i}"),
                    price: *price,
                    change_24h: *change,
                    sparkline: vec![*price; 4],
                })
                .collect(),
        }
    }

    #[test]
    fn build_url_joins_ids_with_commas_and_includes_sparkline() {
        let url = build_url(&["bitcoin".into(), "ethereum".into()], "jpy");
        assert!(url.starts_with(API_BASE));
        assert!(url.contains("vs_currency=jpy"));
        assert!(url.contains("ids=bitcoin,ethereum"));
        assert!(url.contains("sparkline=true"));
        assert!(url.contains("per_page=2"));
    }

    #[test]
    fn sanitise_coin_id_accepts_lowercase_alnum_and_hyphen() {
        assert_eq!(sanitise_coin_id("bitcoin"), Some("bitcoin".into()));
        assert_eq!(sanitise_coin_id("0x0-token"), Some("0x0-token".into()));
        assert_eq!(sanitise_coin_id("  ETHEREUM  "), Some("ethereum".into()));
    }

    #[test]
    fn sanitise_coin_id_rejects_special_characters() {
        // The id is interpolated into the query string — accepting `&` or `=` would let a user
        // sneak extra params in. Lock the allowlist down to alphanumeric + hyphen.
        for bad in ["", "btc&ids=evil", "btc=evil", "btc/eth", "btc.eth", " "] {
            assert_eq!(sanitise_coin_id(bad), None, "should reject {bad:?}");
        }
    }

    #[test]
    fn resolve_coins_falls_back_to_default_when_unset_or_empty() {
        assert_eq!(resolve_coins(None).unwrap(), vec!["bitcoin", "ethereum"]);
        let empty: Vec<String> = vec![];
        assert_eq!(
            resolve_coins(Some(&empty)).unwrap(),
            vec!["bitcoin", "ethereum"]
        );
    }

    #[test]
    fn resolve_coins_drops_invalid_ids_and_caps_to_max() {
        let mut input: Vec<String> = (0..MAX_COINS + 5).map(|i| format!("valid{i}")).collect();
        input.insert(0, "bad&id".into());
        let coins = resolve_coins(Some(&input)).unwrap();
        assert_eq!(coins.len(), MAX_COINS);
        assert!(coins.iter().all(|c| !c.contains('&')));
    }

    #[test]
    fn resolve_coins_errors_when_every_id_is_invalid() {
        let bad: Vec<String> = vec!["?".into(), "/".into(), "".into()];
        assert!(resolve_coins(Some(&bad)).is_err());
    }

    #[test]
    fn resolve_vs_currency_defaults_to_usd_and_lowercases() {
        assert_eq!(resolve_vs_currency(None).unwrap(), "usd");
        assert_eq!(resolve_vs_currency(Some("  ")).unwrap(), "usd");
        assert_eq!(resolve_vs_currency(Some("JPY")).unwrap(), "jpy");
    }

    #[test]
    fn resolve_vs_currency_rejects_non_letters_and_wrong_length() {
        // Same injection concern as `coins`: vs_currency goes straight into the URL query.
        // (Surrounding whitespace is intentionally trimmed before validation, so `"us "` would
        // accept as `"us"` — that case lives in the happy-path test instead.)
        for bad in ["u", "verylong", "us1", "us&ids=evil", "u s"] {
            assert!(
                resolve_vs_currency(Some(bad)).is_err(),
                "should reject {bad:?}"
            );
        }
    }

    #[test]
    fn currency_symbol_known_codes_get_glyphs_others_get_iso() {
        assert_eq!(format_price(1234.5, "usd"), "$1,234.50");
        assert_eq!(format_price(1234.5, "jpy"), "¥1,234.50");
        assert_eq!(format_price(1234.5, "krw"), "₩1,234.50");
        // Unknown codes fall through to "<amount> <CODE>" so config can still pick exotic
        // currencies (e.g. CoinGecko supports `vs_currency = "btc"`).
        assert_eq!(format_price(0.001234, "btc"), "0.001234 BTC");
    }

    #[test]
    fn format_amount_picks_precision_by_magnitude() {
        assert_eq!(format_amount(0.0), "0.00");
        assert_eq!(format_amount(0.0001234), "0.000123");
        assert_eq!(format_amount(0.5432), "0.5432");
        assert_eq!(format_amount(42_150.327), "42,150.33");
        assert_eq!(format_amount(-1_500_000.5), "-1,500,000.50");
    }

    #[test]
    fn format_change_keeps_sign_and_two_decimals() {
        assert_eq!(format_change(2.345), "+2.35%");
        assert_eq!(format_change(-1.2), "-1.20%");
        assert_eq!(format_change(0.0), "+0.00%");
    }

    #[test]
    fn status_for_change_flips_above_volatility_threshold() {
        assert_eq!(status_for_change(0.0), Status::Ok);
        assert_eq!(status_for_change(4.99), Status::Ok);
        assert_eq!(status_for_change(5.0), Status::Warn);
        assert_eq!(status_for_change(-7.5), Status::Warn);
    }

    #[test]
    fn top_mover_picks_largest_absolute_change() {
        let snap = snapshot_with(&[(1.0, 1.5), (2.0, -3.2), (3.0, 0.5)]);
        let top = top_mover(&snap).unwrap();
        assert_eq!(top.symbol, "C1");
    }

    #[test]
    fn top_mover_returns_none_for_empty_snapshot() {
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![],
        };
        assert!(top_mover(&snap).is_none());
    }

    #[test]
    fn entries_body_marks_each_row_with_volatility_status() {
        let snap = snapshot_with(&[(100.0, 1.0), (200.0, 6.0)]);
        assert!(matches!(
            entries_body(&snap),
            Body::Entries(data)
                if data.items.len() == 2
                    && data.items[0].status == Some(Status::Ok)
                    && data.items[1].status == Some(Status::Warn)
                    && data.items[0]
                        .value
                        .as_deref()
                        .is_some_and(|v| v.contains("(+1.00%)"))
        ));
    }

    #[test]
    fn number_series_carries_first_coin_sparkline_as_cents_above_period_low() {
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![CoinPoint {
                id: "x".into(),
                symbol: "X".into(),
                price: 10.0,
                change_24h: 0.0,
                sparkline: vec![1.234, -0.5, 0.0, 12.345_678],
            }],
        };
        // baseline = -0.5; deviations (cents): 1.734→173, 0.0→0, 0.5→50, 12.845_678→1285.
        assert!(matches!(
            number_series_body(&snap),
            Body::NumberSeries(d) if d.values == vec![173, 0, 50, 1285]
        ));
    }

    #[test]
    fn number_series_high_magnitude_asset_lifts_off_the_top_of_the_slot() {
        // The whole point of the baseline-subtraction is that a `[40_000.0, 40_400.0]` swing
        // (1 % daily) reaches the full height of the sparkline instead of collapsing into a
        // flat band at the top. `value / max` on raw cents would put both at >= 0.99.
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![CoinPoint {
                id: "btc".into(),
                symbol: "BTC".into(),
                price: 40_400.0,
                change_24h: 1.0,
                sparkline: vec![40_000.0, 40_200.0, 40_400.0],
            }],
        };
        assert!(matches!(
            number_series_body(&snap),
            Body::NumberSeries(d) if d.values == vec![0, 20_000, 40_000]
        ));
    }

    #[test]
    fn point_series_caps_to_max_series_coins_and_indexes_x_by_hour() {
        let snap = snapshot_with(&[(1.0, 0.0); 7]);
        assert!(matches!(
            point_series_body(&snap),
            Body::PointSeries(d)
                if d.series.len() == MAX_SERIES_COINS
                    && d.series[0].points.first().is_some_and(|p| p.0 == 0.0)
        ));
    }

    #[test]
    fn bars_body_encodes_basis_points_and_direction_arrow() {
        let snap = snapshot_with(&[(1.0, 2.5), (1.0, -3.0), (1.0, 0.0)]);
        assert!(matches!(
            bars_body(&snap),
            Body::Bars(d)
                if d.bars[0].value == 250
                    && d.bars[1].value == 300
                    && d.bars[2].value == 0
                    && d.bars[0].label.starts_with('▲')
                    && d.bars[1].label.starts_with('▼')
                    && d.bars[2].label.starts_with('·')
        ));
    }

    #[test]
    fn linked_text_block_links_each_row_to_the_coin_page() {
        let snap = snapshot_with(&[(1.0, 1.0)]);
        assert!(matches!(
            linked_text_block_body(&snap),
            Body::LinkedTextBlock(d)
                if d.items[0].url.as_deref()
                    == Some("https://www.coingecko.com/en/coins/coin-0")
        ));
    }

    #[test]
    fn markdown_block_lists_coins_with_bold_symbol() {
        let snap = snapshot_with(&[(1.0, 1.0)]);
        assert!(matches!(
            markdown_block_body(&snap),
            Body::MarkdownTextBlock(d) if d.value.starts_with("- **C0**")
        ));
    }

    #[test]
    fn text_value_falls_back_to_no_coins_for_empty_snapshot() {
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![],
        };
        assert_eq!(text_value(&snap), "no coins");
    }

    #[test]
    fn text_value_includes_arrow_symbol_and_change_for_top_mover() {
        let snap = snapshot_with(&[(1.0, 1.0), (2.0, -7.5)]);
        let value = text_value(&snap);
        assert!(value.contains("C1"), "value: {value}");
        assert!(value.contains("-7.50%"), "value: {value}");
        assert!(value.starts_with('▼'), "value: {value}");
    }

    #[test]
    fn text_block_body_emits_one_line_per_coin() {
        let snap = snapshot_with(&[(1.0, 1.0), (2.0, 0.0)]);
        assert!(matches!(
            text_block_body(&snap),
            Body::TextBlock(d)
                if d.lines.len() == 2
                    && d.lines[0].contains("C0")
                    && d.lines[1].contains("C1")
        ));
    }

    #[test]
    fn currency_symbol_covers_eur_and_gbp_glyphs() {
        // Earlier test exercises usd / jpy / krw / unknown. eur / gbp are the remaining
        // members of the symbol table — pin them here so the table can't drift silently.
        assert_eq!(format_price(1234.5, "eur"), "€1,234.50");
        assert_eq!(format_price(1234.5, "gbp"), "£1,234.50");
    }

    #[test]
    fn add_thousands_separators_handles_integer_only_strings() {
        // `format_amount` always emits a fractional part, so the integer-only branch is
        // unreachable through it. Pin the helper directly so the no-fractional formatter
        // (used if a caller ever emits a bare integer) keeps working.
        assert_eq!(add_thousands_separators("1000000"), "1,000,000");
        assert_eq!(add_thousands_separators("-1000000"), "-1,000,000");
        assert_eq!(add_thousands_separators("42"), "42");
    }

    #[test]
    fn payload_helper_strips_chrome_and_wraps_body() {
        let p = payload(Body::Text(TextData {
            value: "hello".into(),
        }));
        assert!(p.icon.is_none());
        assert!(p.status.is_none());
        assert!(p.format.is_none());
        assert!(matches!(p.body, Body::Text(t) if t.value == "hello"));
    }

    #[test]
    fn http_singleton_returns_the_same_client_across_calls() {
        // Mirrors the `OnceLock` reuse assertion used by every other family with a shared
        // client (stock_watchlist, deariary, github, reddit). Catches a regression where the
        // builder gets called per invocation instead of memoised once.
        assert!(std::ptr::eq(http(), http()));
    }

    #[test]
    fn number_series_body_uses_zero_baseline_when_sparkline_has_no_finite_points() {
        // The `if baseline.is_finite()` branch only flips to `0.0` when every sample in the
        // sparkline is non-finite, so `fold(INFINITY, min)` never finds a real low. With
        // NaN inputs `(NaN - 0).max(0)` returns 0, so every output cell collapses to 0.
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![CoinPoint {
                id: "x".into(),
                symbol: "X".into(),
                price: 0.0,
                change_24h: 0.0,
                sparkline: vec![f64::NAN, f64::NAN],
            }],
        };
        assert!(matches!(
            number_series_body(&snap),
            Body::NumberSeries(d) if d.values == vec![0, 0]
        ));
    }

    #[test]
    fn badge_carries_top_mover_label_with_volatility_status() {
        let snap = snapshot_with(&[(1.0, 1.0), (1.0, -7.5)]);
        let badge = badge_for(&snap);
        assert!(badge.label.contains("C1"));
        assert!(badge.label.contains("-7.50"));
        assert_eq!(badge.status, Status::Warn);
    }

    #[test]
    fn badge_for_empty_snapshot_falls_back_to_no_coins() {
        let snap = Snapshot {
            vs_currency: "usd".into(),
            coins: vec![],
        };
        let badge = badge_for(&snap);
        assert_eq!(badge.label, "no coins");
        assert_eq!(badge.status, Status::Warn);
    }

    #[test]
    fn parse_options_accepts_empty_input_and_rejects_unknown_keys() {
        assert!(parse_options::<Options>(None).unwrap().coins.is_none());
        let raw: toml::Value = toml::from_str("coins = [\"bitcoin\"]").unwrap();
        let parsed: Options = parse_options(Some(&raw)).unwrap();
        assert_eq!(parsed.coins.unwrap(), vec!["bitcoin"]);
        let bogus: toml::Value = toml::from_str("coins = [\"bitcoin\"]\nbogus = true").unwrap();
        assert!(parse_options::<Options>(Some(&bogus)).is_err());
    }

    #[test]
    fn api_response_deserialises_coingecko_markets_row() {
        let raw = r#"[{
            "id":"bitcoin","symbol":"btc","name":"Bitcoin",
            "current_price":42150.32,"price_change_percentage_24h":2.34,
            "sparkline_in_7d":{"price":[41000.0,41100.0]}
        }]"#;
        let parsed: Vec<ApiCoin> = serde_json::from_str(raw).unwrap();
        let coin = CoinPoint::from_api(parsed.into_iter().next().unwrap()).unwrap();
        assert_eq!(coin.symbol, "BTC");
        assert_eq!(coin.price, 42150.32);
        assert_eq!(coin.sparkline.len(), 2);
    }

    #[test]
    fn coin_point_from_api_drops_rows_without_a_price() {
        let coin = CoinPoint::from_api(ApiCoin {
            id: "x".into(),
            symbol: "x".into(),
            name: "x".into(),
            current_price: None,
            price_change_percentage_24h: None,
            sparkline_in_7d: None,
        });
        assert!(coin.is_none());
    }

    #[test]
    fn fetcher_metadata_cache_key_and_samples_cover_supported_shapes() {
        let fetcher = CryptoWatchlistFetcher;
        let ctx = FetchContext {
            widget_id: "crypto".into(),
            timeout: Duration::from_secs(1),
            shape: Some(Shape::Entries),
            ..Default::default()
        };
        let with_options = FetchContext {
            options: Some(toml::from_str("coins = [\"bitcoin\"]\nvs_currency = \"jpy\"").unwrap()),
            ..ctx.clone()
        };
        assert_eq!(fetcher.name(), "crypto_watchlist");
        assert_eq!(fetcher.safety(), Safety::Safe);
        assert!(fetcher.description().contains("CoinGecko"));
        assert_eq!(
            fetcher
                .option_schemas()
                .iter()
                .map(|s| s.name)
                .collect::<Vec<_>>(),
            vec!["coins", "vs_currency"]
        );
        assert_eq!(fetcher.shapes(), SHAPES);
        assert_eq!(fetcher.default_shape(), Shape::Entries);
        assert_ne!(fetcher.cache_key(&ctx), fetcher.cache_key(&with_options));
        for &shape in fetcher.shapes() {
            // Avoid `unwrap_or_else(|| panic!(...))` — its panic arm registers as an uncovered
            // region per [[feedback_coverage_test_panic_arms]]. Two-step: assert presence, then
            // re-fetch under matches! with the shape-equality guard inside the arm.
            assert!(
                fetcher.sample_body(shape).is_some(),
                "missing sample for {shape:?}",
            );
            let body = fetcher.sample_body(shape).unwrap();
            assert_eq!(
                crate::render::shape_of(&body),
                shape,
                "sample shape mismatch for {shape:?}"
            );
        }
        assert!(fetcher.sample_body(Shape::Image).is_none());
        assert!(fetcher.sample_body(Shape::Calendar).is_none());
        assert!(fetcher.sample_body(Shape::Heatmap).is_none());
        assert!(fetcher.sample_body(Shape::Timeline).is_none());
        assert!(fetcher.sample_body(Shape::Ratio).is_none());
    }

    #[tokio::test]
    async fn fetch_rejects_invalid_vs_currency_before_network() {
        let ctx = FetchContext {
            widget_id: "crypto".into(),
            timeout: Duration::from_secs(1),
            shape: Some(Shape::Entries),
            options: Some(toml::from_str("vs_currency = \"u\"").unwrap()),
            ..Default::default()
        };
        let err = CryptoWatchlistFetcher.fetch(&ctx).await.unwrap_err();
        assert!(matches!(err, FetchError::Failed(msg) if msg.contains("vs_currency")));
    }

    /// Live smoke test — hits CoinGecko. `#[ignore]` keeps CI offline-safe; run with
    /// `cargo test -- --ignored fetcher::crypto_watchlist::tests::live` to verify the real API.
    #[tokio::test]
    #[ignore]
    async fn live_default_watchlist_returns_two_coins() {
        let snapshot = fetch_snapshot(&["bitcoin".into(), "ethereum".into()], "usd")
            .await
            .unwrap();
        assert!(!snapshot.coins.is_empty(), "expected at least one coin");
        for coin in &snapshot.coins {
            eprintln!(
                "{} {} ({})",
                coin.symbol,
                format_price(coin.price, &snapshot.vs_currency),
                format_change(coin.change_24h),
            );
        }
    }
}
