// Sourcing-ladder enforcement, programmatic.
//
// Single source of truth for:
//   * the canonical priority order (Mfr → SnapMagic → Mouser → DigiKey →
//     Arrow → CSE/UL → LCSC), used by `chip-fetcher sources` and the
//     first-heartbeat HINT and the `next-source` subcommand.
//   * MPN → manufacturer + canonical product-search URL, used by
//     `chip-fetcher mfr-probe`.
//   * fetched_via_chain helpers — reading, appending, querying which
//     ladder step has been tried for a given MPN.
//   * stage-label heuristics — parse a heartbeat stage string for
//     source names so the chain auto-updates without the AI writing it.
//
// All of this lives in code (not skill prose) because the agent doesn't
// reliably remember rules. The binary remembers; the agent queries.

use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;

/// One step in the sourcing ladder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Source {
    Manufacturer,
    SnapMagic,
    Mouser,
    DigiKey,
    Arrow,
    CseUl,
    Lcsc,
}

impl Source {
    pub fn as_slug(&self) -> &'static str {
        match self {
            Source::Manufacturer => "manufacturer",
            Source::SnapMagic    => "snapmagic",
            Source::Mouser       => "mouser",
            Source::DigiKey      => "digikey",
            Source::Arrow        => "arrow",
            Source::CseUl        => "cse_ul",
            Source::Lcsc         => "lcsc",
        }
    }

    pub fn ladder() -> &'static [Source] {
        &[
            Source::Manufacturer,
            Source::SnapMagic,
            Source::Mouser,
            Source::DigiKey,
            Source::Arrow,
            Source::CseUl,
            Source::Lcsc,
        ]
    }

    pub fn next_after(&self) -> Option<Source> {
        let lad = Source::ladder();
        let idx = lad.iter().position(|s| s == self)?;
        lad.get(idx + 1).copied()
    }
}

// ---------- MPN → manufacturer + search URL ----------

/// What we know (or guess) about a chip's manufacturer from its MPN.
#[derive(Debug, Clone, Serialize)]
pub struct MfrGuess {
    pub manufacturer: &'static str,
    pub search_url: String,
    /// True when the prefix match is unambiguous; false when we fell back
    /// to a generic guess. The CLI surfaces this so the AI knows whether
    /// to trust it or do its own probe.
    pub confident: bool,
}

/// Look up the manufacturer + canonical product-search URL for an MPN.
/// Returns `None` only when we have no signal at all (very rare for
/// real-world EE parts; passive R/C MPNs are the usual gap). Always pairs
/// the manufacturer name with a directly-openable URL — the AI's job is
/// just to navigate there, not to figure out where there is.
pub fn guess_mfr(mpn: &str) -> Option<MfrGuess> {
    let m = mpn.to_ascii_uppercase();

    // Each entry: (prefix, manufacturer, search-URL template with {mpn}
    // placeholder; if the URL contains no placeholder it's a direct search).
    // Order matters — longer prefixes first so e.g. "STM32" matches before
    // "ST" does.
    let table: &[(&str, &str, &str)] = &[
        // MCUs / SoCs
        ("STM32",   "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
        ("LSM",     "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
        ("VL5",     "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
        ("USBLC",   "STMicroelectronics", "https://www.st.com/en/search.html#q=%MPN%-t=products-page=1"),
        ("ESP32",   "Espressif",          "https://www.espressif.com/en/search?keys=%MPN%"),
        ("NRF",     "Nordic Semi",        "https://www.nordicsemi.com/Search?Keyword=%MPN%"),
        ("ATSAM",   "Microchip",          "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
        ("ATME",    "Microchip",          "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
        ("ATTI",    "Microchip",          "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
        ("PIC",     "Microchip",          "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
        ("MCP",     "Microchip",          "https://www.microchip.com/en-us/search?searchQuery=%MPN%"),
        ("R7FA",    "Renesas",            "https://www.renesas.com/us/en/search?keywords=%MPN%"),
        ("DA14",    "Renesas",            "https://www.renesas.com/us/en/search?keywords=%MPN%"),
        ("LPC",     "NXP",                "https://www.nxp.com/search?q=%MPN%"),
        ("MCXN",    "NXP",                "https://www.nxp.com/search?q=%MPN%"),
        ("PCA9",    "NXP",                "https://www.nxp.com/search?q=%MPN%"),
        ("ICE40",   "Lattice",            "https://www.latticesemi.com/Search?searchTerm=%MPN%"),

        // Power / analog
        ("TPS",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("ADS",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("INA",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("DRV",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("MCF",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("BQ",      "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("MSP",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("LM",      "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("LMV",     "Texas Instruments",  "https://www.ti.com/product/%MPN%"),
        ("AD7",     "Analog Devices",     "https://www.analog.com/en/search.html?q=%MPN%"),
        ("AD8",     "Analog Devices",     "https://www.analog.com/en/search.html?q=%MPN%"),
        ("LTC",     "Analog Devices",     "https://www.analog.com/en/search.html?q=%MPN%"),
        ("MAX",     "Analog Devices",     "https://www.analog.com/en/search.html?q=%MPN%"),
        ("AP21",    "Diodes Inc.",        "https://www.diodes.com/search/?q=%MPN%"),
        ("AP22",    "Diodes Inc.",        "https://www.diodes.com/search/?q=%MPN%"),
        ("DMG",     "Diodes Inc.",        "https://www.diodes.com/search/?q=%MPN%"),
        ("DMP",     "Diodes Inc.",        "https://www.diodes.com/search/?q=%MPN%"),
        ("DMN",     "Diodes Inc.",        "https://www.diodes.com/search/?q=%MPN%"),

        // Sensors
        ("BME",     "Bosch Sensortec",    "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
        ("BMI",     "Bosch Sensortec",    "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
        ("BMP",     "Bosch Sensortec",    "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
        ("BMA",     "Bosch Sensortec",    "https://www.bosch-sensortec.com/search.html?q=%MPN%"),
        ("BMG",     "Bosch Sensortec",    "https://www.bosch-sensortec.com/search.html?q=%MPN%"),

        // Connectors
        ("S2B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("S3B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("S4B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("B2B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("B3B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("B4B-",    "JST",                "https://www.jst-mfg.com/product/index.php?series=199"),
        ("SM",      "JST",                "https://www.jst-mfg.com/search/?q=%MPN%"),
        ("BM",      "JST",                "https://www.jst-mfg.com/search/?q=%MPN%"),
        ("USB4",    "GCT",                "https://gct.co/search?q=%MPN%"),
        ("USB3",    "GCT",                "https://gct.co/search?q=%MPN%"),
        ("HR9",     "HanRun",             "https://www.hanrun.com/"),  // chinese-only mfr
        ("WAGO",    "WAGO",               "https://www.wago.com/global/search?text=%MPN%"),

        // Crystals / passives
        ("ABM8",    "Abracon",            "https://abracon.com/search?searchQuery=%MPN%"),
        ("ABM3",    "Abracon",            "https://abracon.com/search?searchQuery=%MPN%"),
        ("CRCW",    "Vishay",             "https://www.vishay.com/search/?q=%MPN%"),
        ("VS",      "Vishay",             "https://www.vishay.com/search/?q=%MPN%"),
        ("VLM",     "Vishay",             "https://www.vishay.com/search/?q=%MPN%"),
        ("WSL",     "Vishay",             "https://www.vishay.com/search/?q=%MPN%"),

        // Switches
        ("TL3",     "E-Switch",           "https://www.e-switch.com/?s=%MPN%"),
    ];

    for (prefix, mfr, url_tmpl) in table {
        if m.starts_with(prefix) {
            let url = url_tmpl.replace("%MPN%", &urlencoding(mpn));
            return Some(MfrGuess {
                manufacturer: mfr,
                search_url: url,
                confident: true,
            });
        }
    }
    None
}

/// Normalize a chain-source slug. Accepts legacy variants ("cse",
/// "snapeda") and maps to the canonical slug used by Source::as_slug.
/// Without this, an entry with source="cse" doesn't satisfy a check for
/// "cse_ul" and the next-source state machine wrongly thinks cse_ul
/// hasn't been tried.
fn normalize_slug(s: &str) -> String {
    match s.to_ascii_lowercase().as_str() {
        "cse" | "ul" | "cseul" | "ultralibrarian" => "cse_ul".into(),
        "snapeda" => "snapmagic".into(),
        "mfr" => "manufacturer".into(),
        other => other.to_string(),
    }
}

fn urlencoding(s: &str) -> String {
    // Tiny URL-encoder — enough for MPN strings. Avoids a dep.
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(b as char)
            }
            _ => out.push_str(&format!("%{:02X}", b)),
        }
    }
    out
}

// ---------- fetched_via_chain helpers ----------

/// One entry in info.json.fetched_via_chain. Each represents a step on the
/// ladder we tried, with outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainEntry {
    pub source: String,           // slug from Source::as_slug
    pub outcome: String,          // "OK", "empty", "404", "blocked", "ladder_exhausted", etc.
    pub note: Option<String>,     // free-form context: "no English DS", "Chinese-only site"
    pub at: u64,                  // unix seconds
}

/// Read `info.json.fetched_via_chain` for an MPN. Returns empty Vec if no
/// chain has been recorded yet (fresh stub).
pub fn read_chain(mpn: &str) -> Result<Vec<ChainEntry>> {
    let path = info_path(mpn);
    if !path.exists() {
        return Ok(vec![]);
    }
    let s = std::fs::read_to_string(&path)?;
    let v: Value = serde_json::from_str(&s).unwrap_or(Value::Null);
    Ok(parse_chain(&v))
}

fn parse_chain(v: &Value) -> Vec<ChainEntry> {
    let arr = match v.get("fetched_via_chain").and_then(|c| c.as_array()) {
        Some(a) => a,
        None => return vec![],
    };
    arr.iter().filter_map(|e| {
        // Two formats supported: legacy "manufacturer:st.com:OK" string,
        // or the structured object below. Old entries get parsed loosely.
        if let Some(s) = e.as_str() {
            let parts: Vec<&str> = s.splitn(3, ':').collect();
            return Some(ChainEntry {
                source: parts.first().map(|x| x.to_string()).unwrap_or_default(),
                outcome: parts.get(2).map(|x| x.to_string()).unwrap_or_else(|| "?".into()),
                note: parts.get(1).map(|x| x.to_string()),
                at: 0,
            });
        }
        serde_json::from_value::<ChainEntry>(e.clone()).ok()
    }).collect()
}

/// Return the source slug from the last chain entry (most recent attempt).
/// Used by `fetch` to derive provenance for imported files.
pub fn last_chain_source(mpn: &str) -> Option<String> {
    read_chain(mpn).ok()?.last().map(|e| e.source.clone())
}

/// Append an entry to info.json.fetched_via_chain, creating the file if
/// missing. Idempotent on (source, outcome) — won't double-log the same
/// step result. Returns true if a new entry was added.
pub fn append_chain(mpn: &str, source: Source, outcome: &str, note: Option<&str>) -> Result<bool> {
    let path = info_path(mpn);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut info: Value = if path.exists() {
        let s = std::fs::read_to_string(&path)?;
        serde_json::from_str(&s).unwrap_or_else(|_| serde_json::json!({"mpn": mpn}))
    } else {
        serde_json::json!({"mpn": mpn})
    };

    let chain = parse_chain(&info);
    let dup = chain.iter().any(|e| e.source == source.as_slug() && e.outcome == outcome);
    if dup {
        return Ok(false);
    }

    let new_entry = ChainEntry {
        source: source.as_slug().to_string(),
        outcome: outcome.to_string(),
        note: note.map(|s| s.to_string()),
        at: now_secs(),
    };

    let arr = info.get_mut("fetched_via_chain")
        .and_then(|v| v.as_array_mut());
    match arr {
        Some(a) => a.push(serde_json::to_value(&new_entry)?),
        None => {
            info["fetched_via_chain"] = serde_json::Value::Array(
                chain.iter().filter_map(|e| serde_json::to_value(e).ok())
                    .chain(std::iter::once(serde_json::to_value(&new_entry)?))
                    .collect()
            );
        }
    }

    std::fs::write(&path, serde_json::to_string_pretty(&info)?)?;
    Ok(true)
}

/// Return the next ladder step that hasn't been tried (or has only been
/// tried with a transient outcome). Manufacturer always comes first, even
/// if the AI thinks it knows the part.
pub fn next_source(mpn: &str) -> Result<Option<Source>> {
    let chain = read_chain(mpn)?;
    let tried: std::collections::HashSet<String> = chain.iter()
        // Anything other than the bare "attempted" marker (= "navigated to
        // the page, no outcome yet") counts as tried. Legacy free-form
        // outcomes from older sessions ("Chinese-only site...", "no English
        // DS") still count.
        .filter(|e| e.outcome != "attempted")
        .map(|e| normalize_slug(&e.source))
        .collect();
    for step in Source::ladder() {
        if !tried.contains(step.as_slug()) {
            return Ok(Some(*step));
        }
    }
    Ok(None) // ladder fully exhausted
}

// ---------- stage-label parsing ----------

/// Parse a heartbeat stage string for source-name signals. Returns
/// (source, outcome) if we can confidently extract one. The point is to
/// auto-record the chain on every heartbeat so the AI doesn't have to
/// remember to do it explicitly.
pub fn detect_source_attempt(stage: &str) -> Option<(Source, String)> {
    let s = stage.to_ascii_lowercase();

    // Try to read the source out first.
    let source = if s.contains("manufacturer") || s.contains("mfr ") || s.contains(" mfr,")
                 || s.contains("st.com") || s.contains("ti.com") || s.contains("nxp.com")
                 || s.contains("nordic") || s.contains("microchip") || s.contains("ti ")
                 || s.contains(".com") && s.contains("trying ") && !s.contains("snapeda") && !s.contains("snapmagic")
                 && !s.contains("mouser") && !s.contains("digikey") && !s.contains("arrow")
                 && !s.contains("lcsc")
    {
        Source::Manufacturer
    } else if s.contains("snapmagic") || s.contains("snapeda") {
        Source::SnapMagic
    } else if s.contains("mouser") {
        Source::Mouser
    } else if s.contains("digikey") {
        Source::DigiKey
    } else if s.contains("arrow") {
        Source::Arrow
    } else if s.contains("cse") || s.contains("ultra librarian") || s.contains("ultralibrarian") {
        Source::CseUl
    } else if s.contains("lcsc") {
        Source::Lcsc
    } else {
        return None;
    };

    // Now infer outcome heuristics.
    let outcome = if s.contains("imported") || s.contains("download ok") || s.contains(" ok ") {
        "OK"
    } else if s.contains("empty") || s.contains("no result") || s.contains("not carried") || s.contains("not_carried") {
        "empty"
    } else if s.contains("404") || s.contains("not found") {
        "404"
    } else if s.contains("blocked") || s.contains("captcha") || s.contains("cdn") {
        "blocked"
    } else if s.contains("trying") || s.contains("probing") || s.contains("driving") || s.contains("falling to") {
        // Just a navigation event, not yet an outcome — don't pollute the chain.
        return None;
    } else {
        "attempted"
    };

    Some((source, outcome.to_string()))
}

// ---------- stage state machine ----------

/// Canonical fetch stages, in expected order. Used to validate stage
/// transitions in heartbeat and warn when the AI reports "imported"
/// without ever heartbeating "downloading".
pub fn stage_phase(stage: &str) -> Option<u8> {
    let s = stage.to_ascii_lowercase();
    if s.contains("queued") || s.contains("starting") { return Some(0); }
    if s.contains("probing") || s.contains("navigating") || s.contains("trying") || s.contains("falling to") || s.contains("driving") { return Some(1); }
    if s.contains("found") || s.contains("identified") { return Some(2); }
    if s.contains("downloading") || s.contains("fetching") || s.contains("polling downloads") { return Some(3); }
    if s.contains("pulling") || s.contains("from desktop") { return Some(4); }
    if s.contains("importing") { return Some(5); }
    if s.contains("imported") || s.contains("complete") || s.contains("done") { return Some(6); }
    None
}

// ---------- internal helpers ----------

fn info_path(mpn: &str) -> PathBuf {
    crate::library::root().join(mpn).join("info.json")
}

fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}