12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;

use crate::{hints, import, inventory, library, pup, serve, sources, sourcing, thumbnails, validate};

const SKILL_MD: &str = include_str!("../SKILL.md");

// Playbooks — deep reference, loaded on demand by Claude when SKILL.md tells it which one to Read.
// Embedded at compile time so `adom-chip-fetcher install` deploys them alongside SKILL.md without
// needing the source repo on the user's container.
const PLAYBOOKS: &[(&str, &str)] = &[
    ("sourcing-ladder.md",       include_str!("../playbooks/sourcing-ladder.md")),
    ("manufacturer-direct.md",   include_str!("../playbooks/manufacturer-direct.md")),
    ("ultra-librarian.md",       include_str!("../playbooks/ultra-librarian.md")),
    ("snapmagic.md",             include_str!("../playbooks/snapmagic.md")),
    ("mouser-cse.md",            include_str!("../playbooks/mouser-cse.md")),
    ("digikey-models.md",        include_str!("../playbooks/digikey-models.md")),
    ("credentials-and-logins.md", include_str!("../playbooks/credentials-and-logins.md")),
    ("adom-desktop-api.md",      include_str!("../playbooks/adom-desktop-api.md")),
    ("handoff-and-watching.md",  include_str!("../playbooks/handoff-and-watching.md")),
    ("jst.md",                   include_str!("../playbooks/jst.md")),
    ("jst-email-retrieval.md",   include_str!("../playbooks/jst-email-retrieval.md")),
];

const SESSION: &str = "adom-chip-fetcher";
const PROFILE: &str = "adom-chip-fetcher";

#[derive(Parser)]
#[command(name = "adom-chip-fetcher", version, about = "Download manufacturer STEP/footprint/datasheet bundles via pup")]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// PLAN a batch: show the user the full queue up front + seed planned-state
    /// dashboard cards, BEFORE any scraping. This is the required first step —
    /// browser-driving verbs (fetch/mfr-probe/scrape) REFUSE to run for an MPN
    /// that isn't in the active plan. That's the guardrail that stops freelancing.
    Plan {
        /// The MPNs to source, in order.
        mpns: Vec<String>,
        /// Name the plan (defaults to "batch"). Becomes the active project.
        #[arg(long, default_value = "batch")]
        name: String,
    },
    /// Scan the native browser's SAVED LOGINS across every profile (Chrome +
    /// Edge), map them to the sourcing ladder, and report which CAD/distributor
    /// vendors you're signed in to + the best profile. adom-chip-fetcher uses this to
    /// self-drive: it auto-switches to the vendor-authed profile while sourcing.
    Logins,
    /// Fetch a part's full CAD bundle (symbol+footprint+STEP) from Component
    /// Search Engine via XHR + basic-auth — no browser download, no prompts, no
    /// questions. ST/TI/NXP/etc. route their EDA models here. First-class +
    /// permanent: works for any user with a CSE login in the credential vault.
    Cse {
        mpn: String,
        /// Manufacturer name for CSE's `mna` param (default: inferred from MPN).
        #[arg(long)]
        mfr: Option<String>,
    },
    /// Set the human "what is this chip" description shown on the card + zoom
    /// view (writes info.json.description). Every sourced chip should have one.
    Describe {
        mpn: String,
        /// The description text (one or two sentences: what the part IS).
        text: String,
    },
    /// Enrich a chip from the Adom parts tools (adom-parts-search → Mouser +
    /// DigiKey + JLCPCB): writes the description, manufacturer, stock.json
    /// (stock + pricing + lifecycle) and lists orderable package variants.
    /// Keys are baked into those tools' own services — NO API key needed.
    Enrich { mpn: String },
    /// Run the MOST authoritative source: extract symbol + footprint from the
    /// manufacturer DATASHEET via adom-ds2sf. GUARANTEES the datasheet is real
    /// (multi-page + pinout) first — refuses a 1-page denial page — then runs
    /// the extraction and renders the ds2sf variant thumbnails.
    Ds2sf { mpn: String },
    /// Fetch one MPN from the best available source
    Fetch {
        mpn: String,
        /// Associate this chip with a project (e.g. "Tamara-LED-Nameplate")
        #[arg(long)]
        project: Option<String>,
    },
    /// Query the Adom Wiki ONLY (ladder step 0) and reuse a published bundle if
    /// one exists. Same code path `fetch` runs first — exposed standalone so the
    /// wiki reuse can be tested without driving the whole vendor ladder.
    WikiFetch { mpn: String },
    /// Open a source's login page so the user can authenticate once
    Login { source: String },
    /// Show what's already in the library
    List,
    /// Show what's downloaded vs missing for a single MPN
    Status { mpn: String },
    /// Import a downloaded file or zip into the library for an MPN
    Import {
        /// Path to a .zip, .step, .pdf, .kicad_mod, or .kicad_sym
        path: PathBuf,
        /// MPN to file under (default: infer from filename)
        #[arg(long)]
        mpn: Option<String>,
        /// Skip the post-import ds2sf+concur validation step.
        #[arg(long)]
        no_validate: bool,
        /// Where did you FIND the download link? e.g. "digikey" if you
        /// found the link on DigiKey's models page, even though the file
        /// was authored by Ultra Librarian. Chipsmith shows this in
        /// tooltips: "found on DigiKey → made by UL".
        #[arg(long, default_value = "unknown")]
        discovery_source: String,
        /// Who actually MADE the file? e.g. "manufacturer" if the STEP
        /// is from Abracon's own CDN, "ultralibrarian" if UL generated
        /// the KiCad bundle. This is the content attribution.
        #[arg(long, default_value = "unknown")]
        content_origin: String,
        /// URL of the page/download this file came from.
        #[arg(long, default_value = "")]
        source_url: String,
    },
    /// Pull a file from the user's desktop and import it into the library.
    /// e.g. `adom-chip-fetcher pull "C:\\Users\\john\\Downloads\\ul_TPS62840YBGR.zip" --mpn TPS62840YBGR`
    Pull {
        /// Windows/macOS path on the user's desktop
        remote_path: String,
        /// MPN to file under (default: infer from filename)
        #[arg(long)]
        mpn: Option<String>,
        /// Skip the post-pull ds2sf+concur validation step.
        #[arg(long)]
        no_validate: bool,
        /// Where did you FIND the download link?
        #[arg(long, default_value = "unknown")]
        discovery_source: String,
        /// Who actually MADE the file?
        #[arg(long, default_value = "unknown")]
        content_origin: String,
        /// URL of the page/download this file came from.
        #[arg(long, default_value = "")]
        source_url: String,
    },
    /// Deploy SKILL.md to ~/.claude/skills/adom-chip-fetcher/ so Claude knows how to use this tool.
    /// Run after `curl ... | install` to finish setup.
    Install,
    /// Run the viewer HTTP server. Open in any browser at http://localhost:<port>.
    Serve {
        #[arg(long, default_value_t = 8786)]
        port: u16,
    },
    /// Start the viewer + open it in a Hydrogen webview tab.
    App {
        #[arg(long, default_value_t = 8786)]
        port: u16,
    },
    /// Heartbeat the dashboard. Run before every state change so users see
    /// live progress. Stages are short labels: "navigating UL", "captcha
    /// solved", "downloading STEP", "imported", "stuck — needs login", etc.
    Heartbeat {
        mpn: String,
        stage: String,
    },
    /// Verify the rails before a fetch — dashboard reachable, heartbeat
    /// fresh, pup session 'adom-chip-fetcher' open. Fails with HINT lines so the
    /// next step is mechanical, not remembered.
    Selfcheck,
    /// Print a HINT line about what to do next, based on current state.
    /// Useful when an AI agent has lost the thread mid-batch.
    Hint,
    /// Print the canonical sourcing-ladder priority order (Mfr → SnapMagic →
    /// Mouser → DigiKey → Arrow → CSE/UL → LCSC). LCSC is LAST RESORT —
    /// avoid if possible. AI: read this with `adom-chip-fetcher sources` BEFORE
    /// falling back from one source to the next; do NOT skip ahead to LCSC.
    Sources,
    /// Hard gate: scan every chip in library/ and refuse to declare success
    /// while ANY card is incomplete (stub-only or missing core CAD). Returns
    /// non-zero exit when work remains. AI: ALWAYS run `adom-chip-fetcher gate`
    /// before reporting "done" on a batch — it prints exactly which files
    /// each card is missing, so claiming done is mechanical, not remembered.
    Gate {
        /// Strict mode: also require Altium IntLib + Fusion .lbr. Default
        /// mode requires datasheet PDF + STEP + KiCad sym + KiCad mod.
        #[arg(long)]
        strict: bool,
        /// Gate only this MPN (default: every chip in library/).
        #[arg(long)]
        mpn: Option<String>,
    },
    /// Backfill provenance for files that landed without going through import
    /// (so their source isn't a bare "—"). Derives each present-but-
    /// un-provenanced file's origin from info.json (step_source / the
    /// sourcing-chain entry that succeeded) and records it; flags what it can't.
    BackfillProvenance {
        /// Backfill only this MPN (default: every chip in library/).
        #[arg(long)]
        mpn: Option<String>,
    },
    /// Detect manufacturer from MPN and open their canonical product /
    /// search page in pup. AI: run this BEFORE any third-party source.
    /// The mfr-first rule is enforced in code — `adom-chip-fetcher mfr-probe
    /// <MPN>` either opens the right URL or tells you we can't infer the
    /// manufacturer (in which case the AI must do its own probe). Auto-
    /// records the manufacturer-attempt on info.json.fetched_via_chain.
    MfrProbe {
        mpn: String,
        /// Print the URL only, don't open in pup. Useful for scripts.
        #[arg(long)]
        no_open: bool,
    },
    /// Print the next ladder step that hasn't been tried for this MPN,
    /// reading from info.json.fetched_via_chain. The state machine: if
    /// manufacturer hasn't been tried, the next source is "manufacturer".
    /// If LCSC is suggested, the binary verifies the full ladder above it
    /// has been recorded as failed first. AI: run this instead of
    /// remembering the order.
    NextSource {
        mpn: String,
    },
    /// Run ds2sf + concur on a chip directory and walk hints[] to drive
    /// autonomous self-repair. Loops on ds2sf needs_better_pdf candidates,
    /// re-runs ds2sf with stronger model on concur recommendation,
    /// applies update_upstream_metadata patches, marks symbol source as
    /// ds2sf-preferred when UL has stub pin names, and persists final
    /// status to <MPN>-validation-status.json. Surfaces to human cleanly
    /// when out of automated options.
    Validate {
        /// Chip MPN — directory must already exist at library/<MPN>/.
        mpn: String,
    },
    /// Drive the adom-chip-fetcher pup scrape session. Every action auto-
    /// heartbeats the dashboard, always uses `profile: "adom-chip-fetcher"`
    /// (NEVER a different profile — that loses vendor login cookies),
    /// and logs every step to the fetched_via_chain. The AI calls these
    /// instead of raw `adom-desktop browser_*` calls so the dashboard
    /// shows live progress and credentials are never lost.
    Scrape {
        #[command(subcommand)]
        op: ScrapeOp,
    },
    /// Manage stored vendor credentials. Saved to
    /// ~/.config/adom-chip-fetcher/credentials.json on the user's container.
    /// Survives across sessions — the user enters creds ONCE, adom-chip-fetcher
    /// can re-login automatically when cookies expire.
    Creds {
        #[command(subcommand)]
        op: CredsOp,
    },
    /// Print the full completeness ladder for one chip or the whole
    /// library. Shows every level (0-9) with ✓/✗ and what's missing at
    /// each level. The AI reads this output to decide what to do next
    /// instead of guessing.
    Done {
        /// Check only this MPN (default: every chip in library/).
        #[arg(long)]
        mpn: Option<String>,
    },
    /// Sweep every chip's <MPN>.pdf and report which ones are HTML
    /// masquerading as a PDF (Cloudflare denial pages, "click to accept"
    /// interstitials, 404 HTML, etc.). Reads first 5 bytes; rejects
    /// anything not starting with `%PDF-`. With --repair, deletes the
    /// bogus file + records the failure in info.json.fetched_via_chain
    /// so the next refetch knows the previous source returned garbage.
    /// Always run this after a batch fetch to catch the silent footgun
    /// that lands HTML to .pdf when a vendor wall-blocks curl.
    PdfCheck {
        /// Check only this MPN (default: every chip in library/).
        #[arg(long)]
        mpn: Option<String>,
        /// Delete bogus PDFs + record `html_masquerade` outcome in
        /// info.json.fetched_via_chain so next-source advances correctly.
        #[arg(long)]
        repair: bool,
    },
    /// Drive pup to a vendor page that wall-blocks curl, find the real
    /// PDF anchor on the rendered page, fetch it via `fetch().arrayBuffer()`
    /// inside the browser context (cookies + Cloudflare-passed Chrome
    /// fingerprint preserved), base64-transfer the bytes back, magic-byte
    /// validate, and save to `library/<MPN>/<MPN>.pdf`. The escape hatch
    /// for vendors that detect curl and serve HTML to it (Hanrun's Aliyun
    /// CDN, ST CDN, JST product pages, etc.) — proven pattern from the
    /// 2026-05-07 HR911105A recovery.
    PupPdfFetch {
        /// MPN to file the PDF under.
        mpn: String,
        /// Page URL to navigate to first. The PDF anchor is searched on
        /// this rendered page. If both --page-url and --pdf-url are given,
        /// --page-url is the referrer (cookies set), --pdf-url is fetched
        /// directly.
        #[arg(long)]
        page_url: Option<String>,
        /// Direct PDF URL to fetch via `fetch()`. Skips the page-anchor
        /// search step. Recommended when the URL is already known (from
        /// info.json.datasheet_url, a prior chain entry, etc.).
        #[arg(long)]
        pdf_url: Option<String>,
        /// Source slug for the chain ledger (manufacturer / snapmagic /
        /// mouser / digikey / arrow / cse_ul / lcsc). Default:
        /// `manufacturer`.
        #[arg(long, default_value = "manufacturer")]
        source: String,
        /// Pup session id (default: `cf-pdf-grab` — kept separate from
        /// the dashboard `adom-chip-fetcher` and the scrape `adom-chip-fetcher-scrape`
        /// sessions so this can run alongside an existing fetch).
        #[arg(long, default_value = "cf-pdf-grab")]
        session: String,
    },
    /// Re-fetch a specific library file from a specific source. Drives the
    /// vendor's product/CAD page in pup, watches ~/Downloads for the
    /// resulting bundle, and imports it. Used by `adom-chip-fetcher validate`
    /// when concur returns `refetch_library_source` — the loop calls this
    /// as a subprocess so a divergent kicad_sym can be replaced with one
    /// from a different upstream without a human in the loop.
    ///
    /// Sources accepted (canonical slugs): manufacturer, snapmagic, mouser,
    /// digikey, arrow, cse_ul, lcsc.
    /// Formats accepted: kicad_sym, kicad_mod, eagle, step, all.
    Refetch {
        mpn: String,
        /// What kind of file we want refreshed.
        #[arg(long)]
        format: String,
        /// Which ladder step to refetch from.
        #[arg(long)]
        source: String,
        /// Don't open pup; just print the canonical URL we'd drive to.
        /// Used by validate's autonomous loop to record provenance without
        /// stealing the user's screen.
        #[arg(long)]
        url_only: bool,
        /// How long to wait for a download to land in ~/Downloads (seconds).
        #[arg(long, default_value_t = 180)]
        timeout: u64,
    },
    /// Manage the dashboard surface — auto-(re)open it (default), force a
    /// fresh window, or set the surface preference.
    Dashboard {
        #[command(subcommand)]
        op: DashboardOp,
    },
    /// Generate thumbnail images (symbol SVG, footprint SVG, 3D chip PNG)
    /// for one MPN or the whole library. Idempotent: skips artifacts whose
    /// source hasn't changed since the last generation.
    Thumbnails {
        /// Generate for this single MPN (default: every chip in library/)
        #[arg(long)]
        mpn: Option<String>,
        /// Re-render even when the existing thumbnail is newer than its source
        #[arg(long)]
        force: bool,
    },
    /// Manage projects — scope adom-chip-fetcher work to a named project so the
    /// dashboard shows only the chips you care about right now.
    Project {
        #[command(subcommand)]
        op: ProjectOp,
    },
}

#[derive(Subcommand)]
enum ProjectOp {
    /// Create a new project
    Create {
        name: String,
        /// Short description of the project
        #[arg(long, default_value = "")]
        description: String,
    },
    /// List all projects
    List,
    /// Show chips in a project
    Show { name: String },
    /// Add an MPN to a project
    Add { name: String, mpn: String },
    /// Remove an MPN from a project
    Remove { name: String, mpn: String },
    /// Set the active project (filters the dashboard)
    Activate { name: String },
    /// Clear the active project (show full library)
    Deactivate,
    /// Delete a project (does not delete library chips)
    Delete { name: String },
}

#[derive(Subcommand)]
enum DashboardOp {
    /// Make sure the dashboard is up and visible. Idempotent. Pup by default.
    Ensure {
        /// Re-open even if a adom-chip-fetcher pup window is already detected.
        #[arg(long)]
        force: bool,
    },
    /// Set the surface preference (pup | webview). Saved at
    /// ~/.config/adom-chip-fetcher/dashboard.json. Pup is the default since
    /// adom-chip-fetcher needs pup anyway to drive vendor sites.
    SetSurface {
        /// Either "pup" or "webview"
        surface: String,
    },
    /// Print the current surface preference.
    Surface,
}

/// Every pup action the AI drives during a fetch. ALWAYS uses
/// `profile: "adom-chip-fetcher"` — hardcoded, non-overridable. This
/// preserves vendor login cookies across sessions and ensures the
/// dashboard heartbeat fires on every action.
#[derive(Subcommand)]
enum ScrapeOp {
    /// Navigate the scrape pup window to a URL. Auto-heartbeats.
    Navigate {
        mpn: String,
        url: String,
        /// Stage label for the heartbeat (default: "navigating <url>")
        #[arg(long)]
        stage: Option<String>,
    },
    /// Eval JS in the scrape pup window. Auto-heartbeats. Prints the
    /// result to stdout so the AI reads it back.
    Eval {
        mpn: String,
        expr: String,
    },
    /// Screenshot the scrape pup window. Saves to /tmp/ and prints path.
    Screenshot {
        mpn: String,
    },
    /// Open the scrape window (or re-open if dead). Always profile=adom-chip-fetcher.
    Open {
        mpn: String,
        url: String,
    },
}

#[derive(Subcommand)]
enum CredsOp {
    /// List stored credentials (shows host + username, masks password).
    List,
    /// Save a credential. Persists to ~/.config/adom-chip-fetcher/credentials.json.
    Save {
        /// Vendor host slug (snapeda, ultralib, componentsearch, ti, st, etc.)
        host: String,
        username: String,
        password: String,
    },
    /// Delete a stored credential by host.
    Delete { host: String },
}

pub fn run() -> Result<()> {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Logins => cmd_logins(),
        Cmd::Cse { mpn, mfr } => cmd_cse(&mpn, mfr.as_deref()),
        Cmd::Describe { mpn, text } => cmd_describe(&mpn, &text),
        Cmd::Enrich { mpn } => cmd_enrich(&mpn),
        Cmd::Ds2sf { mpn } => cmd_ds2sf(&mpn),
        Cmd::Plan { mpns, name } => cmd_plan(&mpns, &name),
        Cmd::Fetch { mpn, project } => {
            if let Some(ref proj) = project {
                let _ = crate::projects::add_mpn(proj, &mpn);
                let _ = crate::projects::set_active(Some(proj));
                println!("project: {proj} (added {mpn})");
            }
            require_planned(&mpn)?;
            fetch(&mpn)
        }
        Cmd::WikiFetch { mpn } => wiki_fetch_cmd(&mpn),
        Cmd::Login { source } => login(&source),
        Cmd::List => list(),
        Cmd::Status { mpn } => status(&mpn),
        Cmd::Import { path, mpn, no_validate, discovery_source, content_origin, source_url } =>
            import_cmd(&path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),
        Cmd::Pull { remote_path, mpn, no_validate, discovery_source, content_origin, source_url } =>
            pull_cmd(&remote_path, mpn.as_deref(), no_validate, &discovery_source, &content_origin, &source_url),
        Cmd::Install => install_cmd(),
        Cmd::Serve { port } => serve::run(port),
        Cmd::App { port } => app_cmd(port),
        Cmd::Heartbeat { mpn, stage } => hints::heartbeat(&mpn, &stage),
        Cmd::Selfcheck => hints::selfcheck(),
        Cmd::Hint => hint_cmd(),
        Cmd::Sources => sources_cmd(),
        Cmd::Gate { strict, mpn } => gate_cmd(strict, mpn.as_deref()),
        Cmd::BackfillProvenance { mpn } => backfill_provenance_cmd(mpn.as_deref()),
        Cmd::MfrProbe { mpn, no_open } => { require_planned(&mpn)?; mfr_probe_cmd(&mpn, no_open) }
        Cmd::NextSource { mpn } => next_source_cmd(&mpn),
        Cmd::Validate { mpn } => validate_cmd(&mpn),
        Cmd::Refetch { mpn, format, source, url_only, timeout } =>
            refetch_cmd(&mpn, &format, &source, url_only, timeout),
        Cmd::Done { mpn } => done_cmd(mpn.as_deref()),
        Cmd::Scrape { op } => scrape_cmd(op),
        Cmd::Creds { op } => creds_cmd(op),
        Cmd::PdfCheck { mpn, repair } => pdf_check_cmd(mpn.as_deref(), repair),
        Cmd::PupPdfFetch { mpn, page_url, pdf_url, source, session } =>
            pup_pdf_fetch_cmd(&mpn, page_url.as_deref(), pdf_url.as_deref(), &source, &session),
        Cmd::Dashboard { op } => dashboard_cmd(op),
        Cmd::Thumbnails { mpn, force } => thumbnails_cmd(mpn.as_deref(), force),
        Cmd::Project { op } => project_cmd(op),
    }
}

/// First-class CSE source: fetch the full CAD bundle via XHR + basic-auth (no
/// download, no prompt, no questions) and import it. The permanent version of
/// what unblocked the VL53Lx family.
fn cmd_cse(mpn: &str, mfr: Option<&str>) -> Result<()> {
    require_planned(mpn)?;
    let cred = crate::creds::load()?.into_iter()
        .find(|c| c.host.contains("componentsearchengine"))
        .ok_or_else(|| anyhow::anyhow!(
            "no Component Search Engine credential in the vault.\n  \
             HINT: sign in to componentsearchengine.com once in your browser, or \
             `adom-chip-fetcher creds save componentsearchengine <user> <pass>`"
        ))?;
    let mfr = mfr.map(String::from)
        .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.manufacturer.to_string()))
        .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass --mfr <name>"))?;
    // Keep the dashboard "working on" bar LIVE for the whole browser fetch —
    // cse_fetch_zip blocks ~40s on the XHR chain, so a single heartbeat would
    // go stale mid-fetch. The ticker re-pulses every 6s until it's stopped.
    let ticker = hints::HeartbeatTicker::start(mpn, &format!("CSE: fetching CAD via your browser ({mfr})"));
    let bytes = pup::cse_fetch_zip(mpn, &mfr, &cred.username, &cred.password)?;
    ticker.stop();
    let zip = std::env::temp_dir().join(format!("cse_{mpn}.zip"));
    std::fs::write(&zip, &bytes)?;
    // Keep the ORIGINAL downloaded archive in the library so the user can
    // inspect the exact bytes we pulled off the internet (folder structure, all
    // EDA formats, the 3D, etc.) — not just the files we extracted from it.
    let orig = library::root().join(mpn).join(format!("{mpn}.cse-bundle.zip"));
    let _ = std::fs::create_dir_all(orig.parent().unwrap());
    let _ = std::fs::write(&orig, &bytes);
    println!("OK: CSE zip {} KB → saved original + importing symbol+footprint+STEP", bytes.len() / 1024);
    let _ = hints::heartbeat(mpn, "importing CSE bundle");
    import_cmd(&zip, Some(mpn), false, "cse", "manufacturer", "")?;
    let _ = hints::heartbeat(mpn, "imported");
    // First-class metadata: pull description + stock + pricing from the Adom
    // parts tools (keys baked in — no API key). Best-effort; never fails the run.
    auto_enrich(mpn);
    // The DATASHEET is the most authoritative source — ALWAYS run ds2sf so every
    // fetched chip gets the independent datasheet extraction. Best-effort: a
    // ds2sf failure doesn't fail the CAD fetch, but the gate WILL flag it (so it
    // can never be silently skipped). Set CHIP_FETCHER_SKIP_DS2SF=1 to opt out.
    if std::env::var("CHIP_FETCHER_SKIP_DS2SF").is_err() {
        if let Err(e) = cmd_ds2sf(mpn) {
            println!("WARN: ds2sf did not complete for {mpn}: {e}");
        }
    }
    warn_if_no_description(mpn);
    Ok(())
}

/// Best-effort enrichment from the Adom parts tools after a fetch. Quietly
/// no-ops if adom-parts-search isn't reachable.
fn auto_enrich(mpn: &str) {
    let _ = hints::heartbeat(mpn, "enriching from adom-parts-search");
    match crate::enrich::enrich(mpn) {
        Ok(r) => {
            if let Some(d) = &r.description {
                println!("  enrich: description set — {}", d.chars().take(70).collect::<String>());
            }
            if r.wrote_stock { println!("  enrich: stock.json written (stock + pricing + lifecycle)"); }
        }
        Err(e) => println!("  enrich: skipped ({e})"),
    }
}

/// Read a chip's info.json.description (empty if none).
fn chip_description(mpn: &str) -> String {
    let p = library::root().join(mpn).join("info.json");
    std::fs::read_to_string(&p).ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.get("description").and_then(|d| d.as_str()).map(String::from))
        .unwrap_or_default()
}

/// After sourcing, nudge the AI to set a human description if the chip has none
/// — the card + zoom view show it, and a blank "what is this chip" reads as
/// broken. Self-enforcing, same spirit as the gate/completeness warnings.
fn warn_if_no_description(mpn: &str) {
    if chip_description(mpn).trim().is_empty() {
        println!("HINT: {mpn} has no description — the card/zoom view will show a blank \
                  'what is this chip'. Set one: `adom-chip-fetcher describe {mpn} \"<one-line what it is>\"`");
    }
}

/// Run adom-ds2sf on a chip — the datasheet IS the most authoritative source.
/// Hard-guarantees a real datasheet first, then extracts + renders the variant.
fn cmd_ds2sf(mpn: &str) -> Result<()> {
    let dir = library::root().join(mpn);
    if !dir.exists() { anyhow::bail!("library/{mpn}/ does not exist"); }
    let pdf = dir.join(format!("{mpn}.pdf"));
    // GUARANTEE the datasheet is usable BEFORE spending a Claude extraction on it.
    match crate::import::datasheet_ready(&pdf) {
        Ok(q) => println!("OK: datasheet ready — {} pages, pinout detected", q.pages),
        Err(reason) => {
            hints::print_hint(&format!("{mpn}: datasheet NOT usable for ds2sf — {reason}. \
                Re-fetch a real datasheet (`adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <mfr lit URL>`), then re-run."));
            anyhow::bail!("ds2sf needs a real datasheet for {mpn}: {reason}");
        }
    }
    let _ = hints::heartbeat(mpn, "ds2sf: extracting symbol+footprint from datasheet");
    let status = std::process::Command::new("adom-ds2sf")
        .args(["extract"]).arg(&dir).arg("--force").arg("--json-only")
        .status()
        .map_err(|e| anyhow::anyhow!("could not exec adom-ds2sf (install it): {e}"))?;
    if !status.success() {
        let result = std::fs::read_to_string(dir.join(format!("{mpn}-extraction.result.json"))).ok()
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
        let why = result.as_ref().and_then(|r| r.get("summary").and_then(|s| s.as_str())).unwrap_or("see extraction.result.json");
        anyhow::bail!("ds2sf extraction failed for {mpn}: {why}");
    }
    // Inject the canonical RefDes/Value textPlacement into the footprint
    // extraction so exporters (adom-lbr → Fusion/.lbr, Altium, OrCAD) validate
    // against intent instead of re-deriving from pads. adom-footprint is the
    // single source of truth for placement; best-effort (skips if no pads).
    let fp_json = dir.join(format!("{mpn}-footprint.extracted.json"));
    if fp_json.is_file() {
        let _ = std::process::Command::new("adom-footprint")
            .args(["placement", "--write", "--file"])
            .arg(&fp_json)
            .output();
    }
    // Render the ds2sf variant thumbnails so the carat + validation surface them.
    auto_render_thumbnails(mpn);
    let _ = hints::heartbeat(mpn, "ds2sf: extracted");
    println!("OK: {mpn} — ds2sf symbol + footprint extracted from the datasheet");
    Ok(())
}

/// Enrich a chip from the Adom parts tools and report what was written.
fn cmd_enrich(mpn: &str) -> Result<()> {
    let r = crate::enrich::enrich(mpn)?;
    match &r.description {
        Some(d) => println!("OK: {mpn} description — {d}"),
        None => println!("WARN: {mpn} — no description found via adom-parts-search"),
    }
    if r.wrote_stock { println!("OK: {mpn} stock.json written (stock + pricing + lifecycle)"); }
    if !r.variants.is_empty() {
        println!("OK: {mpn} orderable variants: {}", r.variants.join(", "));
    }
    Ok(())
}

/// Write the human description into library/<MPN>/info.json (creating the file
/// if needed). Shown on the dashboard card and the zoom focus view.
fn cmd_describe(mpn: &str, text: &str) -> Result<()> {
    let dir = library::root().join(mpn);
    if !dir.exists() {
        anyhow::bail!("library/{mpn}/ does not exist — source the chip first");
    }
    let p = dir.join("info.json");
    let mut info: serde_json::Value = std::fs::read_to_string(&p).ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    info["description"] = serde_json::Value::String(text.to_string());
    std::fs::write(&p, serde_json::to_string_pretty(&info)?)?;
    println!("OK: {mpn} description set ({} chars)", text.len());
    Ok(())
}

/// First-class credential awareness: scan saved logins across every browser
/// profile, map to the ladder, report coverage + the best profile. The sourcing
/// flow auto-switches profiles per vendor using the same data.
fn cmd_logins() -> Result<()> {
    use std::collections::{BTreeMap, BTreeSet};
    let creds = pup::credentials();
    let mut by: BTreeMap<String, BTreeMap<&str, BTreeSet<String>>> = BTreeMap::new();
    for c in creds {
        for (frag, name) in pup::VENDOR_HOSTS {
            if c.host.contains(frag) {
                by.entry(format!("{} · {}", c.browser, c.profile))
                    .or_default().entry(*name).or_default()
                    .insert(if c.username.is_empty() { "(saved)".into() } else { c.username.clone() });
            }
        }
    }
    if by.is_empty() {
        println!("No saved CAD/distributor logins found in the native browser.");
        println!("HINT: install/enable the Adom browser extension, or sign in to a vendor once.");
        return Ok(());
    }
    let mut ranked: Vec<_> = by.into_iter().collect();
    ranked.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
    println!("adom-chip-fetcher — saved CAD/distributor logins (read-only; never passwords):");
    for (prof, vmap) in &ranked {
        println!("\n  [{prof}] — {} vendor(s):", vmap.len());
        for (name, users) in vmap {
            println!("     {name:16} {}", users.iter().cloned().collect::<Vec<_>>().join(", "));
        }
    }
    let (best, bv) = &ranked[0];
    println!("\nBEST profile for sourcing: {best} ({} vendors). adom-chip-fetcher auto-switches \
              to the right signed-in profile per vendor as it sources — no questions.", bv.len());

    // Per-BROWSER score: which native browser (Chrome vs Edge) covers the most
    // of the electronics industry across all its profiles.
    let total_vendors = pup::VENDOR_HOSTS.iter().map(|(_, n)| *n).collect::<BTreeSet<_>>().len();
    let mut by_browser: BTreeMap<String, BTreeSet<&str>> = BTreeMap::new();
    for c in creds {
        for (frag, name) in pup::VENDOR_HOSTS {
            if c.host.contains(frag) { by_browser.entry(c.browser.clone()).or_default().insert(*name); }
        }
    }
    let mut bscore: Vec<_> = by_browser.into_iter().collect();
    bscore.sort_by_key(|(_, v)| std::cmp::Reverse(v.len()));
    println!("\n=== Native-browser industry-coverage score ===");
    for (br, v) in &bscore {
        println!("  {:6} {}/{} vendors  ({})", br, v.len(), total_vendors, v.iter().cloned().collect::<Vec<_>>().join(", "));
    }
    if let Some((winner, v)) = bscore.first() {
        println!("\nWINNER: drive in **{winner}** — broadest electronics-industry login coverage ({}/{} vendors).", v.len(), total_vendors);
    }
    Ok(())
}

/// Seed the user-visible plan + planned-state cards BEFORE any scraping, so the
/// user sees the whole queue up front. Required first step of every batch.
fn cmd_plan(mpns: &[String], name: &str) -> Result<()> {
    if mpns.is_empty() {
        anyhow::bail!("no MPNs given.\n  Usage: adom-chip-fetcher plan <MPN1> <MPN2> ... [--name <plan>]");
    }
    let _ = crate::projects::create_project(name, "adom-chip-fetcher plan");
    crate::projects::set_active(Some(name))?;
    if let Err(e) = hints::dashboard_ensure(false) {
        println!("WARN: could not auto-open the dashboard ({e}); start it with `adom-chip-fetcher serve`");
    }
    println!("PLAN '{name}' — {} chip(s) queued (the user sees these as planned cards now):", mpns.len());
    for (i, mpn) in mpns.iter().enumerate() {
        let _ = crate::projects::add_mpn(name, mpn);
        let _ = hints::heartbeat(mpn, "planned"); // side-effect: seeds the stub card
        println!("  {}. {mpn}", i + 1);
    }
    println!("\nNow source them — manufacturer-first, and every verb heartbeats live to the dashboard:");
    println!("  adom-chip-fetcher mfr-probe <MPN>     # required first source (the maker)");
    println!("  adom-chip-fetcher next-source <MPN>   # the next ladder step to try");
    println!("  adom-chip-fetcher gate                # refuses 'done' while any card is incomplete");
    Ok(())
}

/// PREFLIGHT GUARD — every browser-driving verb (fetch / mfr-probe / scrape)
/// calls this FIRST. It refuses (non-zero exit + HINT) to scrape any MPN the
/// user hasn't seen in an active plan, and refuses if the dashboard isn't live
/// to show working status. This is the programmatic backstop that makes
/// "willy-nilly AI scraping with no insight" structurally impossible — for this
/// agent and for every third party that drives the tool.
fn require_planned(mpn: &str) -> Result<()> {
    let active = crate::projects::get_active().ok_or_else(|| anyhow::anyhow!(
        "REFUSED: no active plan — the user must see the queue before any scraping.\n  \
         HINT: adom-chip-fetcher plan {mpn} [more MPNs...]   then source."
    ))?;
    if !crate::projects::mpns_for_project(&active).iter().any(|m| m == mpn) {
        anyhow::bail!(
            "REFUSED: {mpn} is not in the active plan '{active}'. Don't scrape chips the user never saw planned.\n  \
             HINT: adom-chip-fetcher plan {mpn} --name {active}"
        );
    }
    if !dashboard_reachable() {
        anyhow::bail!(
            "REFUSED: the dashboard isn't reachable on :8786, so the user would get no working-status insight.\n  \
             HINT: adom-chip-fetcher serve   (then retry) — the dashboard is the user's home base."
        );
    }
    Ok(())
}

fn dashboard_reachable() -> bool {
    use std::net::TcpStream;
    use std::time::Duration;
    "127.0.0.1:8786"
        .parse()
        .ok()
        .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok())
        .is_some()
}

fn project_cmd(op: ProjectOp) -> Result<()> {
    use crate::projects;
    match op {
        ProjectOp::Create { name, description } => {
            projects::create_project(&name, &description)?;
            println!("OK: created project '{name}'");
        }
        ProjectOp::List => {
            let active = projects::get_active();
            let projs = projects::list_projects();
            if projs.is_empty() {
                println!("no projects. create one with: adom-chip-fetcher project create <name>");
            }
            for p in &projs {
                let marker = if active.as_deref() == Some(&p.name) { " (active)" } else { "" };
                println!("  {} — {} chips{}", p.name, p.mpns.len(), marker);
            }
        }
        ProjectOp::Show { name } => {
            match projects::get_project(&name) {
                Some(p) => {
                    println!("project: {}", p.name);
                    if !p.description.is_empty() { println!("  {}", p.description); }
                    println!("  chips ({}):", p.mpns.len());
                    for m in &p.mpns { println!("    {m}"); }
                }
                None => println!("project '{name}' not found"),
            }
        }
        ProjectOp::Add { name, mpn } => {
            projects::add_mpn(&name, &mpn)?;
            println!("OK: added {mpn} to project '{name}'");
        }
        ProjectOp::Remove { name, mpn } => {
            projects::remove_mpn(&name, &mpn)?;
            println!("OK: removed {mpn} from project '{name}'");
        }
        ProjectOp::Activate { name } => {
            projects::set_active(Some(&name))?;
            println!("OK: active project set to '{name}'");
            hints::print_hint("dashboard will now filter to this project's chips");
        }
        ProjectOp::Deactivate => {
            projects::set_active(None)?;
            println!("OK: active project cleared — dashboard shows full library");
        }
        ProjectOp::Delete { name } => {
            projects::delete_project(&name)?;
            println!("OK: deleted project '{name}' (library chips untouched)");
        }
    }
    Ok(())
}

fn thumbnails_cmd(mpn: Option<&str>, force: bool) -> Result<()> {
    if let Some(m) = mpn {
        let _ = hints::heartbeat(m, "rendering thumbnails");
        let r = thumbnails::generate_for(m, force)?;
        print_thumb_report(&r);
        let _ = hints::heartbeat(m, "thumbnails ready");
    } else {
        println!("rendering thumbnails for every chip in library/...");
        let reports = thumbnails::generate_all(force);
        let mut total_gen = 0;
        let mut total_skip = 0;
        let mut total_err = 0;
        for r in &reports {
            print_thumb_report(r);
            total_gen += r.generated.len();
            total_skip += r.skipped.len();
            total_err += r.errors.len();
        }
        println!();
        println!("OK: {} chips processed — {total_gen} generated, {total_skip} skipped (up-to-date), {total_err} errors", reports.len());
        hints::print_hint("open the dashboard to see them: `adom-chip-fetcher dashboard ensure`");
    }
    Ok(())
}

fn print_thumb_report(r: &thumbnails::GenReport) {
    let parts: Vec<String> = r.generated.iter().map(|s| format!("+{s}")).chain(
        r.skipped.iter().map(|s| format!("={s}"))
    ).chain(
        r.errors.iter().map(|(k,_)| format!("!{k}"))
    ).collect();
    println!("  {:<24} {}", r.mpn, parts.join(" "));
    for (k, e) in &r.errors {
        println!("    ERR {k}: {e}");
    }
}

fn dashboard_cmd(op: Cmd_) -> Result<()> { dashboard_dispatch(op) }

// Shim: clap's `#[command(subcommand)]` argument needs the parent type to
// resolve, so we route through this small helper.
type Cmd_ = DashboardOp;

fn dashboard_dispatch(op: DashboardOp) -> Result<()> {
    match op {
        DashboardOp::Ensure { force } => hints::dashboard_ensure(force),
        DashboardOp::SetSurface { surface } => {
            let parsed = match surface.trim().to_lowercase().as_str() {
                "pup" => hints::DashboardSurface::Pup,
                "webview" | "hydrogen" => hints::DashboardSurface::Webview,
                _ => anyhow::bail!("surface must be 'pup' or 'webview'"),
            };
            hints::set_dashboard_surface(parsed)?;
            hints::print_hint("verify with `adom-chip-fetcher dashboard ensure`");
            Ok(())
        }
        DashboardOp::Surface => {
            let s = hints::get_dashboard_surface();
            println!("dashboard surface: {:?}", s);
            hints::print_hint("change with `adom-chip-fetcher dashboard set-surface pup|webview`");
            Ok(())
        }
    }
}

/// Full completeness ladder for one chip or the whole library.
/// 9 levels, each checked from the filesystem. The AI reads this to
/// decide what to do next — no guessing, no "I think it's done."
fn done_cmd(mpn_filter: Option<&str>) -> Result<()> {
    let lib = inventory::scan()?;
    let chips: Vec<_> = lib.chips.into_iter()
        .filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))
        .collect();
    if chips.is_empty() {
        if let Some(m) = mpn_filter { anyhow::bail!("no chip {m} in library"); }
        println!("library is empty."); return Ok(());
    }

    for c in &chips {
        let dir = crate::library::root().join(&c.mpn);
        let has_stock = dir.join("stock.json").exists();
        let has_prov = dir.join(format!("{}-file-provenance.json", c.mpn)).exists();
        let has_val_status = c.validation_status.is_some();
        let val_golden = matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"));
        let has_thumbs = c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d;

        let levels: [(u8, &str, bool, &str); 9] = [
            (1, "Datasheet PDF",    c.has_pdf,      if c.has_pdf { "" } else { "fetch <MPN>.pdf from mfr → SnapMagic → Mouser → DigiKey" }),
            (2, "3D STEP model",    c.has_step,     if c.has_step { "" } else { "fetch STEP from mfr or DigiKey models page" }),
            (3, "KiCad sym+mod",    c.has_sym && c.has_mod, if c.has_sym && c.has_mod { "" } else {
                if !c.has_sym && !c.has_mod { "fetch .kicad_sym + .kicad_mod from UL/SnapMagic/stdlib" }
                else if !c.has_sym { "fetch .kicad_sym" } else { "fetch .kicad_mod" }
            }),
            (4, "Altium library",   c.has_altium || c.altium_not_available,   if c.has_altium { "" } else if c.altium_not_available { "(not available from any source)" } else { "fetch .IntLib or .SchLib+.PcbLib from SnapMagic/CSE" }),
            (5, "Fusion .lbr",      c.has_fusion_lbr || c.fusion_not_available, if c.has_fusion_lbr { "" } else if c.fusion_not_available { "(not available from any source)" } else { "fetch .lbr from SnapMagic/CSE" }),
            (6, "Thumbnails",       has_thumbs,     if has_thumbs { "" } else { "`adom-chip-fetcher thumbnails --mpn <MPN> --force`" }),
            (7, "Validated",        val_golden,     if val_golden { "" } else if has_val_status { "re-run: `adom-chip-fetcher validate <MPN>`" } else { "run: `adom-chip-fetcher validate <MPN>` (needs PDF + sym + mod)" }),
            (8, "Mouser stock",     has_stock,      if has_stock { "" } else { "fetch stock.json from Mouser API or product page" }),
            (9, "File provenance",  has_prov,       if has_prov { "" } else { "re-import with --discovery-source + --content-origin flags" }),
        ];

        let done_count = levels.iter().filter(|(_, _, ok, _)| *ok).count();
        println!("{} — {}/9 levels complete", c.mpn, done_count);
        for (num, name, ok, fix) in &levels {
            let mark = if *ok { "✓" } else { "✗" };
            let fix_hint = if fix.is_empty() { String::new() } else { format!(" → {fix}") };
            println!("  {mark} {num}. {name}{fix_hint}");
        }
        if done_count < 9 {
            let next = levels.iter().find(|(_, _, ok, _)| !*ok);
            if let Some((_, name, _, fix)) = next {
                println!();
                hints::print_hint(&format!("next step for {}: {} — {}", c.mpn, name, fix));
            }
        }
        println!();
    }

    let total = chips.len();
    let fully = chips.iter().filter(|c| {
        let dir = crate::library::root().join(&c.mpn);
        c.has_pdf && c.has_step && c.has_sym && c.has_mod && (c.has_altium || c.altium_not_available) && (c.has_fusion_lbr || c.fusion_not_available)
            && c.has_thumb_symbol && c.has_thumb_footprint && c.has_thumb_chip3d
            && matches!(c.validation_status.as_deref(), Some("golden") | Some("golden_with_variants"))
            && dir.join("stock.json").exists()
            && dir.join(format!("{}-file-provenance.json", c.mpn)).exists()
    }).count();
    if total > 1 {
        println!("SUMMARY: {fully}/{total} chips at level 9 (fully complete)");
    }
    Ok(())
}

// ---- Hardcoded profile for all pup scraping ----
// The user's vendor login cookies (SnapEDA, UL, CSE, TI, ST, Mouser,
// DigiKey) live in the "adom-chip-fetcher" Chrome profile. Creating ANY
// other profile loses every credential. This constant is the single
// source of truth — every scrape/refetch/pup-pdf-fetch path uses it.
const SCRAPE_PROFILE: &str = "adom-chip-fetcher";
const SCRAPE_SESSION: &str = "adom-chip-fetcher-scrape";

fn scrape_cmd(op: ScrapeOp) -> Result<()> {
    match op {
        ScrapeOp::Open { mpn, url } => {
            require_planned(&mpn)?;
            let _ = hints::heartbeat(&mpn, &format!("scrape: opening {}", &url[..url.len().min(60)]));
            // ONE window: the scrape tab lives beside the dashboard in the
            // adom-chip-fetcher window (native) — no separate scrape window.
            pup::scrape_navigate(&url)?;
            println!("OK: scrape tab open at {url} ({})", pup::surface_label());
            hints::print_hint(&format!("page is loading. Next: `adom-chip-fetcher scrape eval {mpn} '<JS>'` to inspect, or `adom-chip-fetcher scrape screenshot {mpn}`."));
            Ok(())
        }
        ScrapeOp::Navigate { mpn, url, stage } => {
            require_planned(&mpn)?;
            let label = stage.unwrap_or_else(|| format!("navigating {}", &url[..url.len().min(50)]));
            let _ = hints::heartbeat(&mpn, &label);
            pup::scrape_navigate(&url)?;
            println!("OK: navigated to {url}");
            hints::print_hint(&format!("page loaded. Next: eval JS to find download links, or screenshot. Check `adom-chip-fetcher next-source {mpn}` if this source lacks what you need."));
            Ok(())
        }
        ScrapeOp::Eval { mpn, expr } => {
            let short = if expr.len() > 40 { format!("{}…", &expr[..40]) } else { expr.clone() };
            let _ = hints::heartbeat(&mpn, &format!("scrape eval: {short}"));
            let result = pup::scrape_eval(&expr)?;
            println!("{}", serde_json::to_string_pretty(&result)?);
            hints::print_hint(&format!("read the result. For a PDF URL use `adom-chip-fetcher pup-pdf-fetch {mpn} --pdf-url <url>`; for CAD use `adom-chip-fetcher refetch {mpn} --format <fmt> --source <src>`."));
            Ok(())
        }
        ScrapeOp::Screenshot { mpn } => {
            let _ = hints::heartbeat(&mpn, "scrape: taking screenshot");
            let v = pup::scrape_screenshot()?;
            let path = v.get("savedTo").and_then(|p| p.as_str())
                .or_else(|| v.get("output").and_then(|o| o.as_str()))
                .unwrap_or("(captured)");
            println!("OK: screenshot {path}");
            hints::print_hint("look at the screenshot. If a modal blocks, dismiss it with `scrape eval` first.");
            Ok(())
        }
    }
}

fn creds_cmd(op: CredsOp) -> Result<()> {
    match op {
        CredsOp::List => {
            let creds = crate::creds::load()?;
            if creds.is_empty() {
                println!("no stored credentials");
                println!();
                hints::print_hint("save creds with: adom-chip-fetcher creds save <host> <username> <password>");
                hints::print_hint("hosts: snapeda, ultralib, componentsearch, ti, st, microchip, google");
            } else {
                println!("stored credentials ({}):", creds.len());
                for c in &creds {
                    let masked = if c.password.len() > 2 {
                        format!("{}…{}", &c.password[..1], &c.password[c.password.len()-1..])
                    } else { "***".into() };
                    println!("  {:<20} user={:<25} pass={}", c.host, c.username, masked);
                }
            }
            Ok(())
        }
        CredsOp::Save { host, username, password } => {
            crate::creds::set(&host, &username, &password)?;
            println!("OK: saved credential for {host} (user={username})");
            println!("stored at ~/.config/adom-chip-fetcher/credentials.json");
            Ok(())
        }
        CredsOp::Delete { host } => {
            crate::creds::delete(&host)?;
            println!("OK: deleted credential for {host}");
            Ok(())
        }
    }
}

/// PDF magic-byte sweep. Walks every `<MPN>.pdf` in library/ and reports
/// which ones don't actually start with `%PDF-`. The bug this catches:
/// a vendor (LCSC, Cloudflare-walled mfr sites, "click to download"
/// interstitial pages) silently returns an HTML body for what looks like
/// a 200 OK PDF response, the off-band download writes those bytes to
/// `<MPN>.pdf`, and downstream tools (ds2sf, chipsmith) then choke on
/// what's not a real PDF. Real example caught 2026-05-07: HR911105A.pdf
/// was 47 KB of <!DOCTYPE html> from an LCSC mirror that 404'd.
///
/// On --repair: deletes the bogus PDF + appends an `html_masquerade`
/// failure entry to info.json.fetched_via_chain so `adom-chip-fetcher
/// next-source` correctly advances to the next ladder step.
fn pdf_check_cmd(mpn_filter: Option<&str>, repair: bool) -> Result<()> {
    let lib = library::root();
    if !lib.exists() {
        anyhow::bail!("library does not exist at {}", lib.display());
    }
    let mut total = 0usize;
    let mut bogus: Vec<(String, usize, String)> = Vec::new(); // (mpn, size, first_80_bytes)
    let mut clean = 0usize;
    let mut missing = 0usize;

    for entry in std::fs::read_dir(&lib)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() { continue; }
        let mpn = match path.file_name().and_then(|n| n.to_str()) {
            Some(m) => m.to_string(),
            None => continue,
        };
        if let Some(filter) = mpn_filter {
            if mpn != filter { continue; }
        }
        let pdf = path.join(format!("{mpn}.pdf"));
        if !pdf.is_file() { missing += 1; continue; }
        total += 1;

        // Read first 80 bytes — enough for magic + diagnostic preview.
        let mut head = [0u8; 80];
        let read = match std::fs::File::open(&pdf)
            .and_then(|mut f| std::io::Read::read(&mut f, &mut head))
        {
            Ok(n) => n,
            Err(_) => continue,
        };
        let head_slice = &head[..read];

        if head_slice.starts_with(b"%PDF-") {
            clean += 1;
            continue;
        }

        let size = std::fs::metadata(&pdf).map(|m| m.len() as usize).unwrap_or(0);
        let preview: String = head_slice.iter().take(80)
            .map(|b| if b.is_ascii() && !b.is_ascii_control() { *b as char } else { '·' })
            .collect();
        bogus.push((mpn.clone(), size, preview.clone()));

        if repair {
            let _ = std::fs::remove_file(&pdf);
            // Try to record the failure on info.json.fetched_via_chain
            // so next-source knows the prior datasheet attempt was bogus.
            // Best-effort: pick the most recent successful chain entry's
            // source as the failed one (since that's where the file came
            // from), or fall back to "unknown" if chain is empty.
            let chain = sourcing::read_chain(&mpn).unwrap_or_default();
            let suspect_source = chain.iter()
                .rev()
                .find(|e| e.outcome == "OK" || e.outcome.contains("OK") || e.outcome == "lcsc_last_resort")
                .map(|e| e.source.clone());
            if let Some(s) = suspect_source {
                let src_enum = match s.as_str() {
                    "manufacturer" => sourcing::Source::Manufacturer,
                    "snapmagic" => sourcing::Source::SnapMagic,
                    "mouser" => sourcing::Source::Mouser,
                    "digikey" => sourcing::Source::DigiKey,
                    "arrow" => sourcing::Source::Arrow,
                    "cse_ul" | "cse" => sourcing::Source::CseUl,
                    "lcsc" => sourcing::Source::Lcsc,
                    _ => sourcing::Source::Manufacturer,
                };
                let _ = sourcing::append_chain(&mpn, src_enum, "html_masquerade",
                    Some(&format!("PDF was actually HTML — first 80 bytes: {}", preview.chars().take(60).collect::<String>())));
            }
        }
    }

    println!("PDF check: {clean}/{total} real PDFs ({missing} chips have no PDF){}",
        if repair { "; --repair active" } else { "" });
    if !bogus.is_empty() {
        println!();
        println!("Bogus PDFs (HTML masquerading as %PDF-):");
        for (mpn, sz, preview) in &bogus {
            let action = if repair { " → DELETED" } else { "" };
            println!("  ❌ {mpn} ({sz} bytes){action}");
            println!("     first 60 bytes: {}", &preview[..preview.len().min(60)]);
        }
        println!();
        if repair {
            hints::print_hint("bogus PDFs deleted + info.json.fetched_via_chain updated. Run `adom-chip-fetcher next-source <MPN>` to see what to try next.");
        } else {
            hints::print_hint("re-run with --repair to delete bogus PDFs + record html_masquerade in fetched_via_chain. Then refetch from next ladder source.");
        }
        anyhow::bail!("found {} bogus PDF(s)", bogus.len());
    }

    Ok(())
}

/// Drive pup to a vendor page, find the real PDF anchor on the rendered
/// page (cookies + Cloudflare-passed Chrome fingerprint preserved), pull
/// the bytes via `fetch().arrayBuffer()` inside the browser context,
/// base64-transfer them back, magic-byte validate, save to
/// `library/<MPN>/<MPN>.pdf`. The escape hatch for vendors that detect
/// curl and serve HTML to it (Hanrun's Aliyun CDN, ST CDN, JST product
/// pages, etc.).
///
/// Decision matrix:
///   --pdf-url given                → fetch that URL directly (most reliable)
///   --page-url given (no pdf-url)  → navigate, find first PDF anchor matching MPN
///   neither given                  → use sourcing::guess_mfr to pick a search URL
fn pup_pdf_fetch_cmd(
    mpn: &str,
    page_url: Option<&str>,
    pdf_url: Option<&str>,
    source: &str,
    session: &str,
) -> Result<()> {
    let dir = library::root().join(mpn);
    if !dir.is_dir() {
        anyhow::bail!("no chip directory at {} — heartbeat or import once first to create it", dir.display());
    }

    let source_enum = match source.to_ascii_lowercase().as_str() {
        "manufacturer" | "mfr" => sourcing::Source::Manufacturer,
        "snapmagic" | "snapeda" => sourcing::Source::SnapMagic,
        "mouser" => sourcing::Source::Mouser,
        "digikey" => sourcing::Source::DigiKey,
        "arrow" => sourcing::Source::Arrow,
        "cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,
        "lcsc" => sourcing::Source::Lcsc,
        other => anyhow::bail!("unknown source slug {other:?}"),
    };

    let _ = session; // legacy arg; native fetch uses the one adom-chip-fetcher window
    // Resolve the PDF URL: prefer --pdf-url; else open --page-url (or the mfr
    // product page) in the adom-chip-fetcher window's scrape tab and read the
    // datasheet anchor off the rendered page. Everything stays in ONE window.
    let resolved_pdf = if let Some(p) = pdf_url {
        p.to_string()
    } else {
        let nav_url = page_url.map(|s| s.to_string())
            .or_else(|| sourcing::guess_mfr(mpn).map(|g| g.search_url))
            .ok_or_else(|| anyhow::anyhow!("pass --pdf-url or --page-url for {mpn}"))?;
        let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: finding datasheet"));
        pup::scrape_navigate(&nav_url)?;
        std::thread::sleep(std::time::Duration::from_secs(4));
        let r = pup::scrape_eval(
            "(function(){var a=[...document.querySelectorAll('a')].find(function(x){return /\\/resource\\/.*\\.pdf/i.test(x.href)||(/datasheet/i.test(x.textContent)&&/\\.pdf/i.test(x.href))});return a?a.href:''})()"
        )?;
        let url = r.as_str().unwrap_or("").to_string();
        if url.is_empty() {
            let _ = sourcing::append_chain(mpn, source_enum, "pup_no_pdf_link",
                Some(&format!("no datasheet anchor on {nav_url}")));
            anyhow::bail!("no datasheet link found on {nav_url}; pass --pdf-url <direct-url>");
        }
        println!("OK: found datasheet: {url}");
        url
    };

    // Fetch the bytes THROUGH the signed-in browser (real cookies, no download,
    // no native viewer) straight into the library.
    let _ = hints::heartbeat(mpn, &format!("pup-pdf-fetch from {source}: downloading datasheet"));
    let target = dir.join(format!("{mpn}.pdf"));
    let (n, ctype) = pup::fetch_url_to_file(&resolved_pdf, &target)?;
    let head: Vec<u8> = std::fs::read(&target).ok().map(|b| b.into_iter().take(4).collect()).unwrap_or_default();
    if head != b"%PDF" {
        std::fs::remove_file(&target).ok();
        let _ = sourcing::append_chain(mpn, source_enum, "pup_fetch_not_pdf",
            Some(&format!("content-type {ctype}, {n} bytes, head {:?}", String::from_utf8_lossy(&head))));
        anyhow::bail!("fetched bytes are not a PDF (content-type {ctype}, {n} bytes)");
    }
    println!("OK: wrote {} ({} KB, {source})", target.display(), n / 1024);
    let _ = sourcing::append_chain(mpn, source_enum, "OK",
        Some(&format!("datasheet {} KB via fetch_url, %PDF verified", n / 1024)));
    let _ = hints::heartbeat(mpn, "imported");
    Ok(())
}

/// Tiny base64 decoder. Avoids pulling in the `base64` crate for one
/// call site. Spec: RFC 4648 + Standard alphabet, ignores whitespace.
fn base64_decode(s: &str) -> std::result::Result<Vec<u8>, String> {
    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut lookup = [255u8; 256];
    for (i, &c) in ALPHA.iter().enumerate() { lookup[c as usize] = i as u8; }
    let mut out = Vec::with_capacity(s.len() * 3 / 4);
    let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
    let mut i = 0;
    while i + 3 < bytes.len() {
        let (a, b, c, d) = (lookup[bytes[i] as usize], lookup[bytes[i+1] as usize],
                            lookup[bytes[i+2] as usize], lookup[bytes[i+3] as usize]);
        if a == 255 || b == 255 || c == 255 || d == 255 {
            return Err(format!("invalid char at byte {i}"));
        }
        out.push((a << 2) | (b >> 4));
        out.push((b << 4) | (c >> 2));
        out.push((c << 6) | d);
        i += 4;
    }
    // Tail bytes (handles 2- and 3-byte residue without padding).
    if i + 1 < bytes.len() {
        let a = lookup[bytes[i] as usize];
        let b = lookup[bytes[i+1] as usize];
        out.push((a << 2) | (b >> 4));
        if i + 2 < bytes.len() {
            let c = lookup[bytes[i+2] as usize];
            out.push((b << 4) | (c >> 2));
        }
    }
    Ok(out)
}

/// Re-fetch driver. Opens the canonical URL for (source, MPN) in the
/// adom-chip-fetcher-scrape pup session, sets up a desktop_watch_files for the
/// expected download, waits up to `timeout` seconds, then imports the
/// resulting file with magic-byte validation. Records every attempt on
/// `info.json.fetched_via_chain` so the audit trail shows which sources
/// were probed in what order.
fn refetch_cmd(mpn: &str, format: &str, source: &str, url_only: bool, timeout: u64) -> Result<()> {
    let dir = library::root().join(mpn);
    if !dir.is_dir() {
        anyhow::bail!("no chip directory at {}", dir.display());
    }

    let source_enum = match source.to_ascii_lowercase().as_str() {
        "manufacturer" | "mfr" => sourcing::Source::Manufacturer,
        "snapmagic" | "snapeda" => sourcing::Source::SnapMagic,
        "mouser" => sourcing::Source::Mouser,
        "digikey" => sourcing::Source::DigiKey,
        "arrow" => sourcing::Source::Arrow,
        "cse" | "cse_ul" | "ul" | "ultralibrarian" => sourcing::Source::CseUl,
        "lcsc" => sourcing::Source::Lcsc,
        other => anyhow::bail!("unknown source slug {other:?}; valid: manufacturer, snapmagic, mouser, digikey, arrow, cse_ul, lcsc"),
    };

    let url = canonical_source_url(mpn, source_enum)?;
    println!("OK: refetch {mpn} format={format} source={source}");
    println!("URL: {url}");

    if url_only {
        // Used by validate.rs::attempt_refetch_library so the autonomous
        // loop can record what would be tried without opening a browser
        // window in the middle of validation. Returns the URL via stdout
        // for the caller; chain entry written below.
        let _ = sourcing::append_chain(mpn, source_enum, "refetch_url_resolved",
            Some(&format!("format={format}, url={url}")));
        return Ok(());
    }

    // Real refetch — open URL, watch downloads, import.
    let _ = hints::heartbeat(mpn, &format!("refetch {format} from {source}: opening pup"));
    let body = serde_json::json!({
        "sessionId": "adom-chip-fetcher-scrape",
        "profile": "adom-chip-fetcher",
        "url": url,
    }).to_string();
    let out = std::process::Command::new("adom-desktop")
        .args(["browser_open_window", &body])
        .output()?;
    if !out.status.success() {
        let _ = sourcing::append_chain(mpn, source_enum, "refetch_failed",
            Some(&format!("could not open pup window for {url}")));
        anyhow::bail!("could not open pup window: {}", String::from_utf8_lossy(&out.stderr));
    }

    println!("watching ~/Downloads for {timeout}s — click the source's download button to land the file");
    let glob = match format {
        "kicad_sym" | "kicad_mod" | "eagle" | "all" => "*.zip",
        "step" => "*.step",
        "pdf"  => "*.pdf",
        _ => "*",
    };
    let watch_body = serde_json::json!({
        "path": "Downloads",
        "glob": glob,
        "timeoutMs": timeout * 1000,
    }).to_string();
    let watch_out = std::process::Command::new("adom-desktop")
        .args(["desktop_watch_files", &watch_body])
        .output()?;
    if !watch_out.status.success() {
        let _ = sourcing::append_chain(mpn, source_enum, "refetch_timeout",
            Some(&format!("desktop_watch_files exit non-zero (likely user did not click within {timeout}s)")));
        anyhow::bail!("desktop_watch_files timed out for {url}");
    }

    let watch_v: serde_json::Value = serde_json::from_slice(&watch_out.stdout)?;
    let landed = watch_v.get("files")
        .and_then(|f| f.as_array())
        .and_then(|a| a.first())
        .and_then(|f| f.get("path"))
        .and_then(|p| p.as_str())
        .ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file path"))?;

    println!("OK: refetch landed {landed}");
    let pulled = pup::pull_file(landed, dir.parent().unwrap_or(&dir).to_str().unwrap())?;
    let report = import::import_path(&pulled, Some(mpn))?;
    println!("imported into {} (+{} files)", report.dest_dir.display(), report.files_added.len());

    let _ = sourcing::append_chain(mpn, source_enum, "OK",
        Some(&format!("refetched {format} via refetch_cmd, imported {} files", report.files_added.len())));
    Ok(())
}

/// Map (source, MPN) → canonical URL. Manufacturer uses the prefix table
/// from sourcing::guess_mfr (so it's the same URL as `adom-chip-fetcher
/// mfr-probe`); third-party sources have hardcoded search URL templates.
fn canonical_source_url(mpn: &str, source: sourcing::Source) -> Result<String> {
    let enc = mpn.replace(' ', "%20");
    Ok(match source {
        // The wiki is pulled via its API (sources::wiki), not by opening a vendor
        // page in pup — this is just the human search URL for completeness.
        sourcing::Source::AdomWiki => format!("https://wiki.adom.inc/search?q={enc}"),
        sourcing::Source::Manufacturer => {
            sourcing::guess_mfr(mpn)
                .map(|g| g.search_url)
                .ok_or_else(|| anyhow::anyhow!("could not infer manufacturer for {mpn}; pass an explicit URL via --source mouser/digikey/etc."))?
        }
        sourcing::Source::SnapMagic => format!("https://www.snapeda.com/search/?q={enc}&active=part"),
        sourcing::Source::Mouser    => format!("https://www.mouser.com/c/?q={enc}"),
        sourcing::Source::DigiKey   => format!("https://www.digikey.com/en/products/result?keywords={enc}"),
        sourcing::Source::Arrow     => format!("https://www.arrow.com/en/categories/connectors?q={enc}"),
        sourcing::Source::CseUl     => format!("https://componentsearchengine.com/search/{enc}"),
        sourcing::Source::Lcsc      => format!("https://www.lcsc.com/search?q={enc}"),
    })
}

/// Validate driver. Resolves MPN → chip directory under library/<MPN>/,
/// runs the ds2sf → concur loop, prints the outcome. Returns non-zero
/// only on hard error or when the loop ends in NeedsHuman /
/// Ds2sfUnrecoverable — so a CI-style caller can `&& adom-chip-fetcher
/// validate <MPN>` and trust the exit code.
fn validate_cmd(mpn: &str) -> Result<()> {
    let dir = library::root().join(mpn);
    if !dir.is_dir() {
        anyhow::bail!("no chip directory at {} — run adom-chip-fetcher first", dir.display());
    }
    let outcome = validate::validate_chip_dir(&dir)?;
    validate::print_outcome(&outcome);
    match outcome {
        validate::ValidationOutcome::Golden { .. } |
        validate::ValidationOutcome::GoldenWithVariants { .. } => Ok(()),
        validate::ValidationOutcome::NeedsHuman { reason, .. } => {
            anyhow::bail!("validation needs human review: {reason}");
        }
        validate::ValidationOutcome::Ds2sfUnrecoverable { summary, .. } => {
            anyhow::bail!("ds2sf unrecoverable: {}", summary.unwrap_or_default());
        }
    }
}

/// Manufacturer-probe. Detects the manufacturer from an MPN prefix and
/// opens their canonical product/search page in the adom-chip-fetcher-scrape
/// pup session. Records the attempt on info.json.fetched_via_chain so
/// next-source knows manufacturer has been tried. AI must run this
/// BEFORE any aggregator — that's the rule, now enforced.
fn mfr_probe_cmd(mpn: &str, no_open: bool) -> Result<()> {
    let guess = sourcing::guess_mfr(mpn);
    match guess {
        Some(g) => {
            println!("OK: detected manufacturer {} for {mpn}", g.manufacturer);
            println!("URL: {}", g.search_url);
            // Record the attempt as a navigation event (no outcome yet — the
            // AI fills in the outcome when it heartbeats success/empty).
            let _ = sourcing::append_chain(mpn, sourcing::Source::Manufacturer,
                "attempted",
                Some(&format!("auto-probe → {}", g.manufacturer)));
            if !no_open {
                // Manufacturer-first source opens as a TAB in the ONE adom-chip-fetcher
                // window (beside the dashboard tab), via the native browser when
                // available so ST/TI/Nordic logins + trust apply.
                match pup::open_site(SESSION, PROFILE, &g.search_url) {
                    Ok(_) => println!("OK: opened {} in the adom-chip-fetcher window ({})", g.search_url, pup::surface_label()),
                    Err(e) => hints::print_hint(&format!("could not auto-open ({e}) — open the URL above manually")),
                }
            }
            hints::print_hint(&format!("walk the page (Documentation, Tools & Software, CAD icon, Resources). When you decide whether the mfr has the CAD or not, heartbeat with `adom-chip-fetcher heartbeat {mpn} \"manufacturer: <result>\"` so the chain auto-updates."));
            Ok(())
        }
        None => {
            println!("WARN: could not infer manufacturer for {mpn} from prefix table.");
            hints::print_hint(&format!("identify the manufacturer manually (datasheet header, distributor product page) and open <mfr>.com directly. Then heartbeat: `adom-chip-fetcher heartbeat {mpn} \"probing <vendor>.com\"` so the chain records it."));
            Ok(())
        }
    }
}

/// Print the next ladder step that hasn't been tried, with the canonical
/// search URL where applicable. Reads info.json.fetched_via_chain and
/// applies the strict order rule (Mfr → SnapMagic → Mouser → DigiKey →
/// Arrow → CSE/UL → LCSC). LCSC is only suggested when every prior step
/// has a recorded failure outcome.
fn next_source_cmd(mpn: &str) -> Result<()> {
    let chain = sourcing::read_chain(mpn)?;
    println!("Sourcing chain so far for {mpn} ({} entries):", chain.len());
    for e in &chain {
        let note = e.note.as_deref().unwrap_or("");
        println!("  {} → {} {}", e.source, e.outcome, if note.is_empty() { "" } else { "—" });
        if !note.is_empty() { println!("    {note}"); }
    }
    println!();
    match sourcing::next_source(mpn)? {
        Some(s) => {
            println!("NEXT: try {} next.", s.as_slug());
            if matches!(s, sourcing::Source::Manufacturer) {
                hints::print_hint(&format!("`adom-chip-fetcher mfr-probe {mpn}` will detect the manufacturer + open their page in pup."));
            } else if matches!(s, sourcing::Source::Lcsc) {
                hints::print_hint(&format!("LCSC is the LAST step — only fall here when every prior step has a recorded failure. Surface 'no manufacturer CAD found' to the user before pulling LCSC."));
            } else {
                hints::print_hint(&format!("heartbeat `adom-chip-fetcher heartbeat {mpn} \"trying {}\"` before navigating, then heartbeat the outcome (\"empty\" / \"OK\" / \"404\") so next-source advances correctly.", s.as_slug()));
            }
        }
        None => {
            println!("NEXT: ladder fully exhausted for {mpn}. No more steps to try.");
            hints::print_hint("if the chip is still incomplete, surface 'no CAD available anywhere' to the user — don't claim done.");
        }
    }
    Ok(())
}

/// Hard gate. Walks the library, computes each chip's completeness against
/// the sourcing-ladder rule, prints HINT lines per missing artifact, and
/// returns non-zero exit when any card is incomplete. The AI cannot honestly
/// claim "done" without this returning zero — that's the point.
fn backfill_provenance_cmd(mpn_filter: Option<&str>) -> Result<()> {
    let lib = inventory::scan()?;
    let mut fixed = 0usize;
    let mut still_unknown: Vec<(String, Vec<String>)> = Vec::new();
    for c in &lib.chips {
        if mpn_filter.is_some_and(|m| c.mpn != m) { continue; }
        let dir = library::root().join(&c.mpn);
        let (recovered, unknown) = crate::import::backfill_dir(&dir, &c.mpn)?;
        if !recovered.is_empty() {
            fixed += 1;
            println!("  {} → recovered source for: {}", c.mpn, recovered.join(", "));
        }
        if !unknown.is_empty() {
            still_unknown.push((c.mpn.clone(), unknown));
        }
    }
    println!();
    println!("backfill: recovered provenance on {fixed} chip(s).");
    if !still_unknown.is_empty() {
        println!();
        println!("Source genuinely UNRECORDED (no signal to derive from — refetch to capture):");
        for (mpn, files) in &still_unknown {
            println!("  {mpn}: {}", files.join(", "));
        }
    }
    Ok(())
}

fn gate_cmd(strict: bool, mpn_filter: Option<&str>) -> Result<()> {
    let lib = inventory::scan()?;
    let chips: Vec<_> = lib.chips.into_iter()
        .filter(|c| mpn_filter.is_none_or(|m| c.mpn == m))
        .collect();
    if chips.is_empty() {
        if let Some(m) = mpn_filter {
            anyhow::bail!("no chip in library named {m:?}");
        }
        println!("library is empty.");
        return Ok(());
    }

    let mut incomplete: Vec<(String, Vec<String>)> = Vec::new();
    let mut complete = 0usize;

    for c in &chips {
        let dir = library::root().join(&c.mpn);
        let pdf = dir.join(format!("{}.pdf", c.mpn));
        let mut missing: Vec<String> = Vec::new();
        if !c.has_pdf { missing.push("datasheet.pdf".into()); }
        if !c.has_step { missing.push("STEP".into()); }
        if !c.has_sym { missing.push("KiCad .kicad_sym".into()); }
        if !c.has_mod { missing.push("KiCad .kicad_mod".into()); }
        // PERMANENT GUARANTEE 1 — the datasheet must be REAL (multi-page + pinout),
        // not a 1-page denial page that silently breaks ds2sf.
        if c.has_pdf {
            if let Err(reason) = crate::import::datasheet_ready(&pdf) {
                missing.push(format!("GOOD datasheet — {reason}"));
            }
        }
        // PERMANENT GUARANTEE 2 — ds2sf (the most authoritative source: the
        // manufacturer datasheet extraction) must have RUN and SUCCEEDED.
        let ds_ok = std::fs::read_to_string(dir.join(format!("{}-extraction.result.json", c.mpn))).ok()
            .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
            .and_then(|r| r.get("status").and_then(|x| x.as_str()).map(|s| s == "ok"))
            .unwrap_or(false);
        if !ds_ok { missing.push("ds2sf extraction (symbol+footprint from the datasheet)".into()); }
        if strict {
            if !c.has_altium     { missing.push("Altium (.SchLib+.PcbLib OR .IntLib)".into()); }
            if !c.has_fusion_lbr { missing.push("Fusion .lbr".into()); }
            // Pin-1 alignment: the 3D chip's pin-1 must be lined up to the
            // footprint's pin-1 and signed off in chipsmith (writes
            // <mpn>.chipsmith.json). Only required once both a STEP + footprint
            // exist (you can't align without both).
            if c.has_step && c.has_mod && !c.pin1_signed {
                missing.push("pin-1 alignment (open chipsmith → Align pin-1 → sign off)".into());
            }
        }
        // A pin-1 corner MISMATCH (signed chip corner != the footprint's pin-1
        // corner) means the 3D model is misaligned to the footprint — a real
        // defect, so flag it in BOTH modes, not just strict.
        if let (true, Some(cc), Some(fp)) = (c.pin1_signed, c.pin1_corner.as_deref(), c.fp_pin1_corner.as_deref()) {
            if cc != fp {
                missing.push(format!("pin-1 MISMATCH: chip {cc} vs footprint {fp} (re-align in chipsmith)"));
            }
        }
        // PERMANENT GUARANTEE 3 — every file we ship must know where it came
        // from. A file present on disk with no recorded source is a lazy fetch.
        // `backfill-provenance` recovers what it can; whatever's left is real.
        let unprov = crate::import::unprovenanced_files(&dir, &c.mpn);
        if !unprov.is_empty() {
            missing.push(format!("provenance for {} (run `backfill-provenance`, then refetch any still unknown)", unprov.join(", ")));
        }
        if missing.is_empty() {
            complete += 1;
        } else {
            incomplete.push((c.mpn.clone(), missing));
        }
    }

    let total = chips.len();
    println!("GATE: {complete}/{total} cards complete{}", if strict { " (STRICT)" } else { "" });
    println!();

    if !incomplete.is_empty() {
        println!("Incomplete cards — DO NOT claim done while these exist:");
        for (mpn, missing) in &incomplete {
            println!("  {mpn}: missing {}", missing.join(", "));
        }
        println!();
        for (mpn, missing) in &incomplete {
            for m in missing {
                hints::print_hint(&format!("{mpn} → fetch {m} (see `adom-chip-fetcher sources` for the ladder, then `adom-chip-fetcher heartbeat {mpn} \"<stage>\"` per stage)"));
            }
        }
        println!();
        hints::print_hint(&format!("re-run `adom-chip-fetcher gate{}` after each round of work — it returns 0 only when every card is complete.",
            if strict { " --strict" } else { "" }));
        anyhow::bail!("gate FAILED: {} of {total} cards incomplete", incomplete.len());
    }

    println!("OK: all {total} cards complete{}.", if strict { " (STRICT)" } else { "" });
    Ok(())
}

fn sources_cmd() -> Result<()> {
    // Single source of truth for the sourcing ladder. Both this command and
    // hints::sourcing_ladder_lines() read from here, so playbook + binary stay
    // in sync. AI consumers can call `adom-chip-fetcher sources` and parse the
    // ordered list — no need to re-read the playbook prose.
    println!("Sourcing-ladder priority (always go in order, never skip):\n");
    for (i, (name, note)) in hints::SOURCE_LADDER.iter().enumerate() {
        println!("  {}. {:<14}  {}", i + 1, name, note);
    }
    println!();
    hints::print_hint("LCSC is LAST RESORT — avoid if possible. Surface 'no manufacturer CAD' to user before reaching for LCSC.");
    hints::print_hint("Stock data (stock.json) comes from Mouser regardless of where CAD/PDFs land.");
    Ok(())
}

fn hint_cmd() -> Result<()> {
    match hints::last() {
        None => {
            hints::print_hint("no heartbeat yet — kick off with `adom-chip-fetcher heartbeat <MPN> \"starting fetch\"`");
            hints::print_hint("then `adom-chip-fetcher selfcheck` to verify dashboard + pup session.");
        }
        Some(hb) => {
            let age = hints::age_secs(&hb);
            println!("last heartbeat: {} → {:?} ({}s ago)", hb.mpn, hb.stage, age);
            if age > 60 {
                hints::print_hint(&format!(
                    "stale heartbeat — refresh: `adom-chip-fetcher heartbeat {} \"<current stage>\"`",
                    hb.mpn
                ));
            } else {
                // If the stage is one of our canonical post-action labels, suggest
                // the next concrete command. Otherwise fall back to a generic prompt
                // so this is never silent — the AI always gets a next step.
                let canonical = matches!(hb.stage.as_str(),
                    "fetched" | "imported" | "pulled" | "listed" | "status" | "login");
                if canonical {
                    hints::print_next_after(&hb.stage, Some(&hb.mpn));
                } else {
                    hints::print_hint(&format!(
                        "heartbeat the next stage transition: `adom-chip-fetcher heartbeat {} \"<next stage>\"`",
                        hb.mpn
                    ));
                    hints::print_hint(&format!(
                        "after import lands: `adom-chip-fetcher heartbeat {} imported` then `adom-chip-fetcher status {}`",
                        hb.mpn, hb.mpn
                    ));
                    hints::print_hint("if anything is off-rails: `adom-chip-fetcher selfcheck`");
                }
            }
        }
    }
    Ok(())
}

fn app_cmd(port: u16) -> Result<()> {
    use std::sync::mpsc;
    let (tx, rx) = mpsc::channel::<()>();
    let handle = std::thread::spawn(move || {
        // Signal "ready" once we're about to bind. The bind itself is fast.
        let _ = tx.send(());
        let _ = serve::run(port);
    });
    let _ = rx.recv();
    std::thread::sleep(std::time::Duration::from_millis(300));
    serve::open_in_hydrogen(port)?;
    let _ = handle.join();
    Ok(())
}

fn install_cmd() -> Result<()> {
    let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME not set"))?;
    let skill_dir = std::path::PathBuf::from(home).join(".claude/skills/adom-chip-fetcher");
    std::fs::create_dir_all(&skill_dir)?;
    let skill_path = skill_dir.join("SKILL.md");
    std::fs::write(&skill_path, SKILL_MD)?;
    println!("OK: adom-chip-fetcher SKILL.md deployed to {}", skill_path.display());
    println!("    {} bytes, {} lines", SKILL_MD.len(), SKILL_MD.lines().count());

    // Deploy playbooks/ alongside SKILL.md. Each playbook is a focused deep-dive Claude
    // reads on demand — keeping SKILL.md lean enough to survive context-window compaction.
    let playbooks_dir = skill_dir.join("playbooks");
    std::fs::create_dir_all(&playbooks_dir)?;
    let mut total_bytes: usize = 0;
    let mut total_lines: usize = 0;
    for (name, body) in PLAYBOOKS {
        let pb_path = playbooks_dir.join(name);
        std::fs::write(&pb_path, body)?;
        total_bytes += body.len();
        total_lines += body.lines().count();
    }
    println!(
        "OK: {} playbook(s) deployed to {}",
        PLAYBOOKS.len(),
        playbooks_dir.display()
    );
    println!("    {total_bytes} bytes, {total_lines} lines across all playbooks");

    println!();
    println!("Try `adom-chip-fetcher --help` to see commands.");
    Ok(())
}

fn pull_cmd(remote_path: &str, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {
    let incoming = library::root().parent().map(|p| p.join("incoming")).unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
    std::fs::create_dir_all(&incoming)?;
    println!("pulling {remote_path} → {}", incoming.display());
    let local = pup::pull_file(remote_path, incoming.to_str().unwrap())?;
    println!("pulled to {}", local.display());
    let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {
        Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })
    } else { None };
    let report = import::import_path_with_provenance(&local, mpn, prov.as_ref())?;
    println!("imported into {}", report.dest_dir.display());
    for f in &report.files_added { println!("  + {f}"); }
    if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {
        let _ = hints::heartbeat(m, "imported");
        // Validate-then-thumbnail order — ds2sf may patch info.json.package
        // (e.g. VQFN-32 → VQFN-40), and thumbnails should reflect the
        // corrected metadata, not the pre-patch one.
        if !no_validate {
            run_post_import_validate(&report.dest_dir, m);
        }
        auto_render_thumbnails(m);
        hints::print_next_after("pulled", Some(m));
    }
    Ok(())
}

fn import_cmd(path: &std::path::Path, mpn: Option<&str>, no_validate: bool, discovery_source: &str, content_origin: &str, source_url: &str) -> Result<()> {
    let prov = if discovery_source != "unknown" || content_origin != "unknown" || !source_url.is_empty() {
        Some(import::FileProvenance { discovery_source: discovery_source.to_string(), content_origin: content_origin.to_string(), url: source_url.to_string() })
    } else { None };
    let report = import::import_path_with_provenance(path, mpn, prov.as_ref())?;
    println!("imported into {}", report.dest_dir.display());
    for f in &report.files_added {
        println!("  + {f}");
    }
    if report.files_added.is_empty() {
        println!("  (no recognized files found in {})", path.display());
    }
    if let Some(m) = mpn.or_else(|| report.dest_dir.file_name().and_then(|s| s.to_str())) {
        let _ = hints::heartbeat(m, "imported");
        if !no_validate {
            run_post_import_validate(&report.dest_dir, m);
        }
        auto_render_thumbnails(m);
        hints::print_next_after("imported", Some(m));
    }
    Ok(())
}

/// Drive the validation pipeline (ds2sf + concur with autonomous-repair
/// loop) after a fresh file lands. Best-effort — failures here surface as
/// WARN; they don't unwind the import. The `--no-validate` flag bypasses
/// this for cases where the user is dropping individual files mid-fetch.
fn run_post_import_validate(dir: &std::path::Path, mpn: &str) {
    println!();
    println!("running validation (ds2sf + concur) — gold-dataset gate");
    match validate::validate_chip_dir(dir) {
        Ok(outcome) => validate::print_outcome(&outcome),
        Err(e) => println!("WARN: validate failed for {mpn}: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),
    }
}

/// Best-effort thumbnail re-render for one MPN immediately after import.
/// Errors are swallowed (printed as WARN) — the import already succeeded;
/// thumbnails are a UX nicety, not a correctness gate.
fn auto_render_thumbnails(mpn: &str) {
    match thumbnails::generate_for(mpn, false) {
        Ok(r) if !r.generated.is_empty() => {
            println!("  thumbnails: +{}", r.generated.join(" +"));
            let _ = hints::heartbeat(mpn, "thumbnails ready");
        }
        Ok(_) => {} // nothing changed
        Err(e) => println!("WARN: thumbnail render failed for {mpn}: {e}"),
    }
}

/// Ladder step 0 in isolation: query the Adom Wiki and reuse a published bundle
/// if one exists. Falls through (prints a clear message) when there's no match,
/// so it doubles as the fall-through test without touching the vendor ladder.
fn wiki_fetch_cmd(mpn: &str) -> Result<()> {
    library::ensure_dir(mpn)?;
    match crate::sources::wiki::try_fetch(mpn)? {
        Some(pull) => {
            println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);
            for f in &pull.files_added {
                println!("  + {f}");
            }
            let _ = thumbnails::generate_for(mpn, true);
            println!("\nReused from the Adom Wiki — no external fetch needed.");
        }
        None => {
            println!("Adom Wiki: no published bundle for {mpn}.");
            hints::print_hint(&format!("not on the wiki — run `adom-chip-fetcher fetch {mpn}` to walk the vendor ladder (manufacturer → SnapMagic → …)."));
        }
    }
    Ok(())
}

fn fetch(mpn: &str) -> Result<()> {
    library::ensure_dir(mpn)?;

    // Ladder step 0 — the Adom Wiki. If this part is already published with a
    // complete bundle, reuse it (free, fast, Adom-consistent) and skip the
    // external vendors entirely. Reads only; no token. On no match (or only a
    // metadata-only page with no CAD files) we fall straight through to the
    // vendor ladder below — the existing ladder is untouched.
    match crate::sources::wiki::try_fetch(mpn) {
        Ok(Some(pull)) => {
            println!("OK: reused Adom Wiki bundle ({} files) from {}", pull.files_added.len(), pull.url);
            for f in &pull.files_added {
                println!("  + {f}");
            }
            let _ = hints::heartbeat(mpn, "imported (Adom Wiki)");
            let _ = thumbnails::generate_for(mpn, true);
            println!("\nReused from the Adom Wiki — no external fetch needed. Run `adom-chip-fetcher gate --mpn {mpn}` to confirm completeness.");
            return Ok(());
        }
        Ok(None) => println!("Adom Wiki: no published bundle for {mpn} — falling through to the vendor ladder."),
        Err(e) => println!("WARN: Adom Wiki lookup failed ({e}) — falling through to the vendor ladder."),
    }

    // Rail: dashboard MUST be visible before a fetch starts. Auto-open if not.
    // Best-effort — a failure here doesn't block the fetch (user might be
    // running headless), but they'll see a WARN.
    if let Err(e) = hints::dashboard_ensure(false) {
        println!("WARN: dashboard_ensure failed: {e}");
    }
    let _ = hints::heartbeat(mpn, "starting fetch");

    let url = sources::ti::cad_url(mpn);
    println!("opening {url}");
    pup::open_window(SESSION, PROFILE, &url)?;

    std::thread::sleep(std::time::Duration::from_secs(3));
    let title_v = pup::eval(SESSION, "document.title")?;
    let title = title_v.as_str().unwrap_or("(unknown)");
    println!("page title: {title}");

    println!("\nstep 1: on the TI page, click \"CAD/CAE Symbols & Footprints\" → opens UL with the part loaded");
    println!("step 2: in UL — pick variant, check STEP + KiCAD v6+, set units to mm, click Submit");
    println!("step 3: adom-chip-fetcher will auto-pull the download (up to 5 min wait)");
    println!();

    let local = match wait_for_download() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("auto-pull failed: {e}");
            eprintln!("fallback: drag the zip to adom-chip-fetcher/incoming/ and run `adom-chip-fetcher import`");
            return Err(e);
        }
    };

    println!("pulled to {}", local.display());
    // Derive provenance from the last chain entry. The fetch function
    // currently drives TI/UL, but the chain records what actually happened.
    let last_source = crate::sourcing::last_chain_source(mpn).unwrap_or_else(|| "unknown".to_string());
    let prov = import::FileProvenance {
        discovery_source: last_source.clone(),
        content_origin: last_source,
        url: String::new(),
    };
    let report = import::import_path_with_provenance(&local, Some(mpn), Some(&prov))?;
    println!("imported into {}", report.dest_dir.display());
    for f in &report.files_added { println!("  + {f}"); }
    let _ = hints::heartbeat(mpn, "imported");

    // Validate FIRST, thumbnails SECOND. ds2sf may patch info.json (e.g.
    // package family correction VQFN-32 → VQFN-40), and the thumbnail
    // render reads info.json for orientation hints + label fields — so
    // thumbnails should run against the patched metadata, not the
    // pre-patch one. Closes gap #3 from the integration design doc.
    println!();
    println!("running validation (ds2sf + concur) — this is the agent-to-agent gate downstream tools rely on");
    let dir = library::root().join(mpn);
    match validate::validate_chip_dir(&dir) {
        Ok(outcome) => validate::print_outcome(&outcome),
        Err(e) => println!("WARN: validate failed: {e} — re-run with `adom-chip-fetcher validate {mpn}`"),
    }

    auto_render_thumbnails(mpn);
    hints::print_next_after("fetched", Some(mpn));
    Ok(())
}

/// Block until a fresh `ul_*.zip` lands in the user's Windows Downloads folder,
/// then pull it to the container's `incoming/`. Uses adom-desktop v1.5+'s
/// native `desktop_watch_files` — no shell, no PowerShell, no user-approval
/// dialog. Times out after 5 minutes.
fn wait_for_download() -> Result<std::path::PathBuf> {
    println!("watching desktop ~/Downloads for ul_*.zip (up to 5 min)...");
    let v = pup::desktop_watch_files(
        "%USERPROFILE%\\Downloads",
        "ul_*.zip",
        300_000, // 5 min in ms
    )?;
    let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false);
    if !ok {
        let hint = v.get("_hint").and_then(|s| s.as_str()).unwrap_or("");
        anyhow::bail!("desktop_watch_files timed out: {hint}");
    }
    let remote = v
        .get("file")
        .and_then(|f| f.get("path"))
        .and_then(|p| p.as_str())
        .ok_or_else(|| anyhow::anyhow!("desktop_watch_files returned no file.path: {v}"))?
        .to_string();
    println!("desktop has {remote}");
    let incoming = library::root()
        .parent()
        .map(|p| p.join("incoming"))
        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
    std::fs::create_dir_all(&incoming)?;
    pup::pull_file(&remote, incoming.to_str().unwrap())
}

const SOURCES: &[(&str, &str, &str)] = &[
    ("google",      "Google account (covers all Sign-in-with-Google flows)", "https://accounts.google.com/signin"),
    ("ti",          "Texas Instruments",                                     "https://www.ti.com/securemyprofile/login"),
    ("st",          "STMicroelectronics",                                    "https://my.st.com/cas/login"),
    ("microchip",   "Microchip",                                             "https://www.microchip.com/en-us/my-account-sign-in"),
    ("ultralib",    "Ultra Librarian",                                       "https://app.ultralibrarian.com/account/login"),
    ("snapeda",     "SnapMagic / SnapEDA",                                   "https://www.snapmagic.com/account/login"),
    ("componentsearch", "Component Search Engine (samacsys)",                "https://componentsearchengine.com/account/login"),
];

fn login(source: &str) -> Result<()> {
    if source == "all" {
        println!("opening login pages for all sources as tabs in profile '{PROFILE}'");
        for (name, _, url) in SOURCES {
            println!("  → {name}: {url}");
            // First call ensures the window exists; subsequent calls add tabs
            let _ = pup::open_tab(SESSION, url);
            // Fall back to open_window if the session doesn't exist yet
            pup::open_window(SESSION, PROFILE, url)?;
        }
        println!("\nlog in to each, then 'Stay signed in' / Smart Lock will keep cookies for ~30 days.");
        return Ok(());
    }
    if source == "list" {
        println!("known sources:");
        for (name, desc, url) in SOURCES {
            println!("  {name:<18}  {desc}");
            println!("  {:<18}  {url}", "");
        }
        return Ok(());
    }
    let entry = SOURCES.iter().find(|(n, _, _)| {
        *n == source
            || (source == "ultralibrarian" && *n == "ultralib")
            || (source == "snapmagic" && *n == "snapeda")
            || (source == "samacsys" && *n == "componentsearch")
    });
    let url = match entry {
        Some((_, _, u)) => *u,
        None => anyhow::bail!(
            "unknown source '{source}' — run `adom-chip-fetcher login list` to see available sources, or `adom-chip-fetcher login all` to open every source"
        ),
    };
    println!("opening {url} for login — cookies will persist in profile '{PROFILE}'");
    pup::open_window(SESSION, PROFILE, url)?;
    hints::print_next_after("login", None);
    Ok(())
}

fn list() -> Result<()> {
    let entries = library::scan()?;
    if entries.is_empty() {
        println!("library is empty ({})", library::root().display());
        hints::print_hint("kick off a first fetch: `adom-chip-fetcher fetch <MPN>`");
        return Ok(());
    }
    println!("{} parts in {}", entries.len(), library::root().display());
    for e in entries {
        println!("  {:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));
    }
    hints::print_next_after("listed", None);
    Ok(())
}

fn status(mpn: &str) -> Result<()> {
    let e = library::status(mpn)?;
    println!("{:<24} step={} mod={} sym={} pdf={}", e.mpn, mark(e.has_step), mark(e.has_mod), mark(e.has_sym), mark(e.has_pdf));
    hints::print_next_after("status", Some(mpn));
    Ok(())
}

fn mark(present: bool) -> &'static str {
    if present { "✓" } else { "·" }
}