//! DigiKey Products v4 API client + response normalization.
//!
//! Two ways to resolve a query:
//!   1. Direct — requires DIGIKEY_CLIENT_ID + DIGIKEY_CLIENT_SECRET. OAuth2
//!      client-credentials flow, bearer token cached in-process with ~80%
//!      of TTL as safety margin.
//!   2. Via a backend — the CLI hits `POST <api>/` and lets the backend hold
//!      the credentials + cache.

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

const DIGIKEY_API_BASE: &str = "https://api.digikey.com/products/v4";
const TOKEN_URL: &str = "https://api.digikey.com/v1/oauth2/token";
const FETCH_TIMEOUT_SECS: u64 = 30;

// ── OAuth2 token cache ─────────────────────────────────────────────
struct TokenCache {
    token: Option<String>,
    expires_at: Option<Instant>,
}
static TOKEN_CACHE: Mutex<TokenCache> = Mutex::new(TokenCache { token: None, expires_at: None });

fn get_access_token(client_id: &str, client_secret: &str) -> Result<String, String> {
    if client_id.is_empty() || client_secret.is_empty() {
        return Err("DIGIKEY_CLIENT_ID and DIGIKEY_CLIENT_SECRET must be set".into());
    }
    if let Ok(cache) = TOKEN_CACHE.lock() {
        if let (Some(tok), Some(exp)) = (cache.token.as_ref(), cache.expires_at) {
            if Instant::now() < exp {
                return Ok(tok.clone());
            }
        }
    }
    let agent = ureq::AgentBuilder::new()
        .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
        .build();
    let res = match agent
        .post(TOKEN_URL)
        .set("Content-Type", "application/x-www-form-urlencoded")
        .send_form(&[
            ("client_id", client_id),
            ("client_secret", client_secret),
            ("grant_type", "client_credentials"),
        ]) {
        Ok(r) => r,
        Err(ureq::Error::Status(code, r)) => {
            let body = r.into_string().unwrap_or_default();
            return Err(format!("OAuth token request failed: HTTP {}: {}", code, body));
        }
        Err(e) => return Err(format!("OAuth token request failed: {}", e)),
    };
    let data: Value = res.into_json().map_err(|e| format!("OAuth response parse: {}", e))?;
    let token = data.get("access_token").and_then(|v| v.as_str())
        .ok_or_else(|| format!("OAuth response missing access_token: {}", data))?.to_string();
    let ttl = data.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(600);
    let expires_at = Instant::now() + Duration::from_secs(ttl * 4 / 5);
    if let Ok(mut cache) = TOKEN_CACHE.lock() {
        cache.token = Some(token.clone());
        cache.expires_at = Some(expires_at);
    }
    Ok(token)
}

// ── Normalized types (same shape as adom-mouser — UI is compatible) ─
#[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 digikey_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>,
    /// Direct URL (usually via SamacSys / SnapEDA / Ultra Librarian
    /// redirect) to a STEP, IGES, or ECAD model for this part. Populated
    /// from `MediaLinks[]` in the DigiKey v4 response. `None` when the
    /// part has no CAD model on DigiKey's side.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cad_3d_url: Option<String>,
    /// Best-effort file format guess from the URL or MediaType ("step",
    /// "iges", "zip" if bundled, or the raw MediaType when unclear).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cad_3d_format: 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 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, 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, got {:?}", other))),
    }
}

fn digikey_post(client_id: &str, client_secret: &str, endpoint: &str, body: Value) -> Result<Value, String> {
    let token = get_access_token(client_id, client_secret)?;
    let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);
    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();
    let req = agent.post(&url)
        .set("Content-Type", "application/json")
        .set("Accept", "application/json")
        .set("Authorization", &format!("Bearer {}", token))
        .set("X-DIGIKEY-Client-Id", client_id)
        .set("X-DIGIKEY-Locale-Site", "US")
        .set("X-DIGIKEY-Locale-Language", "en")
        .set("X-DIGIKEY-Locale-Currency", "USD");
    let res = match req.send_json(body) {
        Ok(r) => r,
        Err(ureq::Error::Status(code, r)) => {
            let body = r.into_string().unwrap_or_default();
            return Err(format!("DigiKey API {}: {}", code, body));
        }
        Err(e) => return Err(format!("DigiKey API request failed: {}", e)),
    };
    res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))
}

fn digikey_get(client_id: &str, client_secret: &str, endpoint: &str) -> Result<Value, String> {
    let token = get_access_token(client_id, client_secret)?;
    let url = format!("{}/{}", DIGIKEY_API_BASE, endpoint);
    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(FETCH_TIMEOUT_SECS)).build();
    let req = agent.get(&url)
        .set("Content-Type", "application/json")
        .set("Accept", "application/json")
        .set("Authorization", &format!("Bearer {}", token))
        .set("X-DIGIKEY-Client-Id", client_id)
        .set("X-DIGIKEY-Locale-Site", "US")
        .set("X-DIGIKEY-Locale-Language", "en")
        .set("X-DIGIKEY-Locale-Currency", "USD");
    let res = match req.call() {
        Ok(r) => r,
        Err(ureq::Error::Status(code, r)) => {
            let body = r.into_string().unwrap_or_default();
            return Err(format!("DigiKey API {}: {}", code, body));
        }
        Err(e) => return Err(format!("DigiKey API request failed: {}", e)),
    };
    res.into_json().map_err(|e| format!("DigiKey API JSON parse failed: {}", e))
}

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

    let variation = p.pointer("/ProductVariations/0");
    let price_tiers: Vec<PriceTier> = variation
        .and_then(|v| v.get("StandardPricing"))
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|t| {
            let qty = t.get("BreakQuantity")?.as_u64()? as u32;
            let unit = t.get("UnitPrice")?.as_f64()?;
            Some(PriceTier { qty, price: format!("${:.4}", unit) })
        }).collect())
        .unwrap_or_default();

    let attributes: Vec<Attribute> = p.get("Parameters").and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|param| {
            let name = param.get("ParameterText").or_else(|| param.get("Parameter"))
                .and_then(|v| v.as_str())?.to_string();
            let value = param.get("ValueText").or_else(|| param.get("Value"))
                .and_then(|v| v.as_str())?.to_string();
            Some(Attribute { name, value })
        }).collect())
        .unwrap_or_default();

    // MediaLinks[] on the v4 response carries datasheet + photo + 3D
    // model links. We scan for the first entry whose MediaType is a
    // known CAD-model label. DigiKey routes most CAD links through
    // SamacSys — the resolved URL usually requires the user to click
    // through a vendor-library auth flow, so we pass the URL straight
    // to the UI rather than trying to download it.
    let mut cad_3d_url: Option<String> = None;
    let mut cad_3d_format: Option<String> = None;
    if let Some(media) = p.get("MediaLinks").and_then(|v| v.as_array()) {
        for m in media {
            let mt = m.get("MediaType").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
            let url = m.get("Url").and_then(|v| v.as_str()).unwrap_or("");
            if url.is_empty() { continue; }
            // Accept the common DigiKey MediaType values. "Design Files"
            // occasionally bundles schematic/footprint/3D together.
            if mt.contains("3d model") || mt == "3d" || mt.contains("cad") || mt == "design files" {
                // Guess format from URL suffix; fall back to label.
                let lower = url.to_lowercase();
                let fmt = if lower.contains(".step") || lower.contains(".stp") { "step" }
                    else if lower.contains(".iges") || lower.contains(".igs") { "iges" }
                    else if lower.contains(".stl") { "stl" }
                    else if lower.contains(".zip") { "zip" }
                    else { "step" }; // SamacSys almost always serves STEP
                cad_3d_url = Some(url.to_string());
                cad_3d_format = Some(fmt.to_string());
                break;
            }
        }
    }

    let s = |k: &str| p.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
    let os = |ptr: &str| p.pointer(ptr).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());

    let digikey_pn = variation.and_then(|v| v.get("DigiKeyProductNumber"))
        .and_then(|v| v.as_str()).unwrap_or("").to_string();
    let min_order = variation.and_then(|v| v.get("MinimumOrderQuantity"))
        .and_then(|v| v.as_u64()).unwrap_or(1) as u32;
    let stock_text = if stock > 0 { format!("{} In Stock", format_with_commas(stock)) } else { "Out of Stock".to_string() };
    let lead_time = p.get("ManufacturerLeadWeeks").and_then(|v| v.as_u64())
        .map(|w| format!("{} weeks", w)).unwrap_or_default();
    let unit_price = p.get("UnitPrice").and_then(|v| v.as_f64()).map(|u| format!("${:.4}", u));

    Component {
        digikey_pn,
        mpn: s("ManufacturerProductNumber"),
        manufacturer: p.pointer("/Manufacturer/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        description: p.pointer("/Description/ProductDescription").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        category: p.pointer("/Category/Name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        stock,
        stock_text,
        popularity: popularity.to_string(),
        lead_time,
        unit_price,
        price_tiers,
        min_order,
        mult: 1,
        rohs: p.pointer("/Classifications/RohsStatus").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        lifecycle: p.pointer("/ProductStatus/Status").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        datasheet_url: os("/DatasheetUrl"),
        product_url: os("/ProductUrl"),
        image_url: os("/PhotoUrl"),
        cad_3d_url,
        cad_3d_format,
        attributes,
    }
}

fn format_with_commas(n: u64) -> String {
    let s = n.to_string();
    let mut out = String::new();
    for (i, c) in s.chars().rev().enumerate() {
        if i > 0 && i % 3 == 0 { out.insert(0, ','); }
        out.insert(0, c);
    }
    out
}

pub fn search(client_id: &str, client_secret: &str, keyword: &str, limit: u32, in_stock_only: bool) -> Result<SearchResult, String> {
    if keyword.trim().is_empty() {
        return Err("keyword is required".into());
    }
    let mut body = json!({
        "Keywords": keyword.trim(),
        "Limit": limit.clamp(1, 50),
        "Offset": 0,
    });
    if in_stock_only {
        body["FilterOptionsRequest"] = json!({"SearchOptions": ["InStock"]});
    }
    let data = digikey_post(client_id, client_secret, "search/keyword", body)?;
    let products = data.get("Products").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    let count = data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0);
    Ok(SearchResult {
        components: products.iter().map(normalize_part).collect(),
        count,
        query: keyword.trim().to_string(),
        exact_match: None,
    })
}

pub fn part(client_id: &str, client_secret: &str, part_number: &str) -> Result<SearchResult, String> {
    if part_number.trim().is_empty() {
        return Err("partNumber is required".into());
    }
    let encoded = urlencoding_simple(part_number.trim());
    let endpoint = format!("search/{}/productdetails", encoded);
    match digikey_get(client_id, client_secret, &endpoint) {
        Ok(data) => {
            let product = data.get("Product");
            match product {
                Some(p) if !p.is_null() => Ok(SearchResult {
                    components: vec![normalize_part(p)],
                    count: 1,
                    query: part_number.trim().into(),
                    exact_match: Some(true),
                }),
                _ => Ok(SearchResult { components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false) }),
            }
        }
        Err(e) if e.contains(" 404") => Ok(SearchResult {
            components: vec![], count: 0, query: part_number.trim().into(), exact_match: Some(false),
        }),
        Err(e) => Err(e),
    }
}

pub fn ping(client_id: &str, client_secret: &str) -> Result<u64, String> {
    let data = digikey_post(client_id, client_secret, "search/keyword",
        json!({"Keywords": "resistor", "Limit": 1, "Offset": 0}))?;
    Ok(data.get("ProductsCount").and_then(|v| v.as_u64()).unwrap_or(0))
}

fn urlencoding_simple(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        match c {
            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
            _ => {
                let mut buf = [0u8; 4];
                for b in c.encode_utf8(&mut buf).bytes() {
                    out.push_str(&format!("%{:02X}", b));
                }
            }
        }
    }
    out
}