123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
//! 3D thumbnail: call service-step2glb's POST /thumbnail directly (OCCT
//! V3d offscreen). We talk to the service over HTTP rather than shelling
//! to the `step2glb` CLI, because the deployed CLI's thumbnail path does
//! NOT poll the service's async (202 + job) response — it bails with
//! "service returned HTTP 202". Calling the service directly lets us poll
//! the job to completion, which is what actually works.
//!
//! Renders the as-is + 5 other face-up orientations at the medium size the
//! dashboard displays. Manufacturer chip STEPs ship in inconsistent up-axis
//! conventions; chip-thumbnailer is intentionally neutral and renders every
//! face-up variant — downstream tools pick the right one per chip.

use crate::library::{ChipPaths, ThreeDOrientation, ThreeDSize};
use anyhow::{anyhow, Context, Result};
use std::path::Path;
use std::time::Duration;

const SERVICE_URL_DEFAULT: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";

fn service_url() -> String {
    std::env::var("STEP2GLB_SERVICE_API")
        .unwrap_or_else(|_| SERVICE_URL_DEFAULT.to_string())
        .trim_end_matches('/')
        .to_string()
}

/// Render every face-up orientation at the displayed (Md) size. This covers
/// the dashboard's Z↑/Y↑ tiles plus the 6-orientation popover. (The sm/lg
/// size variants are rendered on demand via `render_orient_size`.)
pub fn render(chip: &ChipPaths) -> Result<()> {
    let step = chip
        .step
        .as_ref()
        .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?
        .clone();

    let mut first_err: Option<anyhow::Error> = None;
    for orient in ThreeDOrientation::all() {
        let out = chip.three_d_thumb_path(orient, ThreeDSize::Md);
        let (w, h) = ThreeDSize::Md.dims();
        match service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn, "") {
            Ok(_) => {
                // The transparent icon is rendered on a green chroma-key and
                // alpha-keyed (a gray-bg key ate part of the gray/black chip).
                if let Err(e) = render_icon_green(chip, &step, orient) {
                    if first_err.is_none() {
                        first_err = Some(anyhow!(
                            "transparent icon (green key) failed for {} ({}): {e}",
                            chip.mpn,
                            orient.json_key()
                        ));
                    }
                }
            }
            // Keep rendering the other orientations; report the first failure.
            Err(e) => {
                if first_err.is_none() {
                    first_err = Some(anyhow!(
                        "step2glb service render failed for {} ({}): {e}",
                        chip.mpn,
                        orient.json_key()
                    ));
                }
            }
        }
    }

    // Lazily populate <MPN>-features.json (manifest pulls bbox from it).
    ensure_features(&step, chip);

    // Full chip-icon family: vector outlines + name-lasered icons via the
    // service's OCCT endpoints (the service owns OCCT; we stay a lean client).
    // Best-effort: a service hiccup here must not fail the core orientation
    // renders that already landed.
    if let Err(e) = render_family_extras(chip, &step) {
        eprintln!("warn: chip-icon family extras failed for {}: {e:#}", chip.mpn);
    }

    match first_err {
        Some(e) => Err(e),
        None => Ok(()),
    }
}

/// The vector outlines + name-laser are a single-pose set. chip-fetcher records
/// the picked up-axis in `info.json.chosen_up_axis`; we honor it so Y-up chips
/// (Bosch / Espressif / Nordic WLCSP) pose correctly. Default = Z-up canonical.
/// Parse a chip-fetcher / UI up-axis token into an orientation. Accepts the
/// upstream forms ("y", "-z") and our json_key forms ("yUp", "zDown").
pub fn orient_from_token(tok: &str) -> ThreeDOrientation {
    match tok.to_lowercase().as_str() {
        "y" | "yup" | "+y" => ThreeDOrientation::YUp,
        "-y" | "ydown" => ThreeDOrientation::YDown,
        "x" | "xup" | "+x" => ThreeDOrientation::XUp,
        "-x" | "xdown" => ThreeDOrientation::XDown,
        "-z" | "zdown" => ThreeDOrientation::ZDown,
        _ => ThreeDOrientation::AsIs,
    }
}

/// The up-axis decision that drives the extended icons (outlines + name-laser),
/// plus where it came from:
///   "override"     — set by the user here in chip-thumbnailer
///   "chip-fetcher" — from the upstream `chosen_up_axis` metadata
///   "unset"        — no metadata at all; defaulted to AsIs (z-up)
pub fn up_axis_decision(chip: &ChipPaths) -> (ThreeDOrientation, &'static str) {
    let info = std::fs::read_to_string(chip.dir.join("info.json"))
        .ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
    let axis = info
        .as_ref()
        .and_then(|v| v.get("chosen_up_axis"))
        .and_then(|a| a.as_str())
        .filter(|s| !s.is_empty());
    match axis {
        None => (ThreeDOrientation::AsIs, "unset"),
        Some(tok) => {
            let src = info
                .as_ref()
                .and_then(|v| v.get("chosen_up_axis_source"))
                .and_then(|a| a.as_str());
            let source = if src == Some("chip-thumbnailer") {
                "override"
            } else {
                "chip-fetcher"
            };
            (orient_from_token(tok), source)
        }
    }
}

/// Persist the user's up-axis override into info.json, MERGING so we never
/// clobber the other fields chip-fetcher wrote. Tags the source so the UI can
/// show that this axis was chosen here, and so downstream tools (adom-symbol)
/// read the corrected orientation.
pub fn set_up_axis(chip: &ChipPaths, orient: ThreeDOrientation) -> Result<()> {
    let path = chip.dir.join("info.json");
    let mut v: serde_json::Value = std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_else(|| serde_json::json!({}));
    if !v.is_object() {
        v = serde_json::json!({});
    }
    let obj = v.as_object_mut().unwrap();
    obj.insert(
        "chosen_up_axis".into(),
        serde_json::Value::String(orient.json_key().to_string()),
    );
    obj.insert(
        "chosen_up_axis_source".into(),
        serde_json::Value::String("chip-thumbnailer".into()),
    );
    let pretty = serde_json::to_string_pretty(&v).context("serialize info.json")?;
    std::fs::write(&path, pretty).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

fn chosen_orientation(chip: &ChipPaths) -> ThreeDOrientation {
    up_axis_decision(chip).0
}

/// The in-plane text rotation that makes the lasered / outline part number read
/// FACING the viewer at the iso camera, per up-axis. A fixed 180 only faces the
/// viewer on z-up chips; y-up chips need 0 (the text otherwise points away —
/// verified on BME688). Returns (primary, alternate); the alternate is a right
/// angle (facing − 90) for long names that would spill off the body.
fn facing_rotations(orient: ThreeDOrientation) -> (u32, u32) {
    let primary = match orient {
        // y-up family: text faces the viewer at 0 (verified), alternate 270.
        ThreeDOrientation::YUp | ThreeDOrientation::YDown => 0,
        // z-up family (asIs/zDown) verified at 180; x-* default to the same.
        _ => 180,
    };
    (primary, (primary + 270) % 360)
}

/// Generate the vector-outline SVGs + name-lasered icons for the chip by
/// calling service-step2glb's /outline and /name-emboss. Writes:
///   <MPN>-3d-outline-<style>.svg          (5 plain styles)
///   <MPN>-named.step                      (embossed STEP, for KiCad too)
///   <MPN>-3d-iso-named.png                (opaque name-lasered iso)
///   <MPN>-3d-iso-named-icon.png           (transparent name-lasered icon)
///   <MPN>-3d-outline-<style>-named.svg    (5 name-marked styles)
pub fn render_family_extras(chip: &ChipPaths, step: &Path) -> Result<()> {
    let step_bytes = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;
    let orient = chosen_orientation(chip);
    let axis = orient.step2glb_arg(); // "asIs" / "yUp" / ...

    // The text rotation that faces the viewer depends on the up-axis (the fixed
    // 180 pointed away on y-up chips). `primary` = the default `-named`;
    // `alt` = the right-angle `-named90` for long names that would spill.
    let (primary, alt) = facing_rotations(orient);

    // Plain vector outlines (no text, so rotation is irrelevant).
    write_outlines(chip, &step_bytes, axis, None, "", primary)?;
    if write_named(chip, &step_bytes, axis, primary, "named").is_ok() {
        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named", primary);
        // A debug tile showing the full + top-slice bboxes used for placement.
        let _ = write_named_debug(chip, &step_bytes, axis, primary);
    }
    if write_named(chip, &step_bytes, axis, alt, "named90").is_ok() {
        let _ = write_outlines(chip, &step_bytes, axis, Some(&chip.mpn), "-named90", alt);
    }

    // The canonical `<MPN>-3d-iso-icon.png` (no axis suffix) follows the chosen
    // axis, matching chip-fetcher's convention (adom-symbol reads that name).
    // The TRUE as-is icon is preserved separately by render_icon_green
    // (`-asis-icon.png`), so the dashboard's as-is tile stays honest.
    if orient != ThreeDOrientation::AsIs {
        let src = chip.three_d_icon_path(orient);
        if src.is_file() {
            let _ = std::fs::copy(&src, chip.three_d_icon_path(ThreeDOrientation::AsIs));
        }
    }
    Ok(())
}

/// Render a placement-debug tile: the chip on the dark canvas with the full
/// bbox (red) + top-slice bbox (cyan) overlaid, so the dashboard can explain how
/// the part-number placement was derived. Writes <MPN>-3d-iso-named-debug.png.
fn write_named_debug(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32) -> Result<()> {
    let tmp_step = chip.dir.join(format!(".dbg-src-{}.step", chip.mpn));
    std::fs::write(&tmp_step, step)?;
    let out = chip.dir.join(format!("{}-3d-iso-named-debug.png", chip.mpn));
    let res = service_thumbnail_named_ext(
        &tmp_step, &out, axis, 460, 350, &chip.mpn, "", &chip.mpn, rot, true,
    );
    let _ = std::fs::remove_file(&tmp_step);
    res
}

/// Vector outline SVGs (5 styles) via service /outline. `suffix` e.g. "-named".
fn write_outlines(
    chip: &ChipPaths,
    step: &[u8],
    axis: &str,
    name: Option<&str>,
    suffix: &str,
    rot: u32,
) -> Result<()> {
    let mut q = format!("/outline?upAxis={axis}");
    if let Some(n) = name {
        q.push_str(&format!("&name={n}&suffix={suffix}&rot={rot}"));
    }
    let v = service_job_json(&q, step, &format!("outline-{}{}", chip.mpn, suffix))?;
    let outlines = v
        .get("outlines")
        .and_then(|o| o.as_object())
        .ok_or_else(|| anyhow!("/outline result had no outlines"))?;
    // The service already bakes `suffix` into the returned style key
    // (e.g. "blueprint-named"), so the filename must NOT re-append it.
    for (style, svg) in outlines {
        if let Some(s) = svg.as_str() {
            let path = chip
                .dir
                .join(format!("{}-3d-outline-{style}.svg", chip.mpn));
            std::fs::write(&path, s).with_context(|| format!("write {}", path.display()))?;
        }
    }
    Ok(())
}

/// Name-lasered iso + transparent named-icon, the part number drawn onto the
/// chip top in the clean single-stroke Hershey OUTLINE font (the same line-font
/// the vector outlines use) rather than a noisy 3D-extruded emboss. `tag` names
/// the output set ("named" = 180 upright default, "named90" = the -90 right
/// angle). Renders on a green chroma-key so the transparent icon keys cleanly
/// (chip + light text are far from green), then composites onto the dark canvas.
/// Writes:  <MPN>-3d-iso-<tag>.png   <MPN>-3d-iso-<tag>-icon.png
fn write_named(chip: &ChipPaths, step: &[u8], axis: &str, rot: u32, tag: &str) -> Result<()> {
    // service_thumbnail_named_ext reads the STEP from a path, so stage the bytes.
    let tmp_step = chip.dir.join(format!(".named-src-{}.step", chip.mpn));
    std::fs::write(&tmp_step, step).with_context(|| format!("write {}", tmp_step.display()))?;
    let green = chip.dir.join(format!(".green-{tag}-{}.png", chip.mpn));
    let res = service_thumbnail_named_ext(
        &tmp_step, &green, axis, 320, 240, &chip.mpn, "00ff00", &chip.mpn, rot, false,
    );
    let _ = std::fs::remove_file(&tmp_step);
    res?;
    if let Ok((w, h, rgba)) = key_green_rgba(&green) {
        let icon = chip.dir.join(format!("{}-3d-iso-{tag}-icon.png", chip.mpn));
        let _ = write_rgba_png(&icon, w, h, &rgba);
        let disp = chip.dir.join(format!("{}-3d-iso-{tag}.png", chip.mpn));
        let _ = write_rgba_on_dark(&disp, w, h, &rgba);
    }
    let _ = std::fs::remove_file(&green);
    Ok(())
}

/// POST a STEP to a service JSON endpoint (sync 200 or async 202 + job) and
/// return the parsed result JSON.
fn service_job_json(path_and_query: &str, step: &[u8], job_name: &str) -> Result<serde_json::Value> {
    let svc = service_url();
    let url = format!("{svc}{path_and_query}");
    let resp = ureq::post(&url)
        .set("Content-Type", "application/step")
        .set("X-Client", "chip-thumbnailer/chip-fetcher")
        .set("X-Job-Name", job_name)
        .timeout(Duration::from_secs(180))
        .send_bytes(step)
        .map_err(|e| anyhow!("POST {url}: {e}"))?;
    match resp.status() {
        200 => {
            let t = resp.into_string().context("read 200 body")?;
            Ok(serde_json::from_str(&t).context("parse result json")?)
        }
        202 => {
            let t = resp.into_string().context("read 202 body")?;
            let v: serde_json::Value = serde_json::from_str(&t).context("parse 202 json")?;
            let jid = v
                .get("job_id")
                .and_then(|s| s.as_str())
                .ok_or_else(|| anyhow!("202 response had no job_id"))?
                .to_string();
            poll_job_json(&svc, &jid)
        }
        code => Err(anyhow!("service returned HTTP {code}")),
    }
}

fn poll_job_json(svc: &str, jid: &str) -> Result<serde_json::Value> {
    for _ in 0..45 {
        if let Ok(r) = ureq::get(&format!("{svc}/jobs/{jid}"))
            .set("X-Client", "chip-thumbnailer/chip-fetcher")
            .set("X-Job-Name", "poll")
            .timeout(Duration::from_secs(20))
            .call()
        {
            let v: serde_json::Value = r
                .into_string()
                .ok()
                .and_then(|t| serde_json::from_str(&t).ok())
                .unwrap_or(serde_json::Value::Null);
            match v.get("status").and_then(|s| s.as_str()) {
                Some("complete") | Some("done") | Some("succeeded") => {
                    let res = ureq::get(&format!("{svc}/jobs/{jid}/result"))
                        .set("X-Client", "chip-thumbnailer/chip-fetcher")
                        .set("X-Job-Name", "result")
                        .timeout(Duration::from_secs(60))
                        .call()
                        .map_err(|e| anyhow!("GET job result: {e}"))?;
                    let t = res.into_string().context("read job result")?;
                    return Ok(serde_json::from_str(&t).context("parse job result json")?);
                }
                Some("failed") | Some("error") => {
                    let msg = v.get("error").and_then(|s| s.as_str()).unwrap_or("unknown");
                    return Err(anyhow!("service job failed: {msg}"));
                }
                _ => {}
            }
        }
        std::thread::sleep(Duration::from_secs(2));
    }
    Err(anyhow!("service job {jid} timed out"))
}

/// Back-compat single-size render at the AsIs orientation.
pub fn render_size(chip: &ChipPaths, size: ThreeDSize) -> Result<()> {
    render_orient_size(chip, ThreeDOrientation::AsIs, size)
}

/// Render a single (orientation, size) to disk via the service.
pub fn render_orient_size(
    chip: &ChipPaths,
    orient: ThreeDOrientation,
    size: ThreeDSize,
) -> Result<()> {
    let step = chip
        .step
        .as_ref()
        .ok_or_else(|| anyhow!("no .step in {}", chip.dir.display()))?
        .clone();
    let out = chip.three_d_thumb_path(orient, size);
    let (w, h) = size.dims();
    service_thumbnail(&step, &out, orient.step2glb_arg(), w, h, &chip.mpn, "")
        .with_context(|| format!("3D render {} ({}/{})", chip.mpn, orient.json_key(), size.label()))?;
    // The transparent icon (Md) is rendered on green + alpha-keyed.
    if size == ThreeDSize::Md {
        render_icon_green(chip, &step, orient)
            .with_context(|| format!("transparent icon {} ({})", chip.mpn, orient.json_key()))?;
    }
    ensure_features(&step, chip);
    Ok(())
}

fn ensure_features(step: &Path, chip: &ChipPaths) {
    let features_path = chip.dir.join(format!("{}-features.json", chip.mpn));
    if features_path.is_file() {
        return;
    }
    // features still goes through the CLI. Its render path can return the
    // service's async 202 (which the CLI doesn't poll) — that's a non-fatal
    // metadata miss, so capture output rather than letting a misleading
    // "feature extraction failed" line leak to the user.
    let _ = std::process::Command::new("adom-step2glb")
        .arg("features")
        .arg(step)
        .arg("--out")
        .arg(&features_path)
        .output();
}

/// POST a STEP to service-step2glb's /thumbnail, handling both the legacy
/// synchronous (200 → PNG) and the current async (202 → job → poll) shapes,
/// then write the PNG to `out`.
#[allow(clippy::too_many_arguments)]
fn service_thumbnail(
    step: &Path,
    out: &Path,
    up_axis: &str,
    w: u32,
    h: u32,
    mpn: &str,
    bg: &str,
) -> Result<()> {
    service_thumbnail_named_ext(step, out, up_axis, w, h, mpn, bg, "", 180, false)
}

/// Like `service_thumbnail`, but optionally lasers `name` onto the chip top in
/// the clean single-stroke Hershey outline font (the same line-font the vector
/// outlines use) at in-plane rotation `name_rot`. Empty `name` = plain render.
/// `name_debug` overlays the full + top-slice bboxes used for placement.
#[allow(clippy::too_many_arguments)]
fn service_thumbnail_named_ext(
    step: &Path,
    out: &Path,
    up_axis: &str,
    w: u32,
    h: u32,
    mpn: &str,
    bg: &str,
    name: &str,
    name_rot: u32,
    name_debug: bool,
) -> Result<()> {
    let svc = service_url();
    let body = std::fs::read(step).with_context(|| format!("read {}", step.display()))?;
    let job_name = format!("thumbnail-iso-{up_axis}-{mpn}-for-chip-thumbnailer");
    let mut url = format!("{svc}/thumbnail?pose=iso&kind=step&upAxis={up_axis}&width={w}&height={h}");
    if !bg.is_empty() {
        url.push_str(&format!("&bg={bg}"));
    }
    if !name.is_empty() {
        url.push_str(&format!("&name={name}&rot={name_rot}"));
    }
    if name_debug {
        url.push_str("&nameDebug=1");
    }

    let resp = ureq::post(&url)
        .set("Content-Type", "application/step")
        .set("X-Client", "chip-thumbnailer/chip-fetcher")
        .set("X-Job-Name", &job_name)
        .timeout(Duration::from_secs(180))
        .send_bytes(&body)
        .map_err(|e| anyhow!("POST {url}: {e}"))?;

    match resp.status() {
        200 => write_reader(resp, out),
        202 => {
            let txt = resp.into_string().context("read 202 job body")?;
            let v: serde_json::Value =
                serde_json::from_str(&txt).context("parse 202 job json")?;
            let job_id = v
                .get("job_id")
                .and_then(|s| s.as_str())
                .ok_or_else(|| anyhow!("202 response had no job_id"))?
                .to_string();
            poll_and_write(&svc, &job_id, out)
        }
        code => Err(anyhow!("service returned HTTP {code}")),
    }
}

fn poll_and_write(svc: &str, job_id: &str, out: &Path) -> Result<()> {
    for _ in 0..30 {
        if let Ok(r) = ureq::get(&format!("{svc}/jobs/{job_id}"))
            .set("X-Client", "chip-thumbnailer/chip-fetcher")
            .set("X-Job-Name", "poll")
            .timeout(Duration::from_secs(15))
            .call()
        {
            let v: serde_json::Value = r
                .into_string()
                .ok()
                .and_then(|t| serde_json::from_str(&t).ok())
                .unwrap_or(serde_json::Value::Null);
            match v.get("status").and_then(|s| s.as_str()) {
                Some("complete") | Some("done") | Some("succeeded") => {
                    let res = ureq::get(&format!("{svc}/jobs/{job_id}/result"))
                        .set("X-Client", "chip-thumbnailer/chip-fetcher")
                        .set("X-Job-Name", "result")
                        .timeout(Duration::from_secs(30))
                        .call()
                        .map_err(|e| anyhow!("GET job result: {e}"))?;
                    return write_reader(res, out);
                }
                Some("failed") | Some("error") => {
                    let msg = v.get("error").and_then(|s| s.as_str()).unwrap_or("unknown");
                    return Err(anyhow!("service job failed: {msg}"));
                }
                _ => {}
            }
        }
        std::thread::sleep(Duration::from_secs(2));
    }
    Err(anyhow!("service job {job_id} timed out"))
}

/// Render one orientation on a pure-green chroma-key background and alpha-key
/// the green out, writing the transparent icon. The chip is gray/black, far from
/// green, so this keeps the whole chip, unlike keying a gray background (which
/// ate chip faces that happened to match the bg shade).
fn render_icon_green(chip: &ChipPaths, step: &Path, orient: ThreeDOrientation) -> Result<()> {
    let green = chip.dir.join(format!(".green-{}-{}.png", chip.mpn, orient.json_key()));
    service_thumbnail(step, &green, orient.step2glb_arg(), 320, 240, &chip.mpn, "00ff00")?;
    let (w, h, rgba) = key_green_rgba(&green)?;
    let _ = std::fs::remove_file(&green);
    write_rgba_png(&chip.three_d_icon_path(orient), w, h, &rgba)?;
    // Keep a stable TRUE as-is icon for the dashboard's as-is tile: the
    // no-suffix `-icon.png` later gets clobbered with the chosen pose for
    // adom-symbol, so the as-is group would otherwise show the chosen pose.
    if orient == ThreeDOrientation::AsIs {
        let asis_true = chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn));
        let _ = write_rgba_png(&asis_true, w, h, &rgba);
    }
    Ok(())
}

/// Alpha-key a pure-green-background PNG. greenness = G - max(R,B): ~0 on the
/// gray/black chip (opaque), high on the green background (transparent). Spill
/// suppression pulls G down to max(R,B) so anti-aliased edges have no green
/// fringe. Returns (width, height, RGBA bytes).
fn key_green_rgba(green_png: &Path) -> Result<(usize, usize, Vec<u8>)> {
    let decoder = png::Decoder::new(
        std::fs::File::open(green_png).with_context(|| format!("open {}", green_png.display()))?,
    );
    let mut reader = decoder.read_info().context("read png header")?;
    let (width, height) = {
        let info = reader.info();
        (info.width as usize, info.height as usize)
    };
    let mut buf = vec![0u8; reader.output_buffer_size()];
    let frame = reader.next_frame(&mut buf).context("decode png frame")?;
    let bytes = &buf[..frame.buffer_size()];
    let ch = match reader.output_color_type().0 {
        png::ColorType::Rgb => 3usize,
        png::ColorType::Rgba => 4usize,
        other => return Err(anyhow!("unexpected iso PNG color type {other:?}")),
    };

    const LOW: f64 = 30.0; // greenness below this -> fully opaque (chip)
    const HIGH: f64 = 95.0; // greenness above this -> fully transparent (bg)
    let mut rgba = vec![0u8; width * height * 4];
    for row in 0..height {
        for col in 0..width {
            let i = (row * width + col) * ch;
            let (r, g, b) = (bytes[i] as f64, bytes[i + 1] as f64, bytes[i + 2] as f64);
            let maxrb = r.max(b);
            let greenness = g - maxrb;
            let alpha = ((1.0 - ((greenness - LOW) / (HIGH - LOW)).clamp(0.0, 1.0)) * 255.0)
                .round() as u8;
            let g2 = g.min(maxrb); // spill suppression
            let o = (row * width + col) * 4;
            rgba[o] = r as u8;
            rgba[o + 1] = g2 as u8;
            rgba[o + 2] = b as u8;
            rgba[o + 3] = alpha;
        }
    }
    Ok((width, height, rgba))
}

fn write_rgba_png(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {
    use std::io::BufWriter;
    let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
    let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h as u32);
    enc.set_color(png::ColorType::Rgba);
    enc.set_depth(png::BitDepth::Eight);
    let mut writer = enc.write_header().context("write png header")?;
    writer.write_image_data(rgba).context("write png data")?;
    Ok(())
}

/// Composite an RGBA buffer over the Adom dark canvas (#0d1117) -> opaque RGB
/// PNG, for the shaded display variants (named.png etc.).
fn write_rgba_on_dark(out: &Path, w: usize, h: usize, rgba: &[u8]) -> Result<()> {
    use std::io::BufWriter;
    const BG: [f64; 3] = [13.0, 17.0, 23.0]; // #0d1117
    let mut rgb = vec![0u8; w * h * 3];
    for px in 0..(w * h) {
        let a = rgba[px * 4 + 3] as f64 / 255.0;
        for c in 0..3 {
            rgb[px * 3 + c] =
                (rgba[px * 4 + c] as f64 * a + BG[c] * (1.0 - a)).round() as u8;
        }
    }
    let file = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
    let mut enc = png::Encoder::new(BufWriter::new(file), w as u32, h as u32);
    enc.set_color(png::ColorType::Rgb);
    enc.set_depth(png::BitDepth::Eight);
    let mut writer = enc.write_header().context("write png header")?;
    writer.write_image_data(&rgb).context("write png data")?;
    Ok(())
}

fn write_reader(resp: ureq::Response, out: &Path) -> Result<()> {
    let mut reader = resp.into_reader();
    let mut f = std::fs::File::create(out).with_context(|| format!("create {}", out.display()))?;
    std::io::copy(&mut reader, &mut f).with_context(|| format!("write {}", out.display()))?;
    Ok(())
}