123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
use crate::client::{self, ConvertResult, ThumbnailResult};
use crate::ui::{err, hint, ok, warn, CYAN, DIM, RESET};
use crate::{note};
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;

/// step2glb — STEP → GLB converter client.
///
/// Forwards conversion requests to the shared service-step2glb container
/// (heavy OCCT runtime lives there; this CLI stays ~3 MB). `preview` opens
/// a Hydrogen webview so humans can see the converted model alongside the
/// AI-readable JSON.
#[derive(Parser, Debug)]
#[command(name = "step2glb", version, about, long_about = None,
    after_help = "\
IDENTITY HEADERS (required by service-step2glb):
  Every POST to the service requires X-Client and X-Job-Name headers.
  This CLI sends them automatically:
    X-Client:   step2glb-cli/<version>  (override with STEP2GLB_CLIENT env var)
    X-Job-Name: auto-generated from the command + filename

  If you're calling the service directly (curl, Python, etc.), you must set both:
    X-Client:   <tool-name>/<user-or-container>  e.g. adom-tsci/john-dev
    X-Job-Name: <endpoint>-<what>-<part>-<context>
                e.g. convert-usbc-connector-type-c-31-m-12-to-glb
                     thumbnail-iso-vl53l8cx-for-library
                     features-extract-bme688-pin-detection

ENVIRONMENT:
  STEP2GLB_SERVICE_API   Override service URL (default: shared Adom container)
  STEP2GLB_CLIENT        Override X-Client identity (default: step2glb-cli/<version>)
")]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
pub enum Command {
    /// Reachability + version check against the shared service.
    Health,
    /// Print the service URL this CLI will hit.
    Config,
    /// Convert a local STEP file → GLB.
    Convert(ConvertArgs),
    /// Fetch a STEP from service-kicad by "<Library>/<Name>" and convert.
    FromLibrary(FromLibraryArgs),
    /// Extract geometric features (bbox, floor_z, longest_axis, pin1) from a local STEP without converting.
    Features(FeaturesArgs),
    /// Render a PNG thumbnail of a local STEP (server-side via pyrender; iso pose by default).
    Thumbnail(ThumbnailArgs),
    /// Render multiple (orientation × size) PNG thumbnails of a local STEP in
    /// a single service call. Loads the STEP once on the server and renders
    /// every requested variant into one V3d session — ~5–10× faster than
    /// looping `step2glb thumbnail` once per variant.
    ThumbnailBatch(ThumbnailBatchArgs),
    /// Convert + open a Hydrogen webview so a human can verify the result.
    Preview(PreviewArgs),
    /// Open a Hydrogen webview thumb-playground — drag-drop a .step file,
    /// see all 6 orientations × 3 sizes populate. Drag-drop UI for one-off
    /// renders; tool integrations should hit /thumbnail-batch directly.
    ThumbPlayground(crate::playground::PlaygroundArgs),
    /// Install SKILL.md + bash completions (fetches fresh SKILL.md from the wiki).
    Install(InstallArgs),
    /// Print shell completions to stdout: `step2glb completions bash`
    Completions(CompletionsArgs),
}

#[derive(Args, Debug)]
pub struct ConvertArgs {
    /// Input .step / .stp file.
    pub input: PathBuf,
    /// Output .glb path. Defaults to <input stem>.glb beside the input.
    #[arg(short, long)]
    pub out: Option<PathBuf>,
    /// Molecule mode: MP-marker anchoring (Z-up, front-left pin at origin),
    /// gold machine pins, and - with --board - footprint + symbol JSON emit.
    #[arg(long)]
    pub molecule: bool,
    /// Board file (.kicad_pcb or .brd) for footprint + symbol generation.
    /// Implies --molecule. Written beside the GLB as <stem>_footprint.json /
    /// <stem>_symbol.json.
    #[arg(long)]
    pub board: Option<PathBuf>,
    /// Machine-pin size (medium | large). The service auto-corrects from the
    /// marker names when they carry a size (pin_size_source: markers).
    #[arg(long, default_value = "medium")]
    pub pin: String,
    /// Top silkscreen PNG overlay (white artwork on transparent).
    #[arg(long)]
    pub silk_top: Option<PathBuf>,
    /// Bottom silkscreen PNG overlay.
    #[arg(long)]
    pub silk_bottom: Option<PathBuf>,
}

#[derive(Args, Debug)]
pub struct FromLibraryArgs {
    /// "<Library>/<Name>" — e.g. `Package_TO_SOT_SMD/SOT-89-3`
    pub path: String,
    /// Output .glb. Defaults to /tmp/step2glb-<name>.glb.
    #[arg(short, long)]
    pub out: Option<PathBuf>,
}

#[derive(Args, Debug)]
pub struct FeaturesArgs {
    /// Input .step / .stp file.
    pub input: PathBuf,
    /// Output .json path. Defaults to <input stem>-features.json beside the input. Use `-` to print to stdout.
    #[arg(short, long)]
    pub out: Option<PathBuf>,
}

#[derive(Args, Debug)]
pub struct ThumbnailArgs {
    /// Input .step / .stp / .glb file. Kind auto-detected from extension or magic bytes.
    pub input: PathBuf,
    /// Output .png path. Defaults to <input stem>-thumb.png beside the input.
    #[arg(short, long)]
    pub out: Option<PathBuf>,
    /// Image width in pixels (default 320 — thumbnail; pass 800+ for hero size).
    #[arg(long, default_value_t = 320)]
    pub width: u32,
    /// Image height in pixels (default 240 — thumbnail; pass 600+ for hero size).
    #[arg(long, default_value_t = 240)]
    pub height: u32,
    /// Camera pose: iso, iso2, top, front, side.
    #[arg(long, default_value = "iso")]
    pub pose: String,
    /// Force input kind: auto (default), step, or glb.
    #[arg(long, default_value = "auto")]
    pub kind: String,
    /// Pre-rotate the model before rendering. Six face-up variants:
    /// `asIs` (=`as-is`/`z`, default — source +Z up, STEP canonical),
    /// `zDown` (R_x(π) — chip flipped),
    /// `yUp` (=`y`, R_x(+π/2) — source +Y up, glTF / three.js),
    /// `yDown` (R_x(-π/2) — source -Y up, e.g. WAGO connectors),
    /// `xUp` (R_y(-π/2)), `xDown` (R_y(+π/2)).
    /// chip-thumbnailer renders all six per chip so a downstream picker
    /// can compare side-by-side. See gallia/skills/up-axis-conventions.
    #[arg(long, default_value = "asIs")]
    pub up_axis: String,
}

#[derive(Args, Debug)]
pub struct ThumbnailBatchArgs {
    /// Input .step / .stp file.
    pub input: PathBuf,
    /// Directory to write the rendered PNGs into. Defaults to `<input stem>-thumbs/`
    /// beside the input. Files are named `<stem>-3d-iso<orient><size>.png`
    /// to match chip-thumbnailer's canonical layout.
    #[arg(short, long)]
    pub out_dir: Option<PathBuf>,
    /// Comma-separated orientations. `all` (default) = asIs,zDown,yUp,yDown,xUp,xDown.
    #[arg(long, default_value = "all")]
    pub orientations: String,
    /// Comma-separated sizes. `all` (default) = sm,md,lg.
    #[arg(long, default_value = "all")]
    pub sizes: String,
    /// Camera pose. Default `iso`.
    #[arg(long, default_value = "iso")]
    pub pose: String,
}

#[derive(Args, Debug)]
pub struct PreviewArgs {
    /// Either a local .step path or "<Library>/<Name>" (same shape as from-library).
    pub source: String,
    /// Port for the local preview HTTP server. 0 = auto-pick a free port.
    #[arg(long, default_value_t = 0)]
    pub port: u16,
    /// Skip opening the Hydrogen webview tab (still prints the proxy URL).
    #[arg(long)]
    pub no_tab: bool,
}

#[derive(Args, Debug)]
pub struct InstallArgs {}

#[derive(Args, Debug)]
pub struct CompletionsArgs {
    #[arg(value_enum)]
    pub shell: ShellArg,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ShellArg { Bash, Zsh, Fish }

impl From<ShellArg> for clap_complete::Shell {
    fn from(s: ShellArg) -> Self {
        match s {
            ShellArg::Bash => clap_complete::Shell::Bash,
            ShellArg::Zsh  => clap_complete::Shell::Zsh,
            ShellArg::Fish => clap_complete::Shell::Fish,
        }
    }
}

// ────────────────────────────────────────────────────────────────────────
// command implementations
// ────────────────────────────────────────────────────────────────────────

pub fn health() {
    let url = client::service_url();
    match client::health() {
        Ok(h) => {
            ok(format!(
                "service-step2glb v{} reachable at {CYAN}{}{RESET} (kicad_base={CYAN}{}{RESET}, converts={}, uptime={}s)",
                h.version, url, h.kicad_base, h.converts, h.uptime_seconds
            ));
        }
        Err(e) => {
            err(format!("service-step2glb unreachable at {CYAN}{}{RESET}: {e}", url));
            hint("Check connectivity, or override via `export STEP2GLB_SERVICE_API=<url>`.");
            hint(format!("Current URL: `step2glb config` · Baked default: {}", client::DEFAULT_SERVICE_URL));
            std::process::exit(2);
        }
    }
}

pub fn config() {
    note!("{DIM}service URL:{RESET} {CYAN}{}{RESET}", client::service_url());
    note!("{DIM}  baked default:{RESET} {}", client::DEFAULT_SERVICE_URL);
    if std::env::var("STEP2GLB_SERVICE_API").is_ok() {
        note!("{DIM}  overridden by STEP2GLB_SERVICE_API env var{RESET}");
    }
}

pub fn convert_cmd(args: ConvertArgs) {
    if !args.input.exists() {
        err(format!("input file not found: {}", args.input.display()));
        hint(format!("Check the path exists: `ls {}`", args.input.parent().map(|p|p.display().to_string()).unwrap_or_else(||".".into())));
        std::process::exit(1);
    }
    let out = args.out.unwrap_or_else(|| {
        let mut p = args.input.clone();
        p.set_extension("glb");
        p
    });
    let bytes = match std::fs::read(&args.input) {
        Ok(b) => b,
        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
    };
    // Molecule mode (#99): --molecule or an attached --board routes through the
    // molecule pipeline (anchor, gold, footprint/symbol emit) instead of the
    // plain convert.
    if args.molecule || args.board.is_some() {
        let board = match &args.board {
            Some(p) => {
                let fname = p
                    .file_name()
                    .and_then(|s| s.to_str())
                    .unwrap_or("board.kicad_pcb")
                    .to_string();
                match std::fs::read(p) {
                    Ok(b) => Some((fname, b)),
                    Err(e) => {
                        err(format!("read board {}: {e}", p.display()));
                        std::process::exit(1);
                    }
                }
            }
            None => None,
        };
        let read_opt = |p: &Option<PathBuf>| -> Option<Vec<u8>> {
            p.as_ref().and_then(|p| std::fs::read(p).ok())
        };
        let opts = client::MoleculeOpts {
            board,
            pin: args.pin.clone(),
            silk_top: read_opt(&args.silk_top),
            silk_bottom: read_opt(&args.silk_bottom),
        };
        match client::convert_molecule(&bytes, &opts, &out) {
            Ok(r) => print_molecule_success(&r, &out, args.board.is_some()),
            Err(e) => {
                err(format!("molecule conversion failed: {e}"));
                hint("Run `step2glb health` to check the shared service is reachable.");
                std::process::exit(4);
            }
        }
        return;
    }

    match client::convert_bytes(&bytes, &out) {
        Ok(r) => print_success(&r, &out, Some(&args.input.display().to_string())),
        Err(e) => {
            err(format!("conversion failed: {e}"));
            hint("Run `step2glb health` to check the shared service is reachable.");
            std::process::exit(4);
        }
    }
}

/// Molecule-mode result block: the OK line, the stats that gate downstream
/// publishing (per the molecule-publish skill), the side-artifact paths, and
/// hints when a gate did not pass. Unknown molecule_* / footprint_* stats
/// print generically so new service fields surface without a CLI rebuild.
fn print_molecule_success(r: &client::MoleculeResult, out: &std::path::Path, board_given: bool) {
    let s = &r.stats;
    let anchored = s["molecule_anchored"].as_bool().unwrap_or(false);
    let fp_applied = s["footprint_applied"].as_bool().unwrap_or(false);

    ok(format!(
        "molecule GLB written to {} ({} KB, {} meshes, {} nodes)",
        out.display(),
        r.convert.size_bytes / 1024,
        r.convert.meshes,
        r.convert.nodes
    ));

    let show = |k: &str| {
        let v = &s[k];
        if !v.is_null() {
            crate::note!("  {k}: {v}");
        }
    };
    for k in [
        "molecule_anchored",
        "molecule_up_axis",
        "fl_marker",
        "pin_size",
        "pin_size_source",
        "molecule_origin_z",
        "measured_contact_z_mm",
        "gold_upgraded",
        "silkscreen_applied",
        "footprint_applied",
        "footprint_pins",
        "footprint_contacts",
        "contact_z",
        "contact_z_source",
    ] {
        show(k);
    }
    if let Some(p) = &r.footprint_path {
        crate::note!("  footprint: {}", p.display());
    }
    if let Some(p) = &r.symbol_path {
        crate::note!("  symbol: {}", p.display());
    }
    if let Some(warns) = s["warnings"].as_array() {
        for w in warns {
            warn(w.as_str().unwrap_or(&w.to_string()).to_string());
        }
    }

    // Gate hints (the molecule-publish skill treats these as stop-signs).
    if !anchored {
        hint("NOT ANCHORED - the board is missing MP1-MP4 machine-pin markers (or they did not survive the STEP export). Fix the board and re-export before publishing.");
    }
    if board_given && !fp_applied {
        hint("Board was attached but footprint generation did not apply - check the warnings above before publishing.");
    }
    if !board_given {
        hint("No --board attached: footprint + symbol were not generated. Wires cannot attach in Hydrogen without a footprint - re-run with --board <file.kicad_pcb|.brd> before publishing.");
    }
}

pub fn from_library_cmd(args: FromLibraryArgs) {
    let out = args.out.unwrap_or_else(|| {
        let leaf = args.path.rsplit('/').next().unwrap_or("part");
        PathBuf::from(format!("/tmp/step2glb-{leaf}.glb"))
    });
    if !args.path.contains('/') {
        err(format!("path must be `<Library>/<Name>`, got `{}`", args.path));
        hint("Example: `step2glb from-library Package_TO_SOT_SMD/SOT-89-3`");
        std::process::exit(1);
    }
    match client::convert_from_library(&args.path, &out) {
        Ok(r) => print_success(&r, &out, Some(&args.path)),
        Err(e) => {
            err(format!("conversion failed: {e}"));
            if format!("{e}").contains("502") || format!("{e}").contains("404") {
                hint(format!("Is `{}` a real library part? Check service-kicad: `service-kicad model fetch <lib>/<name>.step`", args.path));
            } else {
                hint("Run `step2glb health` to check the shared service is reachable.");
            }
            std::process::exit(4);
        }
    }
}

pub fn features_cmd(args: FeaturesArgs) {
    if !args.input.exists() {
        err(format!("input file not found: {}", args.input.display()));
        std::process::exit(1);
    }
    let bytes = match std::fs::read(&args.input) {
        Ok(b) => b,
        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
    };
    let json_str = match client::extract_features(&bytes) {
        Ok(s) => s,
        Err(e) => {
            err(format!("feature extraction failed: {e}"));
            hint("Run `step2glb health` to check the shared service is reachable.");
            std::process::exit(4);
        }
    };
    let pretty = serde_json::from_str::<serde_json::Value>(&json_str)
        .ok()
        .and_then(|v| serde_json::to_string_pretty(&v).ok())
        .unwrap_or(json_str);

    let out_path = match args.out {
        Some(p) if p.as_os_str() == "-" => {
            println!("{pretty}");
            return;
        }
        Some(p) => p,
        None => {
            let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
            args.input.with_file_name(format!("{stem}-features.json"))
        }
    };
    if let Err(e) = std::fs::write(&out_path, &pretty) {
        err(format!("write {}: {e}", out_path.display()));
        std::process::exit(1);
    }
    ok(format!("features written → {CYAN}{}{RESET}", out_path.display()));
    // Echo a one-line summary so AI callers see the highlights without re-parsing the file.
    if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pretty) {
        let bb = &v["bbox_mm"];
        let pin1_src = v["pin1"]["source"].as_str().unwrap_or("?");
        let extra_src = match pin1_src {
            "step-feature" => format!(" {CYAN}pin1=step-feature{RESET}"),
            _ => format!(" {DIM}pin1={pin1_src}{RESET}"),
        };
        note!(
            "{DIM}bbox{RESET} x={}..{} y={}..{} z={}..{}  {DIM}floor_z{RESET}={}  {DIM}axis{RESET}={}{}",
            bb["x"][0], bb["x"][1], bb["y"][0], bb["y"][1], bb["z"][0], bb["z"][1],
            v["floor_z_mm"], v["longest_axis"].as_str().unwrap_or("?"), extra_src
        );
    }
}

pub fn thumbnail_cmd(args: ThumbnailArgs) {
    if !args.input.exists() {
        err(format!("input file not found: {}", args.input.display()));
        std::process::exit(1);
    }
    let valid_poses = ["iso", "iso2", "top", "front", "side"];
    if !valid_poses.contains(&args.pose.as_str()) {
        err(format!("invalid --pose `{}`; must be one of: {}", args.pose, valid_poses.join(", ")));
        std::process::exit(1);
    }
    let valid_kinds = ["auto", "step", "glb"];
    if !valid_kinds.contains(&args.kind.as_str()) {
        err(format!("invalid --kind `{}`; must be one of: {}", args.kind, valid_kinds.join(", ")));
        std::process::exit(1);
    }
    // Six canonical face-up variants. Aliases: as-is/asis/z → asIs, y → yUp.
    // The service does the same normalization, but reject early here so
    // callers get a clear error instead of a successful-but-wrong render.
    let valid_up = [
        "asIs", "as-is", "asis", "z",       // → asIs
        "zDown",                             // R_x(π)
        "yUp", "y",                          // → yUp
        "yDown",                             // R_x(-π/2)
        "xUp", "xDown",                      // R_y(±π/2)
    ];
    if !valid_up.contains(&args.up_axis.as_str()) {
        err(format!("invalid --up-axis `{}`; must be one of: {}", args.up_axis, valid_up.join(", ")));
        std::process::exit(1);
    }
    // Pass through verbatim — the service normalizes synonyms server-side.
    let up_norm = args.up_axis.clone();

    // If --kind is auto, hint by file extension. The service still magic-byte
    // sniffs, so an unknown extension just falls through to whatever the
    // bytes say. No filesystem read required to pick the hint.
    let kind_hint = if args.kind == "auto" {
        match args.input.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) {
            Some(ref e) if e == "glb"  => "glb",
            Some(ref e) if e == "step" || e == "stp" => "step",
            _ => "auto",
        }
    } else {
        args.kind.as_str()
    };

    let out = args.out.unwrap_or_else(|| {
        let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
        args.input.with_file_name(format!("{stem}-thumb.png"))
    });
    let bytes = match std::fs::read(&args.input) {
        Ok(b) => b,
        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
    };
    match client::thumbnail_bytes(&bytes, &out, args.width, args.height, &args.pose, kind_hint, &up_norm) {
        Ok(r) => print_thumbnail_success(&r, &out, &args.input.display().to_string()),
        Err(e) => {
            err(format!("thumbnail render failed: {e}"));
            hint("Run `step2glb health` to check the shared service is reachable. /thumbnail requires service-step2glb v0.3.0+ (pyrender + GL libs deployed).");
            std::process::exit(4);
        }
    }
}

pub fn thumbnail_batch_cmd(args: ThumbnailBatchArgs) {
    use base64::Engine;
    if !args.input.exists() {
        err(format!("input file not found: {}", args.input.display()));
        std::process::exit(1);
    }
    let bytes = match std::fs::read(&args.input) {
        Ok(b) => b,
        Err(e) => { err(format!("read {}: {e}", args.input.display())); std::process::exit(1); }
    };
    let stem = args.input.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "step".into());
    let out_dir = args.out_dir.unwrap_or_else(|| {
        args.input.with_file_name(format!("{stem}-thumbs"))
    });
    if let Err(e) = std::fs::create_dir_all(&out_dir) {
        err(format!("create {}: {e}", out_dir.display()));
        std::process::exit(1);
    }

    let result = match client::thumbnail_batch_bytes(&bytes, &args.orientations, &args.sizes, &args.pose) {
        Ok(r) => r,
        Err(e) => {
            err(format!("batch thumbnail render failed: {e}"));
            hint("Run `step2glb health` to check the shared service is reachable. /thumbnail-batch requires service-step2glb v0.8.0+.");
            std::process::exit(4);
        }
    };

    // Write each variant. Naming matches chip-thumbnailer's canonical scheme:
    //   <stem>-3d-iso.png            (asIs / md — back-compat name)
    //   <stem>-3d-iso-yup.png        (yUp / md)
    //   <stem>-3d-iso-yup-sm.png     (yUp / sm)
    //   ... etc.
    let b64 = base64::engine::general_purpose::STANDARD;
    let mut written = 0u32;
    for entry in &result.thumbnails {
        let png = match b64.decode(entry.data_b64.as_bytes()) {
            Ok(b) => b,
            Err(e) => { err(format!("base64 decode {}/{}: {e}", entry.up_axis, entry.size)); continue; }
        };
        let orient_suffix = match entry.up_axis.as_str() {
            "asIs" => "",
            "zDown" => "-zdown",
            "yUp" => "-yup",
            "yDown" => "-ydown",
            "xUp" => "-xup",
            "xDown" => "-xdown",
            other => other,
        };
        let size_suffix = match entry.size.as_str() {
            "md" => "",
            "sm" => "-sm",
            "lg" => "-lg",
            other => other,
        };
        let path = out_dir.join(format!("{stem}-3d-iso{orient_suffix}{size_suffix}.png"));
        if let Err(e) = std::fs::write(&path, &png) {
            err(format!("write {}: {e}", path.display()));
            continue;
        }
        written += 1;
    }

    ok(format!(
        "{written} PNGs written to {CYAN}{}{RESET}  ({DIM}load {} ms · total {} ms · {} variants{RESET})",
        out_dir.display(), result.load_duration_ms, result.total_duration_ms, result.thumbnail_count
    ));
    for w in &result.render_warnings {
        crate::ui::warn(w);
    }

    // Final JSON line for AI / scripted callers.
    println!("{}", serde_json::to_string(&serde_json::json!({
        "ok": true,
        "outDir": out_dir.display().to_string(),
        "written": written,
        "thumbnailCount": result.thumbnail_count,
        "loadDurationMs": result.load_duration_ms,
        "totalDurationMs": result.total_duration_ms,
        "bboxMm": result.bbox_mm,
        "renderWarnings": result.render_warnings,
    })).unwrap_or_default());
}

pub fn preview_cmd(args: PreviewArgs) {
    crate::preview::run(args);
}

pub fn completions(args: CompletionsArgs) {
    let shell: clap_complete::Shell = args.shell.into();
    let mut cmd = Cli::command();
    clap_complete::generate(shell, &mut cmd, "step2glb", &mut std::io::stdout());
}

fn print_thumbnail_success(r: &ThumbnailResult, out: &std::path::Path, source: &str) {
    ok(format!(
        "PNG written from {CYAN}{source}{RESET} ({DIM}{}{RESET}) → {CYAN}{}{RESET}  ({DIM}{}×{} {} pose, {:.1} KB, {} ms{RESET})",
        r.src_kind, out.display(), r.width, r.height, r.pose, r.size_bytes as f64 / 1024.0, r.duration_ms
    ));
    // Echo the chip's bbox in mm so AI callers see the highlights without
    // re-parsing the JSON. Mirrors the summary line in features_cmd.
    if let Some(bb) = &r.bbox_mm {
        if let (Some(xs), Some(ys), Some(zs)) = (bb.get(0), bb.get(1), bb.get(2)) {
            let ext = |v: &serde_json::Value| -> Option<f64> {
                let lo = v.get(0).and_then(|x| x.as_f64())?;
                let hi = v.get(1).and_then(|x| x.as_f64())?;
                Some(hi - lo)
            };
            if let (Some(dx), Some(dy), Some(dz)) = (ext(xs), ext(ys), ext(zs)) {
                note!(
                    "{DIM}bbox{RESET} {:.2}×{:.2}×{:.2} mm  {DIM}pose{RESET}={CYAN}{}{RESET}  {DIM}src{RESET}={}",
                    dx, dy, dz, r.pose, r.src_kind
                );
            }
        }
    }
    println!("{}", serde_json::to_string(&serde_json::json!({
        "ok": true,
        "output": out,
        "sizeBytes": r.size_bytes,
        "width": r.width,
        "height": r.height,
        "pose": r.pose,
        "srcKind": r.src_kind,
        "bboxMm": r.bbox_mm,
        "durationMs": r.duration_ms,
    })).unwrap());
}

fn print_success(r: &ConvertResult, out: &std::path::Path, source: Option<&str>) {
    let mats_n = r.materials.len();
    let src_label = source.map(|s| format!(" from {CYAN}{s}{RESET}")).unwrap_or_default();
    ok(format!(
        "GLB written{src_label} → {CYAN}{}{RESET}  ({DIM}{} meshes, {} materials, {:.1} KB, {} ms{RESET})",
        out.display(), r.meshes, mats_n, r.size_bytes as f64 / 1024.0, r.duration_ms
    ));
    if mats_n == 0 {
        warn("GLB has no materials — the source STEP may lack color assignments. Downstream viewers will render it white.");
    }
    // Also emit the structured JSON on a separate line so AI callers still get machine-readable output.
    println!("{}", serde_json::to_string(&serde_json::json!({
        "ok": true,
        "output": out,
        "sizeBytes": r.size_bytes,
        "meshes": r.meshes,
        "nodes": r.nodes,
        "materials": r.materials,
        "durationMs": r.duration_ms,
    })).unwrap());
}