//! First-class integration with the Adom parts-data tools.
//!
//! THE LESSON (learned the hard way 2026-06-23): the Adom parts tools
//! — `adom-parts-search` (the umbrella over `adom-mouser` + `adom-digikey` +
//! `adom-jlcpcb`) — keep their API keys baked into their OWN service containers.
//! They ALWAYS work with NO key needed. chip-fetcher must NEVER assume it needs
//! a `MOUSER_API_KEY` of its own; it just shells to `adom-parts-search`.
//!
//! What these tools give us, for free, on every chip:
//!   - a human description ("what is this part")
//!   - the manufacturer, image, datasheet URL, product URL
//!   - stock + pricing + lead time + lifecycle  → stock.json
//!   - EVERY orderable package variant of a base MPN  → the orderable-MPN that
//!     CAD sites (CSE / UltraLibrarian) require. This is the permanent fix for
//!     the "base ADS1263 → CSE Pick-One, no partID" trap.
//!
//! `adom-parts-search search <MPN>` prints unified JSON to stdout:
//!   { query, mouser:{ok,total,hits:[{vendor,vendor_pn,mpn,manufacturer,
//!     description,stock,unit_price,price_100,lead_time,image_url,
//!     datasheet_url,product_url,flags}]}, digikey:{…}, jlcpcb:{…},
//!     recommendation:{winner,reason,mpn,unit_price,stock} }

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::process::Command;

use crate::library;

const PARTS_SEARCH_BIN: &str = "adom-parts-search";

pub struct EnrichReport {
    pub mpn: String,
    pub description: Option<String>,
    pub wrote_stock: bool,
    pub variants: Vec<String>,
}

/// Run `adom-parts-search search <query>` and return the parsed JSON.
/// Tries the umbrella tool; bubbles up a clear error if it's missing.
pub fn parts_search(query: &str) -> Result<Value> {
    let out = Command::new(PARTS_SEARCH_BIN)
        .arg("search")
        .arg(query)
        .output()
        .with_context(|| format!("running `{PARTS_SEARCH_BIN} search {query}` — is adom-parts-search installed?"))?;
    let stdout = String::from_utf8_lossy(&out.stdout);
    // The CLI prints a one-line "OK: …" summary before the JSON body; find the
    // first '{' and parse from there.
    let start = stdout.find('{').ok_or_else(|| {
        anyhow::anyhow!("adom-parts-search returned no JSON for {query}: {}", stdout.trim())
    })?;
    let v: Value = serde_json::from_str(&stdout[start..])
        .with_context(|| format!("parsing adom-parts-search JSON for {query}"))?;
    Ok(v)
}

/// All hits across Mouser + DigiKey (+ JLCPCB), Mouser first.
fn all_hits(data: &Value) -> Vec<Value> {
    let mut hits = Vec::new();
    for vendor in ["mouser", "digikey", "jlcpcb"] {
        if let Some(h) = data.get(vendor).and_then(|b| b.get("hits")).and_then(|h| h.as_array()) {
            hits.extend(h.iter().cloned());
        }
    }
    hits
}

/// The orderable package variants for a (possibly base) MPN — the exact strings
/// CAD sites need. e.g. ADS1263 → ["ADS1263IPW", "ADS1263IPWR"]. Dedup,
/// preserving Mouser-first order. This is the permanent orderable-MPN resolver.
pub fn resolve_orderable(mpn: &str) -> Vec<String> {
    let data = match parts_search(mpn) {
        Ok(d) => d,
        Err(_) => return vec![],
    };
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for h in all_hits(&data) {
        if let Some(m) = h.get("mpn").and_then(|x| x.as_str()) {
            let m = m.trim().to_string();
            if !m.is_empty() && seen.insert(m.clone()) {
                out.push(m);
            }
        }
    }
    out
}

/// Pick the best hit for `mpn`: exact MPN match first (Mouser preferred via
/// all_hits order), else the first hit.
fn best_hit(data: &Value, mpn: &str) -> Option<Value> {
    let hits = all_hits(data);
    let want = mpn.to_uppercase();
    hits.iter()
        .find(|h| h.get("mpn").and_then(|x| x.as_str()).map(|s| s.to_uppercase() == want).unwrap_or(false))
        .cloned()
        .or_else(|| hits.into_iter().next())
}

/// Clean a distributor description into a human "what is this chip" line.
/// Mouser appends vendor-PN noise like " A 595-ADS1263IPW" and a category
/// prefix "Analog to Digital Converters - "; strip both, collapse whitespace.
fn clean_description(raw: &str) -> String {
    let mut s = raw.trim().to_string();
    // Strip a trailing " A <digits>-<rest>" Mouser vendor-PN tail.
    if let Some(idx) = s.rfind(" A ") {
        let tail = &s[idx + 3..];
        if tail.chars().take(4).filter(|c| c.is_ascii_digit()).count() >= 2
            || tail.contains('-')
        {
            s.truncate(idx);
        }
    }
    // Keep a leading "Category - " prefix as context but drop it if it's the
    // generic ADC/converter family noise that just repeats the part type.
    s = s.trim_end_matches(['-', ' ', '·']).trim().to_string();
    // Collapse internal whitespace.
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Build a stock.json body from the best hit.
fn stock_body(hit: &Value) -> Value {
    let get = |k: &str| hit.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
    let stock = hit.get("stock").and_then(|x| x.as_u64()).unwrap_or(0);
    let flags: Vec<String> = hit.get("flags").and_then(|f| f.as_array())
        .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
        .unwrap_or_default();
    let lifecycle = if flags.iter().any(|f| {
        let f = f.to_uppercase();
        f.contains("NRND") || f.contains("EOL") || f.contains("OBSOLETE") || f.contains("LAST TIME")
    }) { "nrnd_or_eol" } else { "active" };
    let mut tiers = Vec::new();
    let p1 = get("unit_price");
    if !p1.is_empty() { tiers.push(json!({"price": p1, "qty": 1})); }
    let p100 = get("price_100");
    if !p100.is_empty() { tiers.push(json!({"price": p100, "qty": 100})); }
    json!({
        "checked_at": "via adom-parts-search",
        "source": "adom-parts-search",
        get_vendor_key(hit): {
            "vendor_pn": get("vendor_pn"),
            "mpn": get("mpn"),
            "manufacturer": get("manufacturer"),
            "stock": stock,
            "stock_text": format!("{stock} in stock"),
            "lead_time": get("lead_time"),
            "lifecycle": lifecycle,
            "unit_price": p1,
            "price_tiers": tiers,
            "product_url": get("product_url"),
            "image_url": get("image_url"),
            "flags": flags,
        }
    })
}

fn get_vendor_key(hit: &Value) -> String {
    hit.get("vendor").and_then(|x| x.as_str()).unwrap_or("mouser").to_lowercase()
}

/// Enrich a chip from the Adom parts tools: write info.json.description (+
/// manufacturer / image / datasheet / product URLs) and stock.json. Best-effort
/// per field; never fails the sourcing run.
pub fn enrich(mpn: &str) -> Result<EnrichReport> {
    let dir = library::root().join(mpn);
    if !dir.exists() {
        anyhow::bail!("library/{mpn}/ does not exist");
    }
    let data = parts_search(mpn)?;
    let mut seen = std::collections::HashSet::new();
    let variants: Vec<String> = all_hits(&data).iter()
        .filter_map(|h| h.get("mpn").and_then(|x| x.as_str()).map(String::from))
        .filter(|m| !m.is_empty() && seen.insert(m.clone()))
        .collect();
    let hit = best_hit(&data, mpn);

    let mut description = None;
    let mut wrote_stock = false;

    if let Some(h) = hit.as_ref() {
        // --- info.json: description + provenance-friendly metadata ---
        let info_path = dir.join("info.json");
        let mut info: Value = std::fs::read_to_string(&info_path).ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_else(|| json!({}));
        // Only set the description from the distributor if there isn't already a
        // (likely hand-curated, nicer) one — don't clobber a better description.
        let existing = info.get("description").and_then(|d| d.as_str()).unwrap_or("").trim().to_string();
        if existing.len() < 10 {
            let raw = h.get("description").and_then(|x| x.as_str()).unwrap_or("");
            let cleaned = clean_description(raw);
            if cleaned.len() >= 10 {
                info["description"] = json!(cleaned);
                description = Some(cleaned);
            }
        } else {
            description = Some(existing);
        }
        for (k, src) in [("manufacturer", "manufacturer"), ("image_url", "image_url"),
                         ("datasheet_url", "datasheet_url"), ("product_url", "product_url")] {
            if let Some(v) = h.get(src).and_then(|x| x.as_str()) {
                if !v.is_empty() && info.get(k).and_then(|x| x.as_str()).unwrap_or("").is_empty() {
                    info[k] = json!(v);
                }
            }
        }
        let _ = std::fs::write(&info_path, serde_json::to_string_pretty(&info).unwrap_or_default());

        // --- stock.json: stock + pricing + lifecycle ---
        let stock = stock_body(h);
        if std::fs::write(dir.join("stock.json"), serde_json::to_string_pretty(&stock).unwrap_or_default()).is_ok() {
            wrote_stock = true;
        }
    }

    Ok(EnrichReport { mpn: mpn.to_string(), description, wrote_stock, variants })
}