123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
use anyhow::{anyhow, Context as _, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};

use crate::context::Context;
use crate::io::{note, warn};

/// Outcome of the symbol pass. Lets cli.rs distinguish "model refused with explanation"
/// (which is recoverable — try a different PDF) from a hard parse/CLI failure.
pub enum SymbolOutcome {
    Ok(SymbolPass),
    Refused {
        review_required: Vec<Value>,
        raw_response: String,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolPass {
    #[serde(rename = "symbolName")]
    pub symbol_name: String,
    pub manufacturer: Option<String>,
    pub package: Option<String>,
    pub description: Option<String>,
    #[serde(default = "default_category")]
    pub category: String,
    #[serde(rename = "referencePrefix", default = "default_ref_prefix")]
    pub reference_prefix: String,
    pub pins: Vec<Pin>,
    #[serde(default)]
    pub groups: Vec<Group>,
    /// Optional KiCad standard-library baseline for discrete parts
    /// (transistors / diodes / passives). Lets downstream sym_create reuse
    /// the canonical symbol shape instead of authoring a generic rectangle.
    #[serde(default, rename = "discreteBaseline", skip_serializing_if = "Option::is_none")]
    pub discrete_baseline: Option<DiscreteBaseline>,
    #[serde(default)]
    pub variants: Vec<Value>,
    #[serde(default, rename = "review_required")]
    pub review_required: Vec<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscreteBaseline {
    pub library: String,
    pub symbol: String,
}

fn default_category() -> String { "ic".to_string() }
fn default_ref_prefix() -> String { "U".to_string() }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pin {
    pub number: String,
    pub name: String,
    #[serde(rename = "type")]
    pub kind: String,
    #[serde(default)]
    pub group: String,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub source_page: u32,
    #[serde(default)]
    pub source_table_caption: String,
    #[serde(default = "default_confidence")]
    pub confidence: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub notes: String,
}

fn default_confidence() -> String { "medium".to_string() }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
    pub name: String,
    #[serde(default)]
    pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FootprintPass {
    pub package_name: String,
    #[serde(default)]
    pub is_standard_jedec: bool,
    #[serde(default)]
    pub package_name_source_page: u32,
    #[serde(rename = "padCount")]
    pub pad_count: u32,
    #[serde(rename = "bodyDimensions")]
    pub body_dimensions: BodyDims,
    #[serde(rename = "leadDimensions")]
    pub lead_dimensions: Value,
    #[serde(default)]
    pub mechanical: Option<Value>,
    #[serde(rename = "padDescriptions", default)]
    pub pad_descriptions: serde_json::Map<String, Value>,
    #[serde(rename = "symbolPinMap", default)]
    pub symbol_pin_map: serde_json::Map<String, Value>,
    #[serde(default, rename = "review_required")]
    pub review_required: Vec<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BodyDims {
    pub x: f64,
    pub y: f64,
    pub thickness_max: Option<f64>,
    #[serde(default)]
    pub source_page: u32,
    #[serde(default)]
    pub figure_label: String,
}

const KICAD_ERC_TYPES: &[&str] = &[
    "power_in", "power_out", "input", "output", "bidirectional",
    "passive", "unconnected", "tri_state",
];

const ADOM_GROUPS: &[&str] = &[
    "POWER", "GROUND", "I2C", "SPI", "UART", "USB", "QSPI", "GPIO",
    "ADC", "DAC", "CONTROL", "TIMING", "ALERT", "NC", "DEBUG", "RF",
    "CAN", "ETHERNET", "PWM", "AUDIO", "VIDEO", "OTHER",
];

pub fn run_symbol_pass(ctx: &Context, model: &str) -> Result<SymbolOutcome> {
    note(format!("[1a] symbol-pass calling claude (model={model})"));
    let prompt = build_symbol_prompt(ctx);
    let raw = call_claude(&prompt, model, "symbol-pass-1", &ctx.chip_dir)?;
    fs::write(ctx.cache_dir().join("claude-symbol-raw.txt"), &raw).ok();

    let parsed_json = extract_json(&raw)?;

    // Refusal path: empty pins + populated review_required = the model declined to
    // hallucinate (e.g. wrong PDF). This is recoverable upstream — bubble out as
    // a Refused outcome with the review_required so the result.json carries it.
    if let Some(rr) = is_refusal(&parsed_json) {
        return Ok(SymbolOutcome::Refused {
            review_required: rr,
            raw_response: raw,
        });
    }

    match validate_symbol(&parsed_json) {
        Ok(()) => {
            let pass: SymbolPass = serde_json::from_value(parsed_json)
                .context("deserializing symbol-pass JSON")?;
            Ok(SymbolOutcome::Ok(pass))
        }
        Err(e) => {
            warn(format!("symbol-pass validation failed: {e} — retrying once"));
            let retry_prompt = format!(
                "{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",
                prompt, e
            );
            let raw2 = call_claude(&retry_prompt, model, "symbol-pass-2", &ctx.chip_dir)?;
            fs::write(ctx.cache_dir().join("claude-symbol-raw-retry.txt"), &raw2).ok();
            let parsed2 = extract_json(&raw2)?;
            if let Some(rr) = is_refusal(&parsed2) {
                return Ok(SymbolOutcome::Refused {
                    review_required: rr,
                    raw_response: raw2,
                });
            }
            validate_symbol(&parsed2)
                .with_context(|| "symbol-pass validation failed after retry")?;
            let pass: SymbolPass = serde_json::from_value(parsed2)
                .context("deserializing symbol-pass JSON (retry)")?;
            Ok(SymbolOutcome::Ok(pass))
        }
    }
}

/// Detects a "refusal" — empty pins[] but review_required[] is populated, meaning
/// Claude explained why it can't extract (typically wrong PDF for a module).
fn is_refusal(v: &Value) -> Option<Vec<Value>> {
    let pins_empty = v
        .get("pins")
        .and_then(|p| p.as_array())
        .map(|a| a.is_empty())
        .unwrap_or(true);
    if !pins_empty {
        return None;
    }
    let rr = v
        .get("review_required")
        .and_then(|r| r.as_array())
        .cloned()
        .unwrap_or_default();
    if rr.is_empty() { None } else { Some(rr) }
}

pub fn run_footprint_pass(ctx: &Context, model: &str, sym: &SymbolPass) -> Result<FootprintPass> {
    note(format!("[1b] footprint-pass calling claude (model={model})"));
    let prompt = build_footprint_prompt(ctx, sym);
    let raw = call_claude(&prompt, model, "footprint-pass-1", &ctx.chip_dir)?;
    fs::write(ctx.cache_dir().join("claude-footprint-raw.txt"), &raw).ok();

    let parsed = extract_json(&raw)?;
    match validate_footprint(&parsed, sym) {
        Ok(()) => {
            let fp: FootprintPass = serde_json::from_value(parsed)
                .context("deserializing footprint-pass JSON")?;
            Ok(fp)
        }
        Err(e) => {
            warn(format!("footprint-pass validation failed: {e} — retrying once"));
            let retry_prompt = format!(
                "{}\n\nYour previous response failed validation:\n{}\n\nRespond again with corrected JSON only.",
                prompt, e
            );
            let raw2 = call_claude(&retry_prompt, model, "footprint-pass-2", &ctx.chip_dir)?;
            fs::write(ctx.cache_dir().join("claude-footprint-raw-retry.txt"), &raw2).ok();
            let parsed2 = extract_json(&raw2)?;
            validate_footprint(&parsed2, sym)
                .with_context(|| "footprint-pass validation failed after retry")?;
            let fp: FootprintPass = serde_json::from_value(parsed2)
                .context("deserializing footprint-pass JSON (retry)")?;
            Ok(fp)
        }
    }
}

pub fn cache_symbol(ctx: &Context, sym: &SymbolPass) -> Result<()> {
    let p = ctx.symbol_cache_path();
    fs::write(&p, serde_json::to_string_pretty(sym)?)
        .with_context(|| format!("writing {}", p.display()))?;
    Ok(())
}

pub fn cache_footprint(ctx: &Context, fp: &FootprintPass) -> Result<()> {
    let p = ctx.footprint_cache_path();
    fs::write(&p, serde_json::to_string_pretty(fp)?)
        .with_context(|| format!("writing {}", p.display()))?;
    Ok(())
}

pub fn load_cached_symbol(ctx: &Context) -> Result<SymbolPass> {
    let p = ctx.symbol_cache_path();
    let raw = fs::read_to_string(&p)
        .with_context(|| format!("reading cached symbol at {}", p.display()))?;
    serde_json::from_str(&raw).context("parsing cached symbol JSON")
}

pub fn merge_review_required(sym: &SymbolPass, fp: Option<&FootprintPass>) -> Vec<Value> {
    let mut all = sym.review_required.clone();
    if let Some(f) = fp {
        all.extend(f.review_required.clone());
    }
    all
}

fn build_symbol_prompt(ctx: &Context) -> String {
    let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown manufacturer)");
    let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no package hint)");
    let desc = ctx.description_hint.as_deref().unwrap_or("");
    let ds_path = ctx.datasheet_path.display();
    let mpn = &ctx.mpn;

    format!(
        r#"You are extracting symbol/pinout data for the chip "{mpn}" from its datasheet PDF.

CONTEXT (from upstream chip-fetcher):
- MPN: {mpn}
- Manufacturer: {manuf}
- Package hint: {pkg_hint}
- Family/description hint: {desc}

DATASHEET PDF (please use the Read tool to read this file):
  {ds_path}

THIS CALL IS ONLY ABOUT PIN/SYMBOL DATA. Ignore mechanical drawings and footprint
land patterns — a separate call will handle those. Your output must be valid JSON
matching the schema below; do not return explanatory text outside the JSON block.

If the datasheet covers multiple package variants, restrict the pinout to the
variant matching the package hint above.

SCHEMA:
{{
  "symbolName": "string (use the MPN, possibly stripped of suffixes)",
  "manufacturer": "string",
  "package": "string (concise package name, e.g. 'VSSOP-10')",
  "description": "1-3 sentence chip summary",
  "category": "ic | discrete",
  "referencePrefix": "U (IC) | Q (transistor) | D (diode/LED) | R | C | L | Y (crystal) | F (fuse) | BT | J/P (connector) | SW | K (relay) | T (transformer)",
  "discreteBaseline": {{ "library": "Transistor_FET | Device | ...", "symbol": "Q_PMOS_GSD | LED | ..." }},
  "pins": [
    {{
      "number": "string (pin number as it appears on the package; e.g. '1' or 'A4')",
      "name": "string (e.g. 'VDD', 'SCL', 'GPIO0')",
      "type": "one of: power_in, power_out, input, output, bidirectional, passive, unconnected, tri_state",
      "group": "one of: POWER, GROUND, I2C, SPI, UART, USB, QSPI, GPIO, ADC, DAC, CONTROL, TIMING, ALERT, NC, DEBUG, RF, CAN, ETHERNET, PWM, AUDIO, VIDEO, OTHER",
      "description": "1-3 sentence engineering description with V/I/Ω where applicable",
      "source_page": 0,
      "source_table_caption": "string (e.g. 'Table 7-1. Pin description')",
      "confidence": "high | medium | low",
      "notes": "optional — muxed-pin protocols, alt-functions, reset-state, etc."
    }}
  ],
  "groups": [
    {{ "name": "POWER", "description": "2-4 sentence group description" }}
  ],
  "variants": [
    {{
      "package": "string (e.g. 'VSSOP-10')",
      "ordering_suffixes": ["IDGSR", "IDGS"],
      "pin_count": 10,
      "body_dims_approx_mm": [3.0, 3.0, 1.1],
      "source_pages": [1, 48],
      "compatible_pinout": true,
      "notes": "optional — differences from the main extraction (thermal pad, extra pins, etc.)"
    }}
  ],
  "review_required": [
    {{ "reason": "muxed_pin_protocol_select", "pins": ["A4","A5"] }}
  ]
}}

MULTI-VARIANT DISCOVERY:
- Most datasheets describe the chip in multiple package variants (e.g. SOIC-8 + TSSOP-8,
  or QFN-32 + LQFP-48). List ALL variants you find in the "variants" array.
- For each variant: the package name, any ordering code suffixes that map to it
  (check the ordering information or package marking table), approximate body
  dimensions if visible, and which pages describe it.
- compatible_pinout = true when the variant has the same pin-name assignments as
  the main extraction (just a different physical package). Set false when pin
  assignments differ (different number of pins, or pins rearranged).
- The main pins[] / groups[] still focus on the variant matching package_hint above.
  The variants[] array is a discovery catalog for chip-fetcher to offer the user
  alternative packages (smaller, cheaper, different thermal characteristics).
- Include at least the variant matching package_hint in the array.

RULES:
- Every pin MUST include source_page (the 1-indexed PDF page where the pin's row appears in the pinout table).
- Use canonical Adom group names exactly. Collapse separate VCC/GND tables into POWER unless the datasheet has a clear distinction.
- For muxed pins (e.g. SDA/MOSI), keep the slash-delimited name and add a note explaining how the protocol is selected.
- When uncertain, set confidence to "low" and add a row to review_required.
- If the data isn't in the datasheet, omit the field and surface it in review_required — DO NOT hallucinate.
- IF THE PART IS A MODULE (chip + supporting components packaged together with castellated edge pads, e.g. ESP32-WROOM, NORA, ATWINC, SARA-R5, etc.), extract the MODULE's external pinout — typically 20-60 castellated pads — NOT the internal SoC's pinout. The module's pin numbers are usually 1..N matching silkscreen labels on the module's edge. Add a review_required entry of reason "module_external_pinout" so downstream tools know this symbol represents a module.

- IF THE DATASHEET DESCRIBES SIGNAL CLASSES BUT DEFERS PER-PACKAGE PIN-NUMBER ASSIGNMENTS to a separate supplement (Lattice iCE40 family is the textbook case — main datasheet has only signal-level descriptions like CRESET_B, SPI_SCK, G0–G6, RGB0/1/2, generic PIOT_xx/PIOB_xx, with the SG48/UG121-pin-number mapping deferred to "iCE40 UltraPlus Pinout Files" or a "Package Diagrams" supplement) — DO NOT EXTRACT PARTIAL SIGNAL DATA. Instead emit `pins: []` and add a `review_required` entry with reason `pinout_supplement_required` listing the supplement filename or document title the datasheet references. The upstream caller (chip-fetcher) will walk to a sibling PDF named `*-package-diagrams.pdf` / `*-pinout*.pdf` / `*-hardware-checklist.pdf` and re-call.

DISCRETE COMPONENT CLASSIFICATION (CRITICAL — gets the schematic refdes right):

If the part is a discrete component (NOT an integrated circuit), set:
  - category: "discrete"  (NOT "ic")
  - referencePrefix to the EE-canonical letter:

    Part type                                   | category   | referencePrefix
    --------------------------------------------|------------|----------------
    MOSFET / BJT / JFET / IGBT / transistor     | discrete   | Q
    Diode / LED / Schottky / TVS / Zener / SBD  | discrete   | D
    Resistor (chip resistor, current sense)     | discrete   | R
    Capacitor (cap, varactor, trimcap)          | discrete   | C
    Inductor / choke / ferrite bead             | discrete   | L
    Crystal / resonator / TCXO / oscillator     | discrete   | Y
    Fuse                                        | discrete   | F
    Battery                                     | discrete   | BT
    Connector / jack / receptacle / header      | discrete   | J or P
    Switch / button / tactile                   | discrete   | SW
    Relay                                       | discrete   | K
    Transformer                                 | discrete   | T

  - For 3-lead transistors, pins are conventionally G/D/S (MOSFET/JFET) or B/C/E (BJT)
  - For 2-lead diodes/LEDs, pins are conventionally Anode/A and Cathode/K (or numbered 1/2)
  - For 2-lead passives (resistor/cap/inductor), pins are conventionally numbered 1/2

If the part has a control input + multiple outputs / inputs / interfaces, it's an IC: keep
  category: "ic" and referencePrefix: "U". Op-amps, comparators, voltage references,
  ADCs, DACs, microcontrollers, FPGAs, sensors with I2C/SPI — all ICs. Don't be fooled
  by low pin counts: an 8-pin op-amp is an IC, not a discrete.

For discrete parts you may also propose a discreteBaseline so downstream sym_create can
reuse a KiCad standard symbol. Examples:
  - P-channel MOSFET → discreteBaseline: {{ library: "Transistor_FET", symbol: "Q_PMOS_GSD" }}
  - N-channel MOSFET → {{ library: "Transistor_FET", symbol: "Q_NMOS_GDS" }}
  - LED               → {{ library: "Device", symbol: "LED" }}
  - Diode             → {{ library: "Device", symbol: "D" }}
  - TVS / Zener       → {{ library: "Device", symbol: "D_TVS" }} or "D_Zener"

Return ONLY the JSON object. No prose before or after.
"#,
    )
}

fn build_footprint_prompt(ctx: &Context, sym: &SymbolPass) -> String {
    let manuf = ctx.manufacturer.as_deref().unwrap_or("(unknown)");
    let pkg_hint = ctx.package_hint.as_deref().unwrap_or("(no hint)");
    let ds_path = ctx.datasheet_path.display();
    let mpn = &ctx.mpn;

    let pin_map = serde_json::to_string_pretty(
        &sym.pins
            .iter()
            .map(|p| json!({"number": p.number, "name": p.name}))
            .collect::<Vec<_>>(),
    )
    .unwrap_or_default();

    format!(
        r#"You are extracting package outline + recommended land-pattern data for "{mpn}".

CONTEXT:
- MPN: {mpn}
- Manufacturer: {manuf}
- Package hint (from upstream chip-fetcher): {pkg_hint}

DATASHEET PDF (use the Read tool):
  {ds_path}

SYMBOL PIN MAP (canonical from the prior symbol-pass — DO NOT re-derive these):
{pin_map}

THIS CALL IS ONLY ABOUT THE PACKAGE OUTLINE AND RECOMMENDED LAND PATTERN.
Ignore pin functional descriptions; you already have the pin name → number map.
Mechanical drawings + recommended-solder-pattern figures are usually near the back
of the datasheet (e.g. §12, §13, §"Mechanical data", §"Package information").

SCHEMA:
{{
  "package_name": "string (e.g. 'VSSOP-10', 'VQFN-32', 'OLGA-16'). Drop dimensions/marking-codes from this — keep the canonical JEDEC/IPC name when one exists.",
  "is_standard_jedec": true,
  "package_name_source_page": 0,
  "padCount": 0,
  "bodyDimensions": {{ "x": 0.0, "y": 0.0, "thickness_max": 0.0,
                       "source_page": 0, "figure_label": "string" }},
  "leadDimensions": {{ "pitch_x": 0.0, "pitch_y": 0.0,
                       "pad_w": 0.0, "pad_h": 0.0,
                       "thermal_pad": null,
                       "source_page": 0, "figure_label": "string" }},
  "mechanical": {{
    "jedec_dims": {{
      "A":  {{ "value": 0.0, "min": null, "max": null, "label": "Total package height", "source_page": 0, "figure_label": "string" }},
      "A1": {{ "value": 0.0, "label": "Standoff height (body bottom to PCB)", "source_page": 0, "figure_label": "string" }},
      "A2": {{ "value": 0.0, "label": "Body height (without leads)", "source_page": 0, "figure_label": "string" }},
      "D":  {{ "value": 0.0, "label": "Body length", "source_page": 0, "figure_label": "string" }},
      "E":  {{ "value": 0.0, "label": "Body width (across leads/pads)", "source_page": 0, "figure_label": "string" }},
      "E1": {{ "value": 0.0, "label": "Body width (molded body only, excluding leads)", "source_page": 0, "figure_label": "string" }},
      "b":  {{ "value": 0.0, "label": "Lead width", "source_page": 0, "figure_label": "string" }},
      "e":  {{ "value": 0.0, "label": "Lead pitch", "source_page": 0, "figure_label": "string" }},
      "L":  {{ "value": 0.0, "label": "Lead foot length (toe to heel)", "source_page": 0, "figure_label": "string" }},
      "L1": {{ "value": 0.0, "label": "Lead length from body edge", "source_page": 0, "figure_label": "string" }}
    }},
    "lead_shape": "gull_wing | j_lead | flat | ball | straight_pin | castellated | no_lead",
    "pin1_indicator": "chamfer | dot | bar | notch | none",
    "pin1_indicator_page": 0,
    "exposed_pad_paste_coverage_pct": null,
    "keepout_distance_mm": null,
    "courtyard_source_page": null,
    "courtyard_figure_label": null
  }},
  "padDescriptions": {{ "1": "VDD — 1-line pad description, may include footprint-specific notes (thermal pad, drill, plating)" }},
  "symbolPinMap": {{ "1": "VDD" }},
  "review_required": [
    {{ "reason": "non_standard_package_no_kicad_baseline", "package": "..." }}
  ]
}}

IMPORTANT — THE `mechanical` SECTION:
- Read the mechanical drawing page(s) carefully. Look for the JEDEC dimension
  callout letters (A, A1, A2, D, E, E1, b, e, L, L1). Most datasheets label
  them in a table adjacent to the mechanical drawing.
- Not every dimension applies to every package. Include only what's present.
  QFN has A/A1/D/E/b/e but no L (flat pads). SOIC has A/A1/D/E/E1/b/e/L.
  BGA has A/A1/D/E/e + ball diameter (report as b). DIP has A/A1/D/E/b/e/L.
- Use nominal values in mm. When only min/max are given, set value to the midpoint.
- lead_shape: look at the mechanical drawing's side view. Gull-wing leads
  bend outward from the body and down. J-leads curl under. Flat pads sit
  under the body (QFN/LGA). Balls are solder spheres (BGA).
- pin1_indicator: look at the TOP view for a chamfer, dot, bar, or notch
  marking pin 1. Report the type and the page.
- exposed_pad_paste_coverage_pct: some datasheets specify solder paste
  coverage % for the center EP (e.g. "apply 50% paste coverage"). Include
  if stated.

RULES:
- Every dimension entry MUST include source_page and figure_label (e.g. "Figure 12-1. Mechanical Drawing", "Figure 13. Recommended Land Pattern").
- If the package is a standard JEDEC/IPC package (SOIC, SOT, QFN, LQFP, BGA, etc.) and you can identify it confidently, set is_standard_jedec: true.
- If the package is non-standard (e.g. a custom optical LGA, a module with castellated edges), set is_standard_jedec: false and fill bodyDimensions + leadDimensions from the mechanical drawing precisely.
- padDescriptions and symbolPinMap MUST reuse the pin numbers + names from the SYMBOL PIN MAP above. padCount MUST equal the length of that map.
- Dimensions in millimeters. If the datasheet lists nominal/min/max, use the nominal.
- When data isn't in the datasheet, omit the field and surface in review_required — DO NOT hallucinate dimensions.

Return ONLY the JSON object. No prose before or after.
"#,
    )
}

fn call_claude(prompt: &str, model: &str, tag: &str, add_dir: &std::path::Path) -> Result<String> {
    // Shell `claude -p --output-format json --model <model> --add-dir <chip-dir>`.
    // Feed the prompt on stdin. The --add-dir flag whitelists the chip directory so
    // Claude's Read tool can read the PDF without permission prompts.
    note(format!("  → claude -p ({tag}, {} chars prompt, add-dir={})", prompt.len(), add_dir.display()));

    let mut child = Command::new("claude")
        .arg("-p")
        .arg("--output-format").arg("json")
        .arg("--model").arg(model)
        .arg("--add-dir").arg(add_dir)
        .arg("--max-budget-usd").arg("5.00")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .context("spawning `claude -p` — is the claude CLI on PATH?")?;

    {
        let stdin = child.stdin.as_mut().ok_or_else(|| anyhow!("claude stdin"))?;
        stdin.write_all(prompt.as_bytes())?;
    }

    let output = child.wait_with_output().context("waiting for claude")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(anyhow!("claude CLI exited {}: {}", output.status, stderr));
    }
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();

    // The --output-format json envelope wraps the model's text response in a JSON object.
    if let Ok(envelope) = serde_json::from_str::<Value>(&stdout) {
        let is_error = envelope.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false);
        let result_text = envelope.get("result").and_then(|v| v.as_str()).unwrap_or("");
        if is_error {
            return Err(anyhow!(
                "claude CLI returned an error envelope: {}",
                result_text
            ));
        }
        if !result_text.is_empty() {
            note(format!(
                "  ← claude finished ({} duration_ms, ${} cost)",
                envelope.get("duration_ms").map(|v| v.to_string()).unwrap_or_default(),
                envelope.get("total_cost_usd").map(|v| v.to_string()).unwrap_or_default(),
            ));
            return Ok(result_text.to_string());
        }
        if let Some(s) = envelope.get("text").and_then(|v| v.as_str()) {
            return Ok(s.to_string());
        }
    }
    Ok(stdout)
}

fn extract_json(raw: &str) -> Result<Value> {
    // Strip code fences if present, then locate the outermost {...} block.
    let s = raw.trim();
    let s = s.strip_prefix("```json").unwrap_or(s);
    let s = s.strip_prefix("```").unwrap_or(s);
    let s = s.strip_suffix("```").unwrap_or(s).trim();

    let start = match s.find('{') {
        Some(i) => i,
        None => {
            // Haiku sometimes returns a prose-only refusal ("Unable to proceed
            // — this PDF is the Adafruit user guide, not the NXP datasheet")
            // without a JSON wrapper. Detect refusal language and synthesize
            // an envelope so the upstream pipeline still gets a structured
            // needs_better_pdf result with try_alternate_pdf hints.
            let lower = raw.to_lowercase();
            let refusal_markers = [
                "unable to proceed",
                "user guide",
                "shield",
                "not the actual",
                "not the datasheet",
                "wrong document",
                "different chip",
                "different variant",
                "module datasheet is required",
                "this guide",
                "manufacturer datasheet is required",
                // Per-package-pinout-supplement refusal (Lattice iCE40 case
                // — main datasheet describes signal classes only and defers
                // SG48/SG78/UG121-pin assignments to a separate Pinout
                // Files supplement).
                "external pinout file",
                "supplement document",
                "pinout files",
                "package-specific pinout",
                "package-diagram",
                "pinout supplement",
                "deferred to the external",
                "pinout file is referenced",
            ];
            if refusal_markers.iter().any(|m| lower.contains(m)) {
                return Ok(serde_json::json!({
                    "pins": [],
                    "review_required": [{
                        "reason": "module_external_pinout_missing",
                        "details": raw.lines().take(10).collect::<Vec<_>>().join(" "),
                    }]
                }));
            }
            return Err(anyhow!("no JSON object in claude response"));
        }
    };
    let mut depth = 0i32;
    let mut end = None;
    let bytes = s.as_bytes();
    let mut in_str = false;
    let mut esc = false;
    for (i, &b) in bytes.iter().enumerate().skip(start) {
        if esc {
            esc = false;
            continue;
        }
        match b {
            b'\\' if in_str => esc = true,
            b'"' => in_str = !in_str,
            b'{' if !in_str => depth += 1,
            b'}' if !in_str => {
                depth -= 1;
                if depth == 0 {
                    end = Some(i + 1);
                    break;
                }
            }
            _ => {}
        }
    }
    let end = end.ok_or_else(|| anyhow!("unbalanced JSON braces"))?;
    let obj = &s[start..end];
    serde_json::from_str::<Value>(obj).with_context(|| {
        let snippet: String = obj.chars().take(400).collect();
        format!("parsing JSON: {snippet}…")
    })
}

fn validate_symbol(v: &Value) -> Result<()> {
    let pins = v.get("pins").and_then(|x| x.as_array()).ok_or_else(|| anyhow!("missing pins[]"))?;
    if pins.is_empty() {
        // Empty pins[] + review_required explanation = Claude refused to hallucinate
        // (e.g. wrong PDF for a module product). Treat this as a non-retry-able state;
        // the caller surfaces it via review_required.
        let has_explanation = v
            .get("review_required")
            .and_then(|r| r.as_array())
            .map(|a| !a.is_empty())
            .unwrap_or(false);
        if has_explanation {
            return Err(anyhow!(
                "pins[] is empty but review_required is populated — extractor refused to hallucinate; consult review_required and try --pdf <path> to a more-specific datasheet"
            ));
        }
        return Err(anyhow!("pins[] is empty"));
    }
    let mut seen = std::collections::HashSet::new();
    for (i, p) in pins.iter().enumerate() {
        let num = p.get("number").and_then(|x| x.as_str())
            .ok_or_else(|| anyhow!("pins[{i}].number missing"))?;
        if !seen.insert(num.to_string()) {
            return Err(anyhow!("duplicate pin number '{num}'"));
        }
        let name = p.get("name").and_then(|x| x.as_str())
            .ok_or_else(|| anyhow!("pins[{i}].name missing"))?;
        let kind = p.get("type").and_then(|x| x.as_str())
            .ok_or_else(|| anyhow!("pins[{i}].type missing for pin {num}"))?;
        if !KICAD_ERC_TYPES.contains(&kind) {
            return Err(anyhow!(
                "pins[{i}].type '{kind}' invalid for pin {num} ({name}); must be one of {KICAD_ERC_TYPES:?}"
            ));
        }
        let group = p.get("group").and_then(|x| x.as_str()).unwrap_or("");
        if !group.is_empty() && !ADOM_GROUPS.contains(&group) {
            return Err(anyhow!(
                "pins[{i}].group '{group}' invalid for pin {num} ({name}); must be one of {ADOM_GROUPS:?}"
            ));
        }
        let page = p.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0);
        if page == 0 {
            return Err(anyhow!(
                "pins[{i}] '{name}' (pin {num}) missing source_page"
            ));
        }
    }
    Ok(())
}

fn validate_footprint(v: &Value, sym: &SymbolPass) -> Result<()> {
    let pad_count = v.get("padCount").and_then(|x| x.as_u64())
        .ok_or_else(|| anyhow!("padCount missing"))?;
    if (pad_count as usize) != sym.pins.len() {
        // Soft-warn: modules can legitimately have a different padCount from the
        // symbol's pin count (e.g. ESP32-WROOM module exposes 41 castellated pads
        // while the internal SoC has more pins). Surface in review_required, do
        // not error — the extraction is still useful.
        warn(format!(
            "padCount {} differs from symbol pin count {} — proceeding (likely a module symbol/module footprint mismatch; surfaced in review_required)",
            pad_count, sym.pins.len()
        ));
    }
    let body = v.get("bodyDimensions").ok_or_else(|| anyhow!("bodyDimensions missing"))?;
    if body.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {
        return Err(anyhow!("bodyDimensions.source_page missing"));
    }
    let lead = v.get("leadDimensions").ok_or_else(|| anyhow!("leadDimensions missing"))?;
    if lead.get("source_page").and_then(|x| x.as_u64()).unwrap_or(0) == 0 {
        return Err(anyhow!("leadDimensions.source_page missing"));
    }

    // padDescriptions keys: warn (don't error) if there's a key the symbol map doesn't
    // know about — same module-vs-IC reason.
    let valid_nums: std::collections::HashSet<&str> =
        sym.pins.iter().map(|p| p.number.as_str()).collect();
    if let Some(desc) = v.get("padDescriptions").and_then(|x| x.as_object()) {
        for k in desc.keys() {
            if !valid_nums.contains(k.as_str()) {
                warn(format!(
                    "padDescriptions key '{k}' is not in the symbol pin map (module/SoC mismatch?)"
                ));
                break; // one warning is enough
            }
        }
    }
    Ok(())
}