123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
use anyhow::Context as _;
#[allow(unused_imports)]
use anyhow::Result;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Command;

use std::path::Path;
use crate::context::Context;
use crate::extract::SymbolOutcome;
use crate::io::{err, hint, note, ok, set_json_only};
use crate::result::{self, Hint, Outputs, ResultDoc, Status};
use crate::{emit, extract, normalize};

#[derive(Parser)]
#[command(name = "adom-ds2sf", version, about = "Datasheet → symbol + footprint + provenance JSON")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Cmd,
}

#[derive(Subcommand)]
pub enum Cmd {
    /// Read a chip's datasheet PDF and emit symbol + footprint + provenance JSONs.
    Extract {
        /// Path to a chip-fetcher chip directory (e.g. /home/adom/project/chip-fetcher/library/ADS1115IDGSR).
        chip_dir: PathBuf,

        /// Override auto-detected datasheet PDF.
        #[arg(long)]
        pdf: Option<PathBuf>,

        /// Override the MPN (defaults to chip-dir basename or info.json).
        #[arg(long)]
        mpn: Option<String>,

        /// Override the manufacturer name.
        #[arg(long)]
        manufacturer: Option<String>,

        /// Override the datasheet URL.
        #[arg(long)]
        datasheet_url: Option<String>,

        /// Override the datasheet source tier.
        #[arg(long, value_parser = ["mfr","snapmagic","mouser","digikey","arrow","cse","lcsc","unknown"])]
        source_tier: Option<String>,

        /// Output directory (defaults to chip-dir).
        #[arg(long)]
        out_dir: Option<PathBuf>,

        /// Claude model to use.
        #[arg(long, default_value = "claude-opus-4-7")]
        model: String,

        /// Bypass the cache and re-run Claude even if outputs exist.
        #[arg(long)]
        force: bool,

        /// Suppress progress text; only emit OK:/ERROR: lines.
        #[arg(long)]
        json_only: bool,

        /// Skip Stage 1b (footprint pass) — useful for symbol-only sanity checks.
        #[arg(long)]
        symbol_only: bool,

        /// Skip Stage 1a (symbol pass) — re-run only the footprint pass against existing cached symbol output.
        #[arg(long)]
        footprint_only: bool,
    },
    /// Resolve a package string against the service-kicad standard library.
    Normalize {
        /// Package string to resolve, e.g. "VSSOP-10" or "QFN-32 5x5 0.5mm".
        package: String,
    },
    /// Print a summary of an existing extraction in a chip directory.
    Inspect {
        chip_dir: PathBuf,
    },
    /// Regenerate `<MPN>-footprint.authoritative.svg` from the cached
    /// footprint JSON without re-running Claude. Useful for downstream
    /// tooling that wants the visual reference without paying for an
    /// extraction, and for iterating on the SVG generator itself.
    Svg {
        chip_dir: PathBuf,
    },
    /// Probe `claude` CLI, service-kicad reachability.
    Health,
    /// Drop SKILL.md to ~/.claude/skills/adom-ds2sf/.
    Install,
}

pub fn run(cli: Cli) -> i32 {
    match cli.command {
        Cmd::Extract {
            chip_dir,
            pdf,
            mpn,
            manufacturer,
            datasheet_url,
            source_tier,
            out_dir,
            model,
            force,
            json_only,
            symbol_only,
            footprint_only,
        } => {
            set_json_only(json_only);
            run_extract(
                chip_dir, pdf, mpn, manufacturer, datasheet_url, source_tier,
                out_dir, model, force, symbol_only, footprint_only,
            )
        }
        Cmd::Normalize { package } => match normalize::resolve(&package, None) {
            Ok(resolved) => {
                match resolved.kicad_baseline {
                    Some(b) => ok(format!("{} → {}:{} (service-kicad stdlib)", package, b.library, b.name)),
                    None => {
                        crate::io::warn(format!(
                            "{} → no service-kicad stdlib match (tried {} libraries)",
                            package, resolved.search_attempts.len()
                        ));
                        for a in &resolved.search_attempts {
                            note(format!("  tried {}:{} → {}", a.library, a.name, a.result));
                        }
                    }
                }
                0
            }
            Err(e) => { err(e); 1 }
        },
        Cmd::Inspect { chip_dir } => {
            let prov_path = chip_dir.join(format!(
                "{}-extraction.provenance.json",
                chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?")
            ));
            if !prov_path.exists() {
                err(format!("no provenance at {}", prov_path.display()));
                hint(format!(
                    "run `ds2sf extract {}` first",
                    chip_dir.display()
                ));
                return 1;
            }
            match std::fs::read_to_string(&prov_path)
                .context("reading provenance")
                .and_then(|raw| serde_json::from_str::<Value>(&raw).context("parsing provenance"))
            {
                Ok(prov) => { print_inspect(&prov); 0 }
                Err(e) => { err(e); 1 }
            }
        }
        Cmd::Svg { chip_dir } => match run_svg_only(&chip_dir) {
            Ok(r) => {
                if let Some(p) = &r.ds2sf_svg {
                    ok(format!("wrote {} (ds2sf-datasheet-derived)", p.display()));
                }
                if let Some(p) = &r.pcbnew_svg {
                    ok(format!("wrote {} (pcbnew-stdlib)", p.display()));
                }
                if r.ds2sf_svg.is_none() {
                    err("SVG generation failed"); 1
                } else { 0 }
            }
            Err(e) => { err(e); 1 }
        },
        Cmd::Health => {
            let mut all_ok = true;

            match Command::new("claude").arg("--version").output() {
                Ok(o) if o.status.success() => {
                    let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
                    ok(format!("claude CLI {v}"));
                }
                _ => {
                    err("claude CLI not on PATH");
                    hint("install from claude.ai/code or ensure it is in $PATH");
                    all_ok = false;
                }
            }

            // service-kicad probe — informational only; extraction runs without it
            let url = "https://kicad-rk5ue5pcfemi.adom.cloud/health";
            match ureq::get(url).timeout(std::time::Duration::from_secs(3)).call() {
                Ok(r) if r.status() == 200 => ok(format!("service-kicad reachable ({url})")),
                Ok(r) => {
                    crate::io::warn(format!("service-kicad returned status {} — stdlib match disabled", r.status()));
                }
                Err(e) => {
                    crate::io::warn(format!("service-kicad unreachable ({e}) — stdlib match disabled"));
                }
            }

            if all_ok {
                ok("ds2sf ready");
                0
            } else {
                1
            }
        }
        Cmd::Install => {
            let home = match std::env::var("HOME") {
                Ok(h) => h,
                Err(_) => { err("HOME not set"); return 1; }
            };
            let dest_dir = std::path::PathBuf::from(&home).join(".claude/skills/adom-ds2sf");
            if let Err(e) = std::fs::create_dir_all(&dest_dir) {
                err(format!("creating skill dir: {e}")); return 1;
            }
            let dest = dest_dir.join("SKILL.md");

            // SAFETY NET — do not rely on for release propagation.
            const SKILL_MD: &str = include_str!("../SKILL.md");
            if let Err(e) = std::fs::write(&dest, SKILL_MD) {
                err(format!("writing SKILL.md: {e}")); return 1;
            }
            ok(format!("installed SKILL.md → {}", dest.display()));
            0
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn run_extract(
    chip_dir: PathBuf,
    pdf: Option<PathBuf>,
    mpn: Option<String>,
    manufacturer: Option<String>,
    datasheet_url: Option<String>,
    source_tier: Option<String>,
    out_dir: Option<PathBuf>,
    model: String,
    force: bool,
    symbol_only: bool,
    footprint_only: bool,
) -> i32 {
    let ctx = match Context::ingest(
        &chip_dir,
        pdf.as_deref(),
        mpn.as_deref(),
        manufacturer.as_deref(),
        datasheet_url.as_deref(),
        source_tier.as_deref(),
        out_dir.as_deref(),
    ) {
        Ok(c) => c,
        Err(e) => {
            err(&e);
            // Best-effort: emit a result.json describing the pre-flight failure.
            // We can't write into out_dir without ctx, but we can still log.
            return Status::Unrecoverable.exit_code();
        }
    };

    if !force && match ctx.outputs_exist() { Ok(b) => b, Err(_) => false }
        && !symbol_only && !footprint_only
    {
        // Cache hit: the prior result.json (if any) stands. Return its exit code,
        // or 0 if the legacy run pre-dates result.json.
        let cached_status = std::fs::read_to_string(result::result_path(&ctx))
            .ok()
            .and_then(|raw| serde_json::from_str::<ResultDoc>(&raw).ok())
            .map(|d| d.exit_code)
            .unwrap_or(0);
        ok(format!(
            "{} — cached extraction at {} (use --force to re-run)",
            ctx.mpn,
            ctx.provenance_path().display()
        ));
        return cached_status;
    }

    if let Err(e) = ctx.cache_dir_create() {
        err(e);
        return Status::Unrecoverable.exit_code();
    }
    let _ = ctx.write_pdf_sha_marker();

    note(format!(
        "ds2sf {}: {} ({} pages, sha256 {}…)",
        ctx.mpn,
        ctx.datasheet_path.display(),
        ctx.page_count.unwrap_or(0),
        &ctx.datasheet_sha256[..12],
    ));

    // Stage 1a — symbol pass (or load cached for --footprint-only).
    let symbol = if footprint_only {
        match extract::load_cached_symbol(&ctx) {
            Ok(s) => s,
            Err(e) => {
                err(format!("--footprint-only requires a cached symbol pass: {e}"));
                return finalize_unrecoverable(&ctx, &model, "no_cached_symbol", &e.to_string());
            }
        }
    } else {
        match extract::run_symbol_pass(&ctx, &model) {
            Ok(SymbolOutcome::Ok(s)) => {
                let _ = extract::cache_symbol(&ctx, &s);
                s
            }
            Ok(SymbolOutcome::Refused { review_required, raw_response }) => {
                let _ = std::fs::write(
                    ctx.cache_dir().join("claude-symbol-refusal.txt"),
                    raw_response,
                );
                return finalize_refusal(&ctx, &model, review_required);
            }
            Err(e) => {
                err(format!("symbol-pass failed: {e}"));
                return finalize_unrecoverable(&ctx, &model, "symbol_pass_error", &e.to_string());
            }
        }
    };

    // Stage 1b — footprint pass (skipped if --symbol-only).
    let footprint = if symbol_only {
        None
    } else {
        match extract::run_footprint_pass(&ctx, &model, &symbol) {
            Ok(fp) => {
                let _ = extract::cache_footprint(&ctx, &fp);
                Some(fp)
            }
            Err(e) => {
                err(format!("footprint-pass failed: {e}"));
                return finalize_unrecoverable(&ctx, &model, "footprint_pass_error", &e.to_string());
            }
        }
    };

    let normalized = footprint
        .as_ref()
        .map(|fp| normalize::resolve(&fp.package_name, ctx.package_hint.as_deref()).ok())
        .unwrap_or(None);

    if let Err(e) = emit::write_all(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &model) {
        err(format!("emit failed: {e}"));
        return finalize_unrecoverable(&ctx, &model, "emit_error", &e.to_string());
    }

    // Generate footprint SVGs — two independent renderings:
    //   1. ds2sf SVG: synthesized from the datasheet mechanical drawing
    //   2. pcbnew SVG: fetched from service-kicad (when stdlib match exists)
    let svg_result = if let Some(fp) = footprint.as_ref() {
        let r = crate::fpsvg::generate(&ctx, fp, normalized.as_ref());
        if let Some(p) = &r.ds2sf_svg {
            note(format!("[svg] datasheet-derived SVG → {}", p.display()));
        }
        if let Some(p) = &r.pcbnew_svg {
            note(format!("[svg] pcbnew comparison SVG → {}", p.display()));
        }
        if r.ds2sf_svg.is_none() {
            crate::io::warn(format!("[svg] ds2sf SVG generation failed for {}", ctx.mpn));
        }
        Some(r)
    } else {
        None
    };

    let summary = emit::summary_line(&ctx, &symbol, footprint.as_ref(), normalized.as_ref());
    ok(&summary);

    let merged_review = extract::merge_review_required(&symbol, footprint.as_ref());
    for entry in &merged_review {
        let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("unspecified");
        let detail = serde_json::to_string(entry).unwrap_or_default();
        crate::io::warn(format!("review needed — {reason}: {detail}"));
    }

    // Build hints[] from review_required + normalize attempts + package mismatch.
    let mut hints = build_ok_hints(&ctx, &symbol, footprint.as_ref(), normalized.as_ref(), &merged_review);

    let result_doc = ResultDoc {
        schema_version: "1".into(),
        status: Status::Ok,
        exit_code: Status::Ok.exit_code(),
        mpn: ctx.mpn.clone(),
        manufacturer: ctx.manufacturer.clone(),
        datasheet_used: ctx.datasheet_path.clone(),
        datasheet_sha256: ctx.datasheet_sha256.clone(),
        summary,
        review_required: merged_review,
        hints: std::mem::take(&mut hints),
        outputs: Outputs {
            symbol_json: Some(ctx.symbol_out_path()),
            footprint_json: footprint.as_ref().map(|_| ctx.footprint_out_path()),
            provenance_json: Some(ctx.provenance_path()),
            footprint_svg_ds2sf: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),
            footprint_svg_pcbnew: svg_result.as_ref().and_then(|r| r.pcbnew_svg.clone()),
            footprint_svg: svg_result.as_ref().and_then(|r| r.ds2sf_svg.clone()),
            footprint_svg_source: Some("ds2sf-datasheet-derived".to_string()),
        },
        extractor_version: env!("CARGO_PKG_VERSION").into(),
        claude_model: model.clone(),
    };
    if let Err(e) = result::write(&ctx, &result_doc) {
        err(format!("writing result.json: {e}"));
    }

    Status::Ok.exit_code()
}

fn finalize_refusal(ctx: &Context, model: &str, review_required: Vec<Value>) -> i32 {
    let alts = result::find_alternate_pdfs(ctx);
    let mut hints: Vec<Hint> = Vec::new();
    if !alts.is_empty() {
        hints.push(Hint::TryAlternatePdf {
            reason: result::reasons::MODULE_EXTERNAL_PINOUT_MISSING.into(),
            candidates: alts,
        });
    }
    let summary = format!(
        "{} — extractor refused: supplied PDF doesn't contain the right pinout (likely a module's SoC datasheet was passed instead of the module datasheet, or the wrong variant). Re-call with a different --pdf.",
        ctx.mpn
    );
    crate::io::warn(&summary);

    let doc = ResultDoc {
        schema_version: "1".into(),
        status: Status::NeedsBetterPdf,
        exit_code: Status::NeedsBetterPdf.exit_code(),
        mpn: ctx.mpn.clone(),
        manufacturer: ctx.manufacturer.clone(),
        datasheet_used: ctx.datasheet_path.clone(),
        datasheet_sha256: ctx.datasheet_sha256.clone(),
        summary,
        review_required,
        hints,
        outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },
        extractor_version: env!("CARGO_PKG_VERSION").into(),
        claude_model: model.into(),
    };
    let _ = result::write(ctx, &doc);
    Status::NeedsBetterPdf.exit_code()
}

fn finalize_unrecoverable(ctx: &Context, model: &str, reason: &str, notes: &str) -> i32 {
    let summary = format!("{} — unrecoverable: {reason}", ctx.mpn);
    let review_required = vec![json!({
        "reason": result::reasons::UNRECOVERABLE_EXTRACTION_ERROR,
        "stage_reason": reason,
        "notes": notes,
    })];
    let doc = ResultDoc {
        schema_version: "1".into(),
        status: Status::Unrecoverable,
        exit_code: Status::Unrecoverable.exit_code(),
        mpn: ctx.mpn.clone(),
        manufacturer: ctx.manufacturer.clone(),
        datasheet_used: ctx.datasheet_path.clone(),
        datasheet_sha256: ctx.datasheet_sha256.clone(),
        summary,
        review_required,
        hints: vec![Hint::HumanReview {
            reason: reason.into(),
            notes: notes.into(),
        }],
        outputs: Outputs { symbol_json: None, footprint_json: None, provenance_json: None, footprint_svg_ds2sf: None, footprint_svg_pcbnew: None, footprint_svg: None, footprint_svg_source: None },
        extractor_version: env!("CARGO_PKG_VERSION").into(),
        claude_model: model.into(),
    };
    let _ = result::write(ctx, &doc);
    Status::Unrecoverable.exit_code()
}

fn build_ok_hints(
    ctx: &Context,
    _sym: &extract::SymbolPass,
    fp: Option<&extract::FootprintPass>,
    norm: Option<&normalize::Resolved>,
    review: &[Value],
) -> Vec<Hint> {
    let mut hints = Vec::new();

    // Package mismatch between info.json and datasheet → upstream metadata fix.
    for entry in review {
        let reason = entry.get("reason").and_then(|v| v.as_str()).unwrap_or("");
        if reason == result::reasons::PACKAGE_MISMATCH_IN_HINT {
            let info_json = ctx.chip_dir.join("info.json");
            let extracted = fp
                .map(|f| f.package_name.clone())
                .unwrap_or_default();
            hints.push(Hint::UpdateUpstreamMetadata {
                file: info_json,
                key: "package".into(),
                current: ctx.package_hint.clone().unwrap_or_default(),
                extracted,
            });
        } else {
            // Generic human-review surfacing for everything else.
            let notes = entry
                .get("notes")
                .or_else(|| entry.get("note"))
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            hints.push(Hint::HumanReview {
                reason: reason.to_string(),
                notes,
            });
        }
    }

    // Custom package → caller should use datasheet-extracted dimensions.
    if let Some(fp) = fp {
        let no_match = norm.map(|n| n.kicad_baseline.is_none()).unwrap_or(true);
        if no_match {
            hints.push(Hint::UseDatasheetExtractedFootprint {
                package: fp.package_name.clone(),
            });
        }
    }

    hints
}

fn print_inspect(prov: &serde_json::Value) {
    let mpn = prov.get("mpn").and_then(|v| v.as_str()).unwrap_or("?");
    let manuf = prov.get("manufacturer").and_then(|v| v.as_str()).unwrap_or("?");
    let pkg = prov
        .pointer("/footprint/package_name_provenance/value")
        .and_then(|v| v.as_str())
        .unwrap_or("?");
    let baseline = prov
        .pointer("/footprint/kicad_baseline")
        .map(|v| {
            if v.is_null() {
                "no stdlib match".to_string()
            } else {
                let lib = v.get("library").and_then(|x| x.as_str()).unwrap_or("?");
                let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?");
                format!("{lib}:{name}")
            }
        })
        .unwrap_or_else(|| "unknown".to_string());
    let pin_count = prov
        .pointer("/symbol/pin_provenance")
        .and_then(|v| v.as_array())
        .map(|a| a.len())
        .unwrap_or(0);
    let review = prov
        .get("review_required")
        .and_then(|v| v.as_array())
        .map(|a| a.len())
        .unwrap_or(0);

    note(format!("MPN: {mpn} (manufacturer: {manuf})"));
    note(format!("Package: {pkg}"));
    note(format!("Stdlib match: {baseline}"));
    note(format!("Pins extracted: {pin_count}"));
    note(format!("review_required entries: {review}"));

    if let Some(arr) = prov.get("review_required").and_then(|v| v.as_array()) {
        for r in arr {
            note(format!("  - {}", serde_json::to_string(r).unwrap_or_default()));
        }
    }
}

/// `ds2sf svg <CHIP-DIR>` — regenerate the authoritative footprint SVG
/// from the cached `<MPN>-footprint.extracted.json`. Useful for iterating
/// on the SVG generator without re-running Claude, and for downstream
/// tooling that wants the visual reference.
fn run_svg_only(chip_dir: &Path) -> anyhow::Result<crate::fpsvg::SvgResult> {
    let chip_dir = chip_dir.canonicalize()?;
    let mpn = chip_dir
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("")
        .to_string();
    let fp_path = chip_dir.join(format!("{mpn}-footprint.extracted.json"));
    let fp_raw = std::fs::read_to_string(&fp_path).map_err(|e| {
        anyhow::anyhow!("reading {} — run `ds2sf extract` first: {e}", fp_path.display())
    })?;
    let fp_json: serde_json::Value = serde_json::from_str(&fp_raw)?;

    // Reconstruct a minimal FootprintPass + Resolved from the cached JSON.
    let pkg_name = fp_json
        .get("package")
        .and_then(|v| v.as_str())
        .unwrap_or("UNKNOWN")
        .to_string();
    let body_x = fp_json.pointer("/bodyDimensions/x").and_then(|v| v.as_f64()).unwrap_or(2.0);
    let body_y = fp_json.pointer("/bodyDimensions/y").and_then(|v| v.as_f64()).unwrap_or(2.0);
    let body_t = fp_json.pointer("/bodyDimensions/thickness_max").and_then(|v| v.as_f64());
    let pad_count = fp_json.get("padCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
    let lead_dimensions = fp_json
        .get("leadDimensions").cloned()
        .unwrap_or(serde_json::Value::Null);
    let pad_descriptions = fp_json
        .get("padDescriptions")
        .and_then(|v| v.as_object()).cloned()
        .unwrap_or_default();
    let symbol_pin_map = fp_json
        .get("symbolPinMap")
        .and_then(|v| v.as_object()).cloned()
        .unwrap_or_default();

    let fp = crate::extract::FootprintPass {
        package_name: pkg_name,
        is_standard_jedec: fp_json.get("kicad_baseline").map(|v| !v.is_null()).unwrap_or(false),
        package_name_source_page: 0,
        pad_count,
        body_dimensions: crate::extract::BodyDims {
            x: body_x,
            y: body_y,
            thickness_max: body_t,
            source_page: 0,
            figure_label: String::new(),
        },
        lead_dimensions,
        mechanical: fp_json.get("mechanical").cloned(),
        pad_descriptions,
        symbol_pin_map,
        review_required: vec![],
    };

    let kicad_baseline = fp_json
        .get("kicad_baseline")
        .and_then(|v| if v.is_null() { None } else { Some(v) })
        .and_then(|v| {
            let lib = v.get("library").and_then(|x| x.as_str())?.to_string();
            let name = v.get("name").and_then(|x| x.as_str())?.to_string();
            Some(crate::normalize::KicadBaseline {
                library: lib,
                name,
                source: "service-kicad-stdlib".into(),
            })
        });
    let resolved = crate::normalize::Resolved {
        input: fp_json.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string(),
        kicad_baseline,
        search_attempts: vec![],
    };

    // Build a minimal Context with just the fields fpsvg needs.
    let mpn_buf = chip_dir.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
    let ctx = crate::context::Context {
        mpn: mpn_buf.clone(),
        manufacturer: None,
        package_hint: None,
        description_hint: None,
        family_hint: None,
        datasheet_path: chip_dir.join(format!("{mpn_buf}.pdf")),
        datasheet_url: None,
        datasheet_source_tier: None,
        datasheet_source_host: None,
        datasheet_sha256: String::new(),
        fetched_at: None,
        extracted_at: String::new(),
        chip_dir: chip_dir.clone(),
        out_dir: chip_dir.clone(),
        page_count: None,
    };

    let r = crate::fpsvg::generate(&ctx, &fp, Some(&resolved));
    // Back-stamp SVG paths onto result.json if it exists.
    let result_path = chip_dir.join(format!("{mpn_buf}-extraction.result.json"));
    if result_path.exists() && r.ds2sf_svg.is_some() {
        if let Ok(raw) = std::fs::read_to_string(&result_path) {
            if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&raw) {
                if let Some(outputs) = doc.get_mut("outputs").and_then(|v| v.as_object_mut()) {
                    if let Some(p) = &r.ds2sf_svg {
                        outputs.insert("footprint_svg_ds2sf".into(), serde_json::json!(p.display().to_string()));
                        outputs.insert("footprint_svg".into(), serde_json::json!(p.display().to_string()));
                    }
                    if let Some(p) = &r.pcbnew_svg {
                        outputs.insert("footprint_svg_pcbnew".into(), serde_json::json!(p.display().to_string()));
                    }
                    outputs.insert("footprint_svg_source".into(), serde_json::json!("ds2sf-datasheet-derived"));
                    let _ = std::fs::write(
                        &result_path,
                        serde_json::to_string_pretty(&doc).unwrap_or(raw) + "\n",
                    );
                }
            }
        }
    }
    Ok(r)
}