//! Mouser Electronics API v2 client + response normalization.
//!
//! Two ways to resolve a query:
//!   1. Direct — requires MOUSER_API_KEY. Used by `serve` and by `search`
//!      / `part` / `health` when no backend URL is configured.
//!   2. Via a backend — the CLI hits `POST <api>/` and lets the backend
//!      hold the key + cache. Used by `search` / `part` / `health` when
//!      MOUSER_API is set.

use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{json, Value};
use std::time::Duration;

/// Accept u32 as either a JSON number or a numeric string. The legacy
/// Node backend returns `min_order` / `mult` as strings ("1"); a native
/// `adom-mouser serve` returns them as numbers. Handle both.
fn de_u32_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> {
    let v = Value::deserialize(d)?;
    match v {
        Value::Number(n) => n.as_u64().map(|n| n as u32).ok_or_else(|| serde::de::Error::custom("not u32")),
        Value::String(s) => s.parse().map_err(serde::de::Error::custom),
        Value::Null => Ok(1),
        other => Err(serde::de::Error::custom(format!("expected u32 or numeric string, got {:?}", other))),
    }
}

fn de_u64_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
    let v = Value::deserialize(d)?;
    match v {
        Value::Number(n) => n.as_u64().ok_or_else(|| serde::de::Error::custom("not u64")),
        Value::String(s) => s.replace(',', "").parse().map_err(serde::de::Error::custom),
        Value::Null => Ok(0),
        other => Err(serde::de::Error::custom(format!("expected u64 or numeric string, got {:?}", other))),
    }
}

const MOUSER_API_BASE: &str = "https://api.mouser.com/api/v2";
const FETCH_TIMEOUT_SECS: u64 = 15;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceTier {
    #[serde(deserialize_with = "de_u32_lenient")]
    pub qty: u32,
    pub price: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attribute {
    pub name: String,
    pub value: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Component {
    pub mouser_pn: String,
    pub mpn: String,
    pub manufacturer: String,
    pub description: String,
    pub category: String,
    #[serde(deserialize_with = "de_u64_lenient")]
    pub stock: u64,
    pub stock_text: String,
    pub popularity: String,
    pub lead_time: String,
    pub unit_price: Option<String>,
    pub price_tiers: Vec<PriceTier>,
    #[serde(deserialize_with = "de_u32_lenient")]
    pub min_order: u32,
    #[serde(deserialize_with = "de_u32_lenient")]
    pub mult: u32,
    pub rohs: String,
    pub lifecycle: String,
    pub datasheet_url: Option<String>,
    pub product_url: Option<String>,
    pub image_url: Option<String>,
    pub attributes: Vec<Attribute>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub components: Vec<Component>,
    pub count: u64,
    pub query: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exact_match: Option<bool>,
}

fn parse_stock(availability: &str) -> u64 {
    // "1,234 In Stock" → 1234.
    let s: String = availability
        .chars()
        .take_while(|c| c.is_ascii_digit() || *c == ',')
        .collect();
    s.replace(',', "").parse().unwrap_or(0)
}

fn normalize_part(p: &Value) -> Component {
    let avail = p.get("Availability").and_then(|v| v.as_str()).unwrap_or("");
    let stock = parse_stock(avail);
    let popularity = if stock < 1000 {
        "low"
    } else if stock < 10_000 {
        "medium"
    } else {
        "high"
    };

    let price_tiers: Vec<PriceTier> = p
        .get("PriceBreaks")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|pb| {
                    Some(PriceTier {
                        qty: pb.get("Quantity")?.as_u64()? as u32,
                        price: pb.get("Price")?.as_str()?.to_string(),
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let unit_price = price_tiers.first().map(|t| t.price.clone());

    let attributes: Vec<Attribute> = p
        .get("ProductAttributes")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|a| {
                    let name = a.get("AttributeName")?.as_str()?.to_string();
                    let value = a.get("AttributeValue")?.as_str()?.to_string();
                    if value.is_empty() || value.contains("MouseReel") {
                        return None;
                    }
                    Some(Attribute { name, value })
                })
                .collect()
        })
        .unwrap_or_default();

    let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
    let u = |k: &str, d: u32| p.get(k).and_then(|v| v.as_u64()).map(|n| n as u32).unwrap_or(d);
    let os = |k: &str| p.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());

    Component {
        mouser_pn: s("MouserPartNumber"),
        mpn: s("ManufacturerPartNumber"),
        manufacturer: s("Manufacturer"),
        description: s("Description"),
        category: s("Category"),
        stock,
        stock_text: if avail.is_empty() { "Unknown".into() } else { avail.into() },
        popularity: popularity.into(),
        lead_time: s("LeadTime"),
        unit_price,
        price_tiers,
        min_order: u("Min", 1),
        mult: u("Mult", 1),
        rohs: s("ROHSStatus"),
        lifecycle: s("LifecycleStatus"),
        datasheet_url: os("DataSheetUrl"),
        product_url: os("ProductDetailUrl"),
        image_url: os("ImagePath"),
        attributes,
    }
}

/// Direct call to Mouser's `search/keyword` endpoint with a pre-built
/// request body. Returns normalized result.
pub fn mouser_keyword_search(
    api_key: &str,
    keyword: &str,
    records: u32,
    in_stock_only: bool,
) -> Result<Value, String> {
    if api_key.is_empty() {
        return Err("MOUSER_API_KEY is not set".into());
    }
    let url = format!("{}/search/keyword?apiKey={}", MOUSER_API_BASE, api_key);
    let body = json!({
        "SearchByKeywordRequest": {
            "keyword": keyword.trim(),
            "records": records.clamp(1, 50),
            "startingRecord": 0,
            "searchOptions": if in_stock_only { "InStock" } else { "" },
            "searchWithYourSignUpLanguage": "",
        }
    });
    let agent = ureq::AgentBuilder::new()
        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
        .build();
    let res = agent
        .post(&url)
        .set("Content-Type", "application/json")
        .set("Accept", "application/json")
        .send_json(body)
        .map_err(|e| match e {
            // ureq's Display impl for Error includes the full URL which
            // contains ?apiKey=<secret>. NEVER call e.to_string() — emit
            // a classified, URL-free message. Inspect the response body so
            // we can distinguish quota-exhaustion from key-revoked.
            ureq::Error::Status(code, r) => {
                let body_json: Value = r.into_json().unwrap_or(json!({}));
                let upstream_msg = body_json
                    .get("Errors")
                    .and_then(|v| v.as_array())
                    .and_then(|arr| arr.first())
                    .and_then(|e0| e0.get("Message").and_then(|m| m.as_str()))
                    .unwrap_or("")
                    .to_string();
                let upstream_code = body_json
                    .get("Errors")
                    .and_then(|v| v.as_array())
                    .and_then(|arr| arr.first())
                    .and_then(|e0| e0.get("Code").and_then(|m| m.as_str()))
                    .unwrap_or("")
                    .to_string();
                if !upstream_msg.is_empty() || !upstream_code.is_empty() {
                    format!("Mouser API HTTP {} — {} {}", code, upstream_code, upstream_msg)
                        .trim().to_string()
                } else {
                    format!("Mouser API HTTP {}", code)
                }
            }
            ureq::Error::Transport(t) => format!("Mouser transport error: {}", t.kind()),
        })?;
    let data: Value = res
        .into_json()
        .map_err(|e| format!("Mouser API JSON parse failed: {}", e))?;
    if let Some(errs) = data.get("Errors").and_then(|v| v.as_array()) {
        if !errs.is_empty() {
            let msg: Vec<String> = errs
                .iter()
                .filter_map(|e| {
                    e.get("Message")
                        .or_else(|| e.get("Code"))
                        .and_then(|v| v.as_str())
                        .map(|s| s.to_string())
                })
                .collect();
            let joined = msg.join("; ");
            // Quota tracker: upstream sometimes returns 2xx with
            // {Errors:[{Code:"TooManyRequests"}]}. Route that through
            // the same quota-hit path as the 403-body case.
            if joined.to_lowercase().contains("maximum calls per day") || joined.to_lowercase().contains("toomanyrequests") {
                if crate::quota::record_quota_hit() {
                    let q = crate::quota::get();
                    crate::quota::post_kel_alert(&format!(
                        "⚠️ *Mouser daily API quota exhausted.*\n\
                         Key used up for {} — resets at midnight CT.\n\
                         Usage today: {} searches, {} results returned.\n\
                         (DigiKey + JLCPCB are unaffected.)",
                        q.date_ct, q.searches, q.results_returned
                    ));
                }
            }
            return Err(format!("Mouser API: {}", joined));
        }
    }
    Ok(data)
}

pub fn search(
    api_key: &str,
    keyword: &str,
    limit: u32,
    in_stock_only: bool,
) -> Result<SearchResult, String> {
    if keyword.trim().is_empty() {
        return Err("keyword is required".into());
    }
    let data = mouser_keyword_search(api_key, keyword, limit, in_stock_only)?;
    let parts = data
        .pointer("/SearchResults/Parts")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let count = data
        .pointer("/SearchResults/NumberOfResult")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    Ok(SearchResult {
        components: parts.iter().map(normalize_part).collect(),
        count,
        query: keyword.trim().into(),
        exact_match: None,
    })
}

pub fn part(api_key: &str, part_number: &str) -> Result<SearchResult, String> {
    if part_number.trim().is_empty() {
        return Err("partNumber is required".into());
    }
    let data = mouser_keyword_search(api_key, part_number, 5, false)?;
    let parts = data
        .pointer("/SearchResults/Parts")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();
    let target = part_number.trim().to_lowercase();
    let exact = parts.iter().find(|p| {
        let mouser_pn = p.get("MouserPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
        let mpn = p.get("ManufacturerPartNumber").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
        mouser_pn == target || mpn == target
    });
    let chosen = exact.or_else(|| parts.first());
    match chosen {
        Some(p) => Ok(SearchResult {
            components: vec![normalize_part(p)],
            count: 1,
            query: part_number.trim().into(),
            exact_match: Some(exact.is_some()),
        }),
        None => Ok(SearchResult {
            components: vec![],
            count: 0,
            query: part_number.trim().into(),
            exact_match: Some(false),
        }),
    }
}

/// A direct Mouser live-ping used by `health` when a backend is not
/// reachable. Returns `Ok(count)` on success.
pub fn ping(api_key: &str) -> Result<u64, String> {
    let data = mouser_keyword_search(api_key, "resistor", 1, false)?;
    Ok(data
        .pointer("/SearchResults/NumberOfResult")
        .and_then(|v| v.as_u64())
        .unwrap_or(0))
}