123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
// Validate a chip directory by running ds2sf + concur in sequence and
// walking their hints[] arrays to drive autonomous self-repair.
//
// Both tools are agent-to-agent: each emits a stable
// <MPN>-{extraction,concur}.result.json with `status`, `exit_code`,
// `hints[]`, and (for concur) `variants[]`. We mirror those types via
// serde tagged-union deserialization and `match` on the action / kind.
//
// Read both contracts before touching this file:
//   ~/.claude/skills/adom-ds2sf/SKILL.md
//   ~/.claude/skills/adom-concur/SKILL.md

use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;

// ---------- ds2sf result.json types ----------

#[derive(Debug, Deserialize)]
pub struct Ds2sfResult {
    pub schema_version: String,
    pub status: String,                        // "ok" | "needs_better_pdf" | "unrecoverable"
    pub exit_code: i32,
    pub mpn: String,
    #[serde(default)]
    pub manufacturer: Option<String>,
    pub datasheet_used: PathBuf,
    #[serde(default)]
    pub datasheet_sha256: Option<String>,
    #[serde(default)]
    pub summary: Option<String>,
    #[serde(default)]
    pub review_required: Vec<serde_json::Value>,
    #[serde(default)]
    pub hints: Vec<Ds2sfHint>,
    #[serde(default)]
    pub outputs: Option<Ds2sfOutputs>,
}

#[derive(Debug, Deserialize)]
pub struct Ds2sfOutputs {
    #[serde(default)]
    pub symbol_json: Option<PathBuf>,
    #[serde(default)]
    pub footprint_json: Option<PathBuf>,
    #[serde(default)]
    pub provenance_json: Option<PathBuf>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum Ds2sfHint {
    TryAlternatePdf {
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        candidates: Vec<PathBuf>,
    },
    UpdateUpstreamMetadata {
        file: PathBuf,
        key: String,
        #[serde(default)]
        current: Option<serde_json::Value>,
        extracted: serde_json::Value,
    },
    UseDatasheetExtractedFootprint {
        #[serde(default)]
        package: Option<String>,
    },
    HumanReview {
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        notes: Option<String>,
    },
    // Catch-all so a future hint kind doesn't make deserialize fail.
    #[serde(other)]
    Unknown,
}

// ---------- concur result.json types ----------

#[derive(Debug, Deserialize)]
pub struct ConcurResult {
    pub schema_version: String,
    pub status: String,                        // "golden" | "golden_with_variants" | "divergent" | "unrecoverable"
    pub exit_code: i32,
    pub mpn: String,
    #[serde(default)]
    pub summary: Option<String>,
    #[serde(default)]
    pub sources_compared: Vec<String>,
    #[serde(default)]
    pub sources_missing: Vec<String>,
    #[serde(default)]
    pub axes: Vec<serde_json::Value>,
    #[serde(default)]
    pub hints: Vec<ConcurHint>,
    #[serde(default)]
    pub variants: Vec<ConcurVariant>,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum ConcurHint {
    RefetchLibrarySource {
        format: String,                         // "kicad_sym" | "kicad_mod" | "eagle"
        #[serde(default)]
        suspect: Option<String>,
        #[serde(default)]
        notes: Option<String>,
        #[serde(default)]
        suggested_sources: Vec<String>,
        #[serde(default)]
        expected_resolution: Option<String>,
    },
    UlSymbolHasStubNames {
        source: String,
        #[serde(default)]
        pin_count: Option<u32>,
        #[serde(default)]
        suggestion: Option<String>,
        #[serde(default)]
        chip_fetcher_action: Option<String>,
    },
    RerunDs2sfWithStrongerModel {
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        suspect_field: Option<String>,
        #[serde(default)]
        suggested_model: Option<String>,
    },
    AcceptWithCaveats {
        #[serde(default)]
        caveats: Vec<String>,
    },
    HumanReview {
        #[serde(default)]
        reason: Option<String>,
        #[serde(default)]
        notes: Option<String>,
    },
    SourceParseFailed {
        format: String,
        #[serde(default)]
        path: Option<PathBuf>,
        #[serde(default)]
        error: Option<String>,
    },
    #[serde(other)]
    Unknown,
}

#[derive(Debug, Deserialize, Clone)]
pub struct ConcurVariant {
    pub kind: String,                           // tagged-string per contract
    #[serde(flatten)]
    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
}

// ---------- public outcome ----------

#[derive(Debug)]
pub enum ValidationOutcome {
    /// concur reported `golden` — every source agrees exactly.
    Golden { mpn: String },
    /// concur reported `golden_with_variants` — disagreements all explained
    /// by variant kinds. Variants persisted to `<MPN>-validation-status.json`.
    GoldenWithVariants {
        mpn: String,
        variants: Vec<ConcurVariant>,
        used_ds2sf_preferred_symbol: bool,
    },
    /// We exhausted automated repairs (or hit a step that requires human
    /// judgment). Final state of result.json saved alongside the chip.
    NeedsHuman {
        mpn: String,
        reason: String,
        last_concur_status: Option<String>,
        last_ds2sf_status: Option<String>,
    },
    /// ds2sf came back with `unrecoverable` exit code 3.
    Ds2sfUnrecoverable { mpn: String, summary: Option<String> },
}

// ---------- driver ----------

const MAX_DS2SF_PDF_TRIES: usize = 6;
const MAX_CONCUR_REPAIRS: usize = 5;

pub fn validate_chip_dir(dir: &Path) -> Result<ValidationOutcome> {
    let mpn = dir.file_name()
        .and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("chip dir has no basename: {}", dir.display()))?
        .to_string();

    println!("OK: validate {} — running ds2sf …", mpn);
    let mut tried_pdfs: HashSet<PathBuf> = HashSet::new();

    let mut ds2sf = run_ds2sf(dir, None)?;
    tried_pdfs.insert(ds2sf.datasheet_used.clone());

    // Phase 1 — needs_better_pdf loop
    let mut tries = 0usize;
    while ds2sf.status == "needs_better_pdf" {
        tries += 1;
        if tries > MAX_DS2SF_PDF_TRIES {
            return Ok(ValidationOutcome::NeedsHuman {
                mpn,
                reason: format!("ds2sf needs_better_pdf after {tries} attempts; out of candidates"),
                last_concur_status: None,
                last_ds2sf_status: Some("needs_better_pdf".into()),
            });
        }
        let next = ds2sf.hints.iter().find_map(|h| match h {
            Ds2sfHint::TryAlternatePdf { candidates, .. } => candidates.iter().find(|c| !tried_pdfs.contains(*c)).cloned(),
            _ => None,
        });
        let Some(next) = next else {
            return Ok(ValidationOutcome::NeedsHuman {
                mpn,
                reason: "ds2sf needs_better_pdf — no untried candidate PDFs in hints[]".into(),
                last_concur_status: None,
                last_ds2sf_status: Some("needs_better_pdf".into()),
            });
        };
        println!("OK: ds2sf wants alternate PDF → trying {}", next.display());
        tried_pdfs.insert(next.clone());
        ds2sf = run_ds2sf(dir, Some(&next))?;
    }

    if ds2sf.status == "unrecoverable" {
        return Ok(ValidationOutcome::Ds2sfUnrecoverable {
            mpn,
            summary: ds2sf.summary,
        });
    }
    if ds2sf.status != "ok" {
        return Ok(ValidationOutcome::NeedsHuman {
            mpn,
            reason: format!("ds2sf returned unexpected status {:?}", ds2sf.status),
            last_concur_status: None,
            last_ds2sf_status: Some(ds2sf.status),
        });
    }

    // Apply update_upstream_metadata hints from ds2sf — these are
    // info.json corrections (e.g. wrong package family detected).
    apply_ds2sf_metadata_patches(&ds2sf);

    // Phase 2 — concur loop
    println!("OK: ds2sf complete; running concur …");
    let mut tried_repairs: HashSet<String> = HashSet::new();
    let mut used_ds2sf_preferred_symbol = false;
    let mut last_concur_status = String::new();
    let mut last_concur: Option<ConcurResult> = None;

    for round in 1..=MAX_CONCUR_REPAIRS {
        let r = run_concur(dir)?;
        last_concur_status = r.status.clone();

        match r.status.as_str() {
            "golden" | "golden_with_variants" => {
                // ul_symbol_has_stub_names is a "do not refetch" hint —
                // mark the symbol source as ds2sf-preferred so downstream
                // sym_create authors a fresh symbol from ds2sf JSON.
                for h in &r.hints {
                    if let ConcurHint::UlSymbolHasStubNames { .. } = h {
                        if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {
                            eprintln!("WARN: could not mark {mpn} symbol source ds2sf-preferred: {e}");
                        } else {
                            used_ds2sf_preferred_symbol = true;
                        }
                    }
                }
                let _ = persist_validation_status(dir, &r);
                if r.status == "golden" {
                    return Ok(ValidationOutcome::Golden { mpn });
                } else {
                    return Ok(ValidationOutcome::GoldenWithVariants {
                        mpn,
                        variants: r.variants.clone(),
                        used_ds2sf_preferred_symbol,
                    });
                }
            }
            "divergent" => {
                // Walk hints in order; first action that's not been tried
                // wins. Stop iterating once we acted (or once we determined
                // no autonomous repair is available).
                let mut acted = false;
                for h in &r.hints {
                    match h {
                        ConcurHint::RerunDs2sfWithStrongerModel { suggested_model, reason, .. } => {
                            let key = "ds2sf_rerun".to_string();
                            if tried_repairs.insert(key) {
                                let model = suggested_model.clone().unwrap_or_else(|| "claude-opus-4-7".into());
                                println!("OK: concur round {round} — re-running ds2sf with model {model} (reason: {})",
                                    reason.as_deref().unwrap_or("?"));
                                ds2sf = run_ds2sf_with_model(dir, &model)?;
                                acted = true;
                                break;
                            }
                        }
                        ConcurHint::RefetchLibrarySource { format, suggested_sources, notes, suspect, .. } => {
                            // Try the first untried (format, source) pair.
                            // If the refetch surface returns "not implemented"
                            // (current state — chip-fetcher's per-source refetch
                            // CLI isn't wired yet), record ALL suggested_sources
                            // in info.json.fetched_via_chain so the human has
                            // the priority-ordered list, then surface gracefully.
                            let key = format!("refetch_attempted|{format}");
                            if !tried_repairs.contains(&key) {
                                let pick = suggested_sources.iter().next().cloned();
                                if let Some(ref src) = pick {
                                    println!("OK: concur round {round} — refetch_library_source {format} from {src} (suspect: {}, {})",
                                        suspect.as_deref().unwrap_or("?"),
                                        notes.as_deref().unwrap_or("no notes"));
                                    match attempt_refetch_library(dir, format, src) {
                                        Ok(()) => { acted = true; tried_repairs.insert(key); break; }
                                        Err(e) => {
                                            eprintln!("WARN: refetch {format}/{src} failed: {e}");
                                            // Record the full priority-ordered list to the chain
                                            // for later manual follow-up, and stop trying refetches
                                            // for this format — every hint here is variant of "fix
                                            // the kicad_sym," and they all fail the same way.
                                            for s in suggested_sources {
                                                let _ = attempt_refetch_library(dir, format, s); // appends to chain, returns Err
                                            }
                                            tried_repairs.insert(key);
                                            // Don't break — let the OUTER for loop pick a different
                                            // hint kind (e.g. UlSymbolHasStubNames) if present.
                                        }
                                    }
                                }
                            }
                        }
                        ConcurHint::UlSymbolHasStubNames { .. } => {
                            // Even on divergent, this hint means "don't
                            // refetch the .kicad_sym, prefer ds2sf." Mark
                            // and treat as soft repair (not a refetch).
                            let key = "ds2sf_preferred_symbol".to_string();
                            if tried_repairs.insert(key) {
                                if let Err(e) = mark_symbol_source_ds2sf_preferred(dir) {
                                    eprintln!("WARN: could not mark ds2sf-preferred: {e}");
                                } else {
                                    println!("OK: concur round {round} — marked symbol source ds2sf-preferred (UL stub names)");
                                    used_ds2sf_preferred_symbol = true;
                                    acted = true;
                                }
                                break;
                            }
                        }
                        _ => { /* AcceptWithCaveats / HumanReview / SourceParseFailed / Unknown — no autonomous action */ }
                    }
                }
                last_concur = Some(r);
                if !acted {
                    let lc = last_concur.unwrap();
                    let _ = persist_validation_status(dir, &lc);
                    return Ok(ValidationOutcome::NeedsHuman {
                        mpn,
                        reason: human_reason_from_hints(&lc),
                        last_concur_status: Some(last_concur_status),
                        last_ds2sf_status: Some("ok".into()),
                    });
                }
            }
            "unrecoverable" => {
                let _ = persist_validation_status(dir, &r);
                return Ok(ValidationOutcome::NeedsHuman {
                    mpn,
                    reason: r.summary.clone().unwrap_or_else(|| "concur unrecoverable".into()),
                    last_concur_status: Some(last_concur_status),
                    last_ds2sf_status: Some("ok".into()),
                });
            }
            other => {
                return Ok(ValidationOutcome::NeedsHuman {
                    mpn,
                    reason: format!("concur returned unexpected status {other:?}"),
                    last_concur_status: Some(last_concur_status),
                    last_ds2sf_status: Some("ok".into()),
                });
            }
        }
    }

    Ok(ValidationOutcome::NeedsHuman {
        mpn,
        reason: format!("concur still divergent after {MAX_CONCUR_REPAIRS} repair rounds"),
        last_concur_status: Some(last_concur_status),
        last_ds2sf_status: Some("ok".into()),
    })
}

// ---------- subprocess helpers ----------

fn run_ds2sf(chip_dir: &Path, pdf_override: Option<&Path>) -> Result<Ds2sfResult> {
    let mut cmd = Command::new("adom-ds2sf");
    cmd.arg("extract").arg(chip_dir).arg("--force").arg("--json-only");
    if let Some(p) = pdf_override {
        cmd.arg("--pdf").arg(p);
    }
    let _ = cmd.status().with_context(|| "running ds2sf extract")?;
    read_ds2sf_result(chip_dir)
}

fn run_ds2sf_with_model(chip_dir: &Path, model: &str) -> Result<Ds2sfResult> {
    let mut cmd = Command::new("adom-ds2sf");
    cmd.arg("extract").arg(chip_dir)
       .arg("--force").arg("--json-only")
       .arg("--model").arg(model);
    let _ = cmd.status().with_context(|| "running ds2sf extract --model")?;
    read_ds2sf_result(chip_dir)
}

fn read_ds2sf_result(chip_dir: &Path) -> Result<Ds2sfResult> {
    let mpn = chip_dir.file_name().and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("bad chip dir"))?;
    let path = chip_dir.join(format!("{mpn}-extraction.result.json"));
    let s = std::fs::read_to_string(&path)
        .with_context(|| format!("reading {}", path.display()))?;
    let r: Ds2sfResult = serde_json::from_str(&s)
        .with_context(|| format!("parsing ds2sf result.json: {}", path.display()))?;
    Ok(r)
}

fn run_concur(chip_dir: &Path) -> Result<ConcurResult> {
    let mut cmd = Command::new("adom-concur");
    cmd.arg("check").arg(chip_dir).arg("--json-only");
    let _ = cmd.status().with_context(|| "running concur check")?;
    read_concur_result(chip_dir)
}

fn read_concur_result(chip_dir: &Path) -> Result<ConcurResult> {
    let mpn = chip_dir.file_name().and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("bad chip dir"))?;
    let path = chip_dir.join(format!("{mpn}-concur.result.json"));
    let s = std::fs::read_to_string(&path)
        .with_context(|| format!("reading {}", path.display()))?;
    let r: ConcurResult = serde_json::from_str(&s)
        .with_context(|| format!("parsing concur result.json: {}", path.display()))?;
    Ok(r)
}

// ---------- side-effects ----------

/// Apply ds2sf's update_upstream_metadata hints — patch info.json fields
/// where ds2sf detected the chip-fetcher hint was wrong (e.g. wrong
/// package family). Best-effort; won't unwind a partial patch.
fn apply_ds2sf_metadata_patches(r: &Ds2sfResult) {
    for h in &r.hints {
        if let Ds2sfHint::UpdateUpstreamMetadata { file, key, extracted, .. } = h {
            if let Err(e) = patch_json_key(file, key, extracted) {
                eprintln!("WARN: could not apply update_upstream_metadata to {} (key={key}): {e}", file.display());
            } else {
                println!("OK: patched {} → {key} := {}", file.display(), extracted);
            }
        }
    }
}

fn patch_json_key(path: &Path, key: &str, value: &serde_json::Value) -> Result<()> {
    let s = std::fs::read_to_string(path)
        .with_context(|| format!("reading {}", path.display()))?;
    let mut v: serde_json::Value = serde_json::from_str(&s)
        .with_context(|| format!("parsing {}", path.display()))?;
    if let Some(obj) = v.as_object_mut() {
        obj.insert(key.to_string(), value.clone());
    } else {
        return Err(anyhow!("{} is not a JSON object", path.display()));
    }
    std::fs::write(path, serde_json::to_string_pretty(&v)?)?;
    Ok(())
}

/// Mark the chip dir's symbol source as `ds2sf-preferred` so downstream
/// `sym_create` authors a fresh symbol from ds2sf's extracted JSON
/// rather than the UL stub-named .kicad_sym.
fn mark_symbol_source_ds2sf_preferred(chip_dir: &Path) -> Result<()> {
    let info = chip_dir.join("info.json");
    let s = std::fs::read_to_string(&info)
        .with_context(|| format!("reading {}", info.display()))?;
    let mut v: serde_json::Value = serde_json::from_str(&s)?;
    if let Some(obj) = v.as_object_mut() {
        obj.insert("symbol_source".to_string(), serde_json::Value::String("ds2sf-preferred".into()));
        obj.insert("symbol_source_reason".to_string(), serde_json::Value::String(
            "concur reported ul_symbol_has_stub_names; .kicad_sym pin names are stubs (the UL bundle did not carry real labels). Downstream sym_create should author from <MPN>-symbol.extracted.json instead.".into()
        ));
    }
    std::fs::write(&info, serde_json::to_string_pretty(&v)?)?;
    Ok(())
}

/// Write `<MPN>-validation-status.json` so downstream tools can read
/// the final state without re-running the whole pipeline.
fn persist_validation_status(chip_dir: &Path, r: &ConcurResult) -> Result<()> {
    let mpn = chip_dir.file_name().and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("bad chip dir"))?;
    let path = chip_dir.join(format!("{mpn}-validation-status.json"));
    let body = serde_json::json!({
        "schema_version": "1",
        "validated_at": std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs()).unwrap_or(0),
        "concur_status": r.status,
        "sources_compared": r.sources_compared,
        "sources_missing": r.sources_missing,
        "variants": r.variants.iter().map(|v| {
            let mut m = serde_json::Map::new();
            m.insert("kind".into(), serde_json::Value::String(v.kind.clone()));
            for (k, val) in &v.extra { m.insert(k.clone(), val.clone()); }
            serde_json::Value::Object(m)
        }).collect::<Vec<_>>(),
        "summary": r.summary,
    });
    std::fs::write(&path, serde_json::to_string_pretty(&body)?)?;
    println!("OK: validation status persisted to {}", path.display());
    Ok(())
}

/// Refetch a specific library file from a specific source by shelling to
/// `chip-fetcher refetch`. `concur`'s `suggested_sources` is the
/// priority-ordered list; the validate loop walks it and calls this
/// function for the first untried entry. Returns Ok(()) when refetch
/// landed a new file, Err otherwise — the loop then re-runs concur and
/// expects the suspect axis to flip verdict.
///
/// Source slug normalization: maps third-party variants ("snapeda",
/// "Manufacturer site: foo (longer note)") to canonical Source enum
/// before invoking the subprocess. concur's suggested_sources entries
/// often carry verbose context after a colon ("Manufacturer site: …
/// search the part number on the manufacturer's website…"), so we
/// extract just the leading slug for routing.
fn attempt_refetch_library(chip_dir: &Path, format: &str, source: &str) -> Result<()> {
    let mpn = chip_dir.file_name().and_then(|n| n.to_str())
        .ok_or_else(|| anyhow!("bad chip dir"))?;

    // concur emits sources as either bare slugs ("Manufacturer", "Ultra
    // Librarian", "SnapMagic / SnapEDA") or as longer descriptive strings
    // ("Manufacturer site: foo (search the part number…)"). Extract just
    // the routing slug so we can call refetch with a known source.
    let lower = source.to_ascii_lowercase();
    let source_slug = if lower.contains("manufacturer") || lower.contains("mfr") || lower.contains("vendor") {
        "manufacturer"
    } else if lower.contains("snapmagic") || lower.contains("snapeda") {
        "snapmagic"
    } else if lower.contains("mouser") {
        "mouser"
    } else if lower.contains("digikey") {
        "digikey"
    } else if lower.contains("arrow") {
        "arrow"
    } else if lower.contains("ultra librarian") || lower.contains("ultralibrarian") || lower.contains("componentsearchengine") || lower.contains("cse") {
        "cse_ul"
    } else if lower.contains("lcsc") {
        "lcsc"
    } else {
        let _ = crate::sourcing::append_chain(mpn, crate::sourcing::Source::Manufacturer,
            "refetch_unknown_source",
            Some(&format!("concur suggested {source:?} which doesn't map to a known ladder slug")));
        return Err(anyhow!("could not normalize source slug from {source:?}"));
    };

    println!("OK: invoking `adom-chip-fetcher refetch {mpn} --format {format} --source {source_slug}` (180s download window)");
    // Self-invoke by absolute path — name-independent across renames.
    let exe = std::env::current_exe().unwrap_or_else(|_| "adom-chip-fetcher".into());
    let status = std::process::Command::new(exe)
        .args(["refetch", mpn,
               "--format", format,
               "--source", source_slug])
        .status()
        .map_err(|e| anyhow!("could not exec adom-chip-fetcher refetch: {e}"))?;

    if status.success() {
        // refetch_cmd already appended an OK chain entry on success.
        Ok(())
    } else {
        // refetch_cmd appended a failure entry; we just propagate.
        Err(anyhow!("chip-fetcher refetch exited non-zero (timeout, user didn't click, or import failed) — see info.json.fetched_via_chain"))
    }
}

fn human_reason_from_hints(r: &ConcurResult) -> String {
    // Concise + deduped summary. Many divergent hints repeat the same
    // refetch suggestion (e.g. one per pin disagreement). Collapse to
    // unique action lines + a count of pin-level conflicts.
    let mut refetch_targets: std::collections::BTreeSet<(String, String)> =
        std::collections::BTreeSet::new(); // (format, top-source)
    let mut human_reasons: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut parse_failures: Vec<(String, String)> = Vec::new();
    let mut refetch_count = 0usize;

    for h in &r.hints {
        match h {
            ConcurHint::RefetchLibrarySource { format, suggested_sources, .. } => {
                refetch_count += 1;
                if let Some(top) = suggested_sources.iter().next() {
                    // Use just the leading slug, not the long verbose note.
                    let trimmed = top.split(':').next().unwrap_or(top).trim().to_string();
                    refetch_targets.insert((format.clone(), trimmed));
                }
            }
            ConcurHint::HumanReview { reason, .. } => {
                human_reasons.insert(reason.clone().unwrap_or_else(|| "?".into()));
            }
            ConcurHint::SourceParseFailed { format, error, .. } => {
                parse_failures.push((format.clone(), error.clone().unwrap_or_else(|| "?".into())));
            }
            _ => {}
        }
    }

    let mut parts: Vec<String> = Vec::new();
    if refetch_count > 0 {
        parts.push(format!("{refetch_count} divergent axis hint(s) → refetch needed: {}",
            refetch_targets.iter()
                .map(|(f, s)| format!("{f}@{s}"))
                .collect::<Vec<_>>().join(", ")));
    }
    for r in human_reasons { parts.push(format!("human_review:{r}")); }
    for (f, e) in parse_failures { parts.push(format!("parse_failed:{f}({e})")); }

    if parts.is_empty() {
        r.summary.clone().unwrap_or_else(|| "concur divergent — no actionable hints".into())
    } else {
        parts.join("; ")
    }
}

/// Pretty-print a ValidationOutcome for the CLI surface.
pub fn print_outcome(out: &ValidationOutcome) {
    match out {
        ValidationOutcome::Golden { mpn } => {
            println!("OK: {mpn} → GOLDEN (every source agrees)");
        }
        ValidationOutcome::GoldenWithVariants { mpn, variants, used_ds2sf_preferred_symbol } => {
            println!("OK: {mpn} → GOLDEN_WITH_VARIANTS ({} variant{})",
                variants.len(), if variants.len() == 1 { "" } else { "s" });
            for v in variants {
                println!("    variant: {}", v.kind);
            }
            if *used_ds2sf_preferred_symbol {
                println!("    note: symbol_source = ds2sf-preferred (UL bundle had stub pin names)");
            }
        }
        ValidationOutcome::NeedsHuman { mpn, reason, last_concur_status, last_ds2sf_status } => {
            println!("WARN: {mpn} → NEEDS_HUMAN");
            println!("    reason: {reason}");
            if let Some(s) = last_ds2sf_status { println!("    last ds2sf status: {s}"); }
            if let Some(s) = last_concur_status { println!("    last concur status: {s}"); }
        }
        ValidationOutcome::Ds2sfUnrecoverable { mpn, summary } => {
            println!("ERROR: {mpn} → DS2SF_UNRECOVERABLE");
            if let Some(s) = summary { println!("    {s}"); }
        }
    }
}