123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
//! Parallel HTTP aggregator across the three Adom parts services
//! (adom-mouser, adom-digikey, adom-jlcpcb). This module has no API
//! credentials of its own — each vendor's backend already holds them.
//!
//! Output shape: a unified Vec<VendorHit> with the minimum fields the
//! webview needs to render a comparison grid (MPN, image URL, price,
//! stock, link). Normalization across the three vendors' native schemas
//! happens here.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

pub const DEFAULT_MOUSER: &str = "https://mouser-xr8t4f5d01bt.adom.cloud";
pub const DEFAULT_DIGIKEY: &str = "https://digikey-gjuu8l5t69uh.adom.cloud";
pub const DEFAULT_JLCPCB: &str = "https://jlcpcb-wela51osctvp.adom.cloud";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Vendor { Mouser, DigiKey, JLCPCB }

impl Vendor {
    pub fn as_str(&self) -> &'static str {
        match self { Vendor::Mouser => "mouser", Vendor::DigiKey => "digikey", Vendor::JLCPCB => "jlcpcb" }
    }
    pub fn display(&self) -> &'static str {
        match self { Vendor::Mouser => "Mouser", Vendor::DigiKey => "DigiKey", Vendor::JLCPCB => "JLCPCB" }
    }
}

// VendorHit.cad_3d_* is populated opportunistically per-vendor:
//   DigiKey — from MediaLinks[3D Model] in its v4 response
//   Mouser  — not exposed in their API (field stays None)
//   JLCPCB  — not exposed by CDFER scrape (stays None), BUT the
//     aggregator below does a cross-vendor fallback: if a JLCPCB hit
//     shares an MPN with a DigiKey hit that has a cad_3d_url, we
//     copy it onto the JLCPCB card so the user gets the link
//     regardless of which column they clicked.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VendorHit {
    pub vendor: Vendor,
    pub vendor_pn: String,       // mouser_pn, digikey_pn, or LCSC part number
    pub mpn: String,             // manufacturer PN — the cross-vendor match key
    pub manufacturer: String,
    pub description: String,
    pub stock: u64,
    pub unit_price: Option<String>,
    pub price_100: Option<String>,
    pub lead_time: String,
    pub image_url: Option<String>,
    pub datasheet_url: Option<String>,
    pub product_url: Option<String>,
    pub flags: Vec<String>,      // "RoHS", "basic", "preferred", etc.
    /// URL to a STEP/IGES/GLB/ZIP CAD model, when the source vendor
    /// exposes one. Click-through usually redirects through SamacSys /
    /// SnapEDA / Ultra Librarian auth.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cad_3d_url: Option<String>,
    /// Best-guess CAD file format ("step", "iges", "stl", "zip").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cad_3d_format: Option<String>,
    /// Origin of the CAD URL — "digikey" (native), "digikey-xref"
    /// (cross-vendor fallback onto JLCPCB/Mouser card), etc.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cad_3d_source: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AggregatedResults {
    pub query: String,
    pub mouser: VendorBlock,
    pub digikey: VendorBlock,
    pub jlcpcb: VendorBlock,
    pub recommendation: Option<Recommendation>,
    /// Populated when no vendor produced a usable hit. Tells the user
    /// why (no matches vs. all unreachable) and suggests a next step.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub empty_state: Option<EmptyState>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmptyState {
    pub title: String,
    pub explanation: String,
    pub hint: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VendorBlock {
    pub ok: bool,
    /// Short, human-friendly one-liner. Never contains API keys or URLs.
    pub error: Option<String>,
    /// Longer explanation of what happened + what the user can expect.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error_explanation: Option<String>,
    /// Actionable hint: what to try, when to retry, who to ping.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error_hint: Option<String>,
    pub total: u64,
    pub hits: Vec<VendorHit>,
    pub backend_url: String,
    pub elapsed_ms: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recommendation {
    pub winner: Vendor,
    pub reason: String,
    pub mpn: String,
    pub unit_price: Option<String>,
    pub stock: u64,
}

/// Keep hits that contain the primary query token (>=3 char alphanumeric
/// run) in either MPN or description. Vendor backends that correctly
/// pass our MPN-or-keyword into FTS will be untouched; backends that
/// default-respond with "popular parts" on no-match (JLCPCB today) will
/// be filtered down to zero hits.
fn filter_relevant(hits: &[VendorHit], query: &str) -> Vec<VendorHit> {
    let q_lower = query.to_lowercase();
    // Primary token: longest alphanumeric run in the query.
    let primary: String = q_lower
        .split(|c: char| !c.is_ascii_alphanumeric())
        .filter(|s| s.len() >= 3)
        .max_by_key(|s| s.len())
        .unwrap_or("")
        .to_string();
    if primary.is_empty() {
        return hits.to_vec();
    }
    hits.iter().filter(|h| {
        let mpn = h.mpn.to_lowercase();
        let desc = h.description.to_lowercase();
        mpn.contains(&primary) || desc.contains(&primary)
    }).cloned().collect()
}

/// Translate a raw backend error into a short title + longer explanation
/// + actionable hint, written for a non-expert Adom user. Redacts API
/// keys, URLs, and stack traces before they reach stdout or the UI.
fn classify_error(vendor: Vendor, raw: &str) -> (String, String, String) {
    let vendor_name = vendor.display();
    let lower = raw.to_lowercase();

    // Known: Mouser free-tier daily quota exhausted (HTTP 403 with
    // "TooManyRequests" / "Maximum calls per day exceeded").
    if lower.contains("maximum calls per day") || lower.contains("toomanyrequests") {
        return (
            format!("{vendor_name}'s daily search quota is used up."),
            format!(
                "Our {vendor_name} API key is on a free-tier plan that allows a fixed number of \
                 searches per day, and we've already used them all. The counter resets at \
                 midnight Central Time."
            ),
            "DigiKey and JLCPCB results below are still live. If you need Mouser pricing \
             before midnight CT, rotate the key or bump the plan — ask an Adom admin.".into(),
        );
    }

    // Auth failures (401/403 without the quota marker).
    if lower.contains("status code 401") || lower.contains("status code 403")
        || lower.contains("unauthorized") || lower.contains("forbidden") {
        return (
            format!("{vendor_name} rejected our API key."),
            format!(
                "The {vendor_name} backend returned a permission error for the stored API \
                 key. The key may have been rotated, revoked, or is missing from the \
                 service container."
            ),
            format!("Ask an Adom admin to refresh the {vendor_name} key on the service container."),
        );
    }

    // Network/container-up but upstream unreachable / timeout.
    if lower.contains("timed out") || lower.contains("timeout")
        || lower.contains("connection refused") || lower.contains("connection reset") {
        return (
            format!("{vendor_name} backend is slow or unreachable right now."),
            format!(
                "We couldn't reach the {vendor_name} search service in time. This usually \
                 means the service container is restarting or the upstream vendor API is \
                 temporarily slow."
            ),
            "Try the search again in a minute. Other vendors' results are unaffected.".into(),
        );
    }

    // Backend-level errors (5xx, internal errors, panics).
    if lower.contains("status code 5") || lower.contains("internal server error")
        || lower.contains("500") || lower.contains("502") || lower.contains("503") || lower.contains("504") {
        return (
            format!("{vendor_name} backend hit an internal error."),
            format!(
                "The {vendor_name} search service replied with an unexpected server error. \
                 This is usually transient."
            ),
            "Try the search again in a minute. If this keeps happening, ask an Adom admin \
             to check the service container's logs.".into(),
        );
    }

    // Rate limiting that isn't quota-per-day (429 etc).
    if lower.contains("status code 429") || lower.contains("rate limit") || lower.contains("too many requests") {
        return (
            format!("{vendor_name} temporarily rate-limited us."),
            format!(
                "The {vendor_name} API asked us to slow down. This is short-term throttling, \
                 not a daily cap."
            ),
            "Wait 10-30 seconds and try again. If it persists, stagger your searches.".into(),
        );
    }

    // Fallback: generic, with the vendor named but never the raw URL/key.
    (
        format!("{vendor_name} search failed."),
        format!(
            "The {vendor_name} backend returned an error we don't have a canned explanation \
             for. DigiKey and JLCPCB results below are still valid."
        ),
        "Try the search again. If it keeps failing, show this page to an Adom admin.".into(),
    )
}

/// Remove anything that looks like a secret or internal URL from a raw
/// error message before it appears in logs. Called on the STDERR path
/// only; UI errors already come from `classify_error` and never include
/// the raw string.
#[allow(dead_code)]
fn redact(raw: &str) -> String {
    let mut s = raw.to_string();
    // Strip `apiKey=xxx...` query parameters
    if let Some(start) = s.find("apiKey=") {
        let end = s[start..].find(|c: char| c == '&' || c == ' ' || c == '"').map(|i| start + i).unwrap_or(s.len());
        s.replace_range(start..end, "apiKey=***redacted***");
    }
    s
}

fn post_action(url: &str, body: Value) -> Result<Value, String> {
    let url = if url.ends_with('/') { url.to_string() } else { format!("{}/", url) };
    let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(30)).build();
    let res = match agent.post(&url).set("Content-Type", "application/json").send_json(body) {
        Ok(r) => r,
        Err(ureq::Error::Status(_, r)) => r,
        Err(e) => return Err(format!("{}", e)),
    };
    res.into_json().map_err(|e| format!("JSON: {}", e))
}

fn price_at_qty(tiers: Option<&Vec<Value>>, qty: u32) -> Option<String> {
    let tiers = tiers?;
    // Find the tier with the largest qty ≤ requested
    let mut best: Option<(u32, String)> = None;
    for t in tiers {
        let tqty = t.get("qty").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
        let tprice = t.get("price").and_then(|v| v.as_str()).unwrap_or("");
        if tqty <= qty && !tprice.is_empty() {
            let keep = best.as_ref().map(|(q, _)| tqty >= *q).unwrap_or(true);
            if keep {
                best = Some((tqty, tprice.to_string()));
            }
        }
    }
    best.map(|(_, p)| p)
}

fn normalize_mouser(c: &Value) -> VendorHit {
    let tiers = c.get("price_tiers").and_then(|v| v.as_array());
    VendorHit {
        vendor: Vendor::Mouser,
        vendor_pn: c.get("mouser_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
        unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),
        price_100: price_at_qty(tiers, 100),
        lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),
        datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),
        product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),
        flags: {
            let mut f = vec![];
            if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
            if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
            f
        },
        cad_3d_url: None,      // Mouser API doesn't expose CAD links
        cad_3d_format: None,
        cad_3d_source: None,
    }
}

fn normalize_digikey(c: &Value) -> VendorHit {
    let tiers = c.get("price_tiers").and_then(|v| v.as_array());
    let cad_url = c.get("cad_3d_url").and_then(|v| v.as_str()).map(String::from);
    let cad_fmt = c.get("cad_3d_format").and_then(|v| v.as_str()).map(String::from);
    let has_cad = cad_url.is_some();
    VendorHit {
        vendor: Vendor::DigiKey,
        vendor_pn: c.get("digikey_pn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        mpn: c.get("mpn").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
        unit_price: c.get("unit_price").and_then(|v| v.as_str()).map(String::from),
        price_100: price_at_qty(tiers, 100),
        lead_time: c.get("lead_time").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        image_url: c.get("image_url").and_then(|v| v.as_str()).map(String::from),
        datasheet_url: c.get("datasheet_url").and_then(|v| v.as_str()).map(String::from),
        product_url: c.get("product_url").and_then(|v| v.as_str()).map(String::from),
        flags: {
            let mut f = vec![];
            if let Some(s) = c.get("rohs").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
            if let Some(s) = c.get("lifecycle").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { f.push(s.to_string()); }
            f
        },
        cad_3d_url: cad_url,
        cad_3d_format: cad_fmt,
        cad_3d_source: if has_cad { Some("digikey".into()) } else { None },
    }
}

fn normalize_jlcpcb(c: &Value) -> VendorHit {
    let tiers = c.get("price_tiers").and_then(|v| v.as_array());
    let is_basic = c.get("is_basic").and_then(|v| v.as_bool()).unwrap_or(false);
    let is_preferred = c.get("is_preferred").and_then(|v| v.as_bool()).unwrap_or(false);
    let lcsc = c.get("lcsc").and_then(|v| v.as_str()).unwrap_or("").to_string();
    let images = c.get("images").and_then(|v| v.as_array());
    let image_url = c.get("image_url").and_then(|v| v.as_str()).map(String::from)
        .or_else(|| images.and_then(|a| a.first()).and_then(|v| v.as_str()).map(String::from));
    VendorHit {
        vendor: Vendor::JLCPCB,
        vendor_pn: lcsc.clone(),
        mpn: c.get("mfr").or_else(|| c.get("mpn")).and_then(|v| v.as_str()).unwrap_or("").to_string(),
        manufacturer: c.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        description: c.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        stock: c.get("stock").and_then(|v| v.as_u64()).unwrap_or(0),
        unit_price: tiers.and_then(|t| t.first()).and_then(|t| t.get("price")).and_then(|v| v.as_str()).map(String::from),
        price_100: price_at_qty(tiers, 100),
        lead_time: String::new(),
        image_url,
        datasheet_url: c.get("datasheet").and_then(|v| v.as_str()).map(String::from),
        product_url: c.get("jlcpcb_url").and_then(|v| v.as_str()).map(String::from),
        flags: {
            let mut f = vec![];
            if is_basic { f.push("basic".into()); }
            if is_preferred { f.push("preferred".into()); }
            if !is_basic && !is_preferred { f.push("extended".into()); }
            f
        },
        cad_3d_url: None,      // filled in post-aggregation by cross-vendor MPN fallback
        cad_3d_format: None,
        cad_3d_source: None,
    }
}

/// Fire three parallel HTTP POSTs to the vendor backends. Returns a
/// single unified AggregatedResults.
pub fn search_all(
    query: &str,
    limit: u32,
    in_stock_only: bool,
    mouser_api: Option<&str>,
    digikey_api: Option<&str>,
    jlcpcb_api: Option<&str>,
) -> AggregatedResults {
    let m_url = mouser_api.unwrap_or(DEFAULT_MOUSER).to_string();
    let d_url = digikey_api.unwrap_or(DEFAULT_DIGIKEY).to_string();
    let j_url = jlcpcb_api.unwrap_or(DEFAULT_JLCPCB).to_string();
    let q = query.to_string();

    let (tx, rx) = mpsc::channel::<(Vendor, VendorBlock)>();
    let body_common = json!({"keyword": q, "limit": limit, "inStockOnly": in_stock_only});

    // Spawn three threads — one per vendor.
    for (vendor, url) in [(Vendor::Mouser, m_url.clone()), (Vendor::DigiKey, d_url.clone()), (Vendor::JLCPCB, j_url.clone())] {
        let tx = tx.clone();
        let body = {
            let mut b = body_common.clone();
            b["action"] = Value::String("search".into());
            b
        };
        let url_for_block = url.clone();
        let q_for_thread = q.clone();
        thread::spawn(move || {
            let t0 = std::time::Instant::now();
            let result = post_action(&url, body);
            let elapsed = t0.elapsed().as_millis() as u64;
            let mut block = VendorBlock {
                ok: false, error: None, error_explanation: None, error_hint: None,
                total: 0, hits: vec![],
                backend_url: url_for_block, elapsed_ms: elapsed,
            };
            match result {
                Ok(v) => {
                    if let Some(e) = v.get("error").and_then(|v| v.as_str()).or(
                        v.get("api_error").and_then(|v| v.as_str())
                    ) {
                        let (t, expl, hint) = classify_error(vendor, e);
                        block.error = Some(t);
                        block.error_explanation = Some(expl);
                        block.error_hint = Some(hint);
                    } else {
                        block.ok = true;
                        block.total = v.get("count").and_then(|n| n.as_u64()).unwrap_or(0);
                        let components = v.get("components").and_then(|a| a.as_array()).cloned().unwrap_or_default();
                        let raw: Vec<VendorHit> = components.iter().map(|c| match vendor {
                            Vendor::Mouser => normalize_mouser(c),
                            Vendor::DigiKey => normalize_digikey(c),
                            Vendor::JLCPCB => normalize_jlcpcb(c),
                        }).collect();
                        // Drop hits that don't contain the primary query
                        // token in the MPN or description. JLCPCB currently
                        // returns its default popular-parts list on no-match,
                        // so filter vendor-side. Safe for all vendors; vendor
                        // FTS that does match will keep its hits.
                        block.hits = filter_relevant(&raw, &q_for_thread);
                        // Sort by stock DESC so the most-useful variant is
                        // the first card a user sees. Mouser + DigiKey often
                        // return a stock=0 "parent" MPN before the stocked
                        // reel/cut-tape children; rendering that stock=0
                        // card first makes the whole column look empty.
                        block.hits.sort_by(|a, b| b.stock.cmp(&a.stock));
                        // If everything got filtered as irrelevant, reset
                        // `total` so the UI shows "0 matches".
                        if block.hits.is_empty() { block.total = 0; }
                    }
                }
                Err(e) => {
                    let (t, expl, hint) = classify_error(vendor, &e);
                    block.error = Some(t);
                    block.error_explanation = Some(expl);
                    block.error_hint = Some(hint);
                }
            }
            let _ = tx.send((vendor, block));
        });
    }
    drop(tx);

    let mut agg = AggregatedResults { query: query.into(), ..Default::default() };
    for _ in 0..3 {
        match rx.recv() {
            Ok((Vendor::Mouser, b)) => agg.mouser = b,
            Ok((Vendor::DigiKey, b)) => agg.digikey = b,
            Ok((Vendor::JLCPCB, b)) => agg.jlcpcb = b,
            Err(_) => break,
        }
    }
    // Cross-vendor CAD fallback: DigiKey is the only backend that
    // exposes CAD links today. For any Mouser or JLCPCB hit whose MPN
    // matches a DigiKey hit with a cad_3d_url, copy that URL onto
    // the sibling card so clicking "3D" from ANY column works.
    // Tagged cad_3d_source = "digikey-xref" so the UI + downstream
    // can tell xref-sourced URLs from native ones.
    let dk_cad: std::collections::HashMap<String, (String, Option<String>)> = agg.digikey.hits.iter()
        .filter(|h| h.cad_3d_url.is_some())
        .map(|h| (h.mpn.to_lowercase(), (h.cad_3d_url.clone().unwrap(), h.cad_3d_format.clone())))
        .collect();
    if !dk_cad.is_empty() {
        for h in agg.mouser.hits.iter_mut().chain(agg.jlcpcb.hits.iter_mut()) {
            if h.cad_3d_url.is_some() { continue; }
            if let Some((url, fmt)) = dk_cad.get(&h.mpn.to_lowercase()) {
                h.cad_3d_url = Some(url.clone());
                h.cad_3d_format = fmt.clone();
                h.cad_3d_source = Some("digikey-xref".into());
            }
        }
    }

    agg.recommendation = pick_winner(&agg);
    if agg.recommendation.is_none() {
        agg.empty_state = Some(build_empty_state(&agg));
    }
    agg
}

fn build_empty_state(agg: &AggregatedResults) -> EmptyState {
    let q = &agg.query;
    let m_ok = agg.mouser.ok; let d_ok = agg.digikey.ok; let j_ok = agg.jlcpcb.ok;
    let m_hits = agg.mouser.hits.len(); let d_hits = agg.digikey.hits.len(); let j_hits = agg.jlcpcb.hits.len();

    // All three errored — service degradation, not a search-miss.
    if !m_ok && !d_ok && !j_ok {
        return EmptyState {
            title: "All three vendor backends are currently unreachable.".into(),
            explanation: format!(
                "Mouser, DigiKey, and JLCPCB all returned errors for \"{q}\". This is a \
                 service-level issue, not a bad part number."
            ),
            hint: "Check https://status.adom.cloud or ping an admin. Individual vendor errors \
                   are shown in each column.".into(),
        };
    }

    // All three OK but zero hits — the part name didn't match anything.
    if m_ok && d_ok && j_ok && m_hits == 0 && d_hits == 0 && j_hits == 0 {
        return EmptyState {
            title: format!("No matches for \"{q}\" on any vendor."),
            explanation: "All three distributors returned zero hits. Vendor APIs match on MPN \
                          and description substrings, not on natural-language intent — so a \
                          typo, a too-broad phrase, or an out-of-family name will produce this \
                          empty result.".into(),
            hint: "Check the MPN spelling, try a cleaner keyword (e.g. LM555 instead of \
                   \"555 timer chip\"), or pick a known family member. Common substitutes \
                   are documented in the adom-parts-search skill's family table.".into(),
        };
    }

    // Mixed: some vendors OK with 0 hits, others errored — partial visibility.
    let live_vendors: Vec<&str> = [
        (m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),
    ].iter().filter(|(ok, _)| *ok).map(|(_, n)| *n).collect();
    let down_vendors: Vec<&str> = [
        (m_ok, "Mouser"), (d_ok, "DigiKey"), (j_ok, "JLCPCB"),
    ].iter().filter(|(ok, _)| !*ok).map(|(_, n)| *n).collect();
    EmptyState {
        title: format!("No in-stock matches for \"{q}\" right now."),
        explanation: format!(
            "{} returned zero hits. {} {} currently erroring (see the column \
             card for details), so coverage is partial.",
            live_vendors.join(" + "),
            down_vendors.join(" + "),
            if down_vendors.len() == 1 { "is" } else { "are" }
        ),
        hint: "Try a canonical family MPN (LM555 / NE555 / STM32F103C8T6 / ESP32-S3-WROOM-1U-N4R2 \
               / iCE40HX4K-TQ144) or retry in a minute once the erroring backends recover.".into(),
    }
}

/// Apply the Mouser-preferred policy from adom-parts-search SKILL:
/// near-zero shipping + 40-min drone means Mouser wins unless it's
/// out of stock / high-lead / DigiKey is dramatically cheaper.
///
/// We scan ALL hits per vendor, not just the first one — it's common
/// for Mouser to return a handful of stock=0 entries before the real
/// in-stock variant. Pick the FIRST in-stock hit per vendor, then
/// apply the vendor-preference policy on top.
fn pick_winner(agg: &AggregatedResults) -> Option<Recommendation> {
    let long_lead = |lt: &str| lt.contains("280");
    let first_in_stock = |hits: &[VendorHit]| {
        hits.iter().find(|h| h.stock > 0 && !long_lead(&h.lead_time)).cloned()
    };
    let m = first_in_stock(&agg.mouser.hits);
    let d = first_in_stock(&agg.digikey.hits);
    if let Some(m) = &m {
        return Some(Recommendation {
            winner: Vendor::Mouser,
            reason: "Mouser stocked — 40-min drone delivery from Mansfield, near-zero shipping.".into(),
            mpn: m.mpn.clone(),
            unit_price: m.unit_price.clone(),
            stock: m.stock,
        });
    }
    if let Some(d) = &d {
        return Some(Recommendation {
            winner: Vendor::DigiKey,
            reason: "Mouser is out of stock or long-lead; DigiKey has immediate stock.".into(),
            mpn: d.mpn.clone(),
            unit_price: d.unit_price.clone(),
            stock: d.stock,
        });
    }
    None
}