123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
//! chip-thumbnailer web app: a Hydrogen-webview dashboard that takes a STEP
//! file and renders every icon (symbol, footprint, and all six 3D face-up
//! orientations at sm/md/lg) in real-time. The UI lays out a dashed-box
//! placeholder for each upcoming icon and pops the rendered thumbnail into
//! its box the moment it lands, with a per-icon caption: file name, format,
//! pixel dimensions, KB, and whether it carries real alpha transparency.
//!
//! Follows the Adom app-creator conventions: server is the single source of
//! truth (the UI only polls `GET /api/state` and POSTs commands), every
//! action is curl-drivable, relative URLs throughout, brand-compliant UI,
//! monochrome favicon, console forwarding, graceful `/shutdown`.

use crate::library::{self, ChipPaths, ThreeDOrientation, ThreeDSize};
use crate::render_3d;
use anyhow::{anyhow, Context, Result};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};

const INDEX_HTML: &str = include_str!("ui/app.html");
const FAVICON_SVG: &str = include_str!("ui/icon.svg");

// Monotonic generation counter. Each `generate` / `regenerate` bumps it; the
// background render thread checks its captured generation before every state
// write and bails the instant a newer run supersedes it (so a Regenerate
// click never lets a stale thread clobber the fresh grid).
static GEN: AtomicU64 = AtomicU64::new(0);

static STATE: Mutex<Option<Session>> = Mutex::new(None);

// Bounded ring buffer of forwarded browser console lines (app-creator §8).
static CONSOLE: Mutex<Vec<String>> = Mutex::new(Vec::new());

#[derive(Clone)]
struct Session {
    generation: u64,
    name: String,
    source: &'static str, // "upload" | "library"
    dir: PathBuf,
    started: u64,
    running: bool,
    slots: Vec<Slot>,
    up_axis: String,        // json_key of the orientation driving the extended icons
    up_axis_source: String, // "chip-fetcher" | "override" | "unset"
}

#[derive(Clone)]
struct Slot {
    id: String,
    kind: &'static str, // "symbol" | "footprint" | "3d"
    label: String,      // short caption shown under the box
    group: String,      // group header the box sits under
    status: String,     // pending | rendering | done | failed | skipped
    file: Option<String>,
    format: Option<String>,
    width: Option<u32>,
    height: Option<u32>,
    size_bytes: Option<u64>,
    transparent: Option<bool>,
    light_tile: Option<bool>,
    error: Option<String>,
}

/// What the render thread actually does for one slot. chip-thumbnailer renders
/// the chip body (3D orientations); the transparent icon is keyed as a side
/// effect of the Md render, so it has no Job of its own.
#[derive(Clone)]
enum Job {
    ThreeD(ThreeDOrientation, ThreeDSize),
}

struct Plan {
    id: String,
    job: Job,
    out: PathBuf,
}

fn now_epoch() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

// ────────────────────────────────────────────────────────────────────────
// HTTP server
// ────────────────────────────────────────────────────────────────────────

pub fn run(port: u16) -> Result<()> {
    let addr = format!("0.0.0.0:{port}");
    let server = Server::http(&addr).map_err(|e| anyhow!("bind {addr}: {e}"))?;
    println!("adom-chip-thumbnailer web app listening on http://{addr}");
    println!("open it in Hydrogen: adom-chip-thumbnailer app --port {port}");
    println!("or visit: {}", proxy_url_for_port(port));

    for mut request in server.incoming_requests() {
        // Strip a Coder proxy prefix (e.g. /proxy/8794/) so routes match
        // whether we're hit directly or through the wiki proxy.
        let raw_url = request.url().to_string();
        let url = strip_proxy_prefix(&raw_url);
        let path = url.split('?').next().unwrap_or("").to_string();
        let method = request.method().clone();

        let response = match (&method, path.as_str()) {
            (Method::Get, "/") | (Method::Get, "/index.html") => html_response(INDEX_HTML),
            (Method::Get, "/favicon.svg") | (Method::Get, "/favicon.ico") => Response::from_string(
                FAVICON_SVG.to_string(),
            )
            .with_header(hdr("Content-Type", "image/svg+xml")),

            (Method::Get, "/api/state") => json_response(state_json()),
            (Method::Get, "/api/library") => json_response(library_json()),

            (Method::Post, "/api/generate") => {
                let filename = header_value(&request, "X-Filename");
                let ctype = header_value(&request, "Content-Type").unwrap_or_default();
                let mut body: Vec<u8> = Vec::new();
                let _ = request.as_reader().read_to_end(&mut body);
                handle_generate(ctype, filename, body)
            }
            (Method::Post, "/api/regenerate") => handle_regenerate(),
            (Method::Post, "/api/up-axis") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                handle_set_up_axis(body)
            }

            (Method::Get, p) if p.starts_with("/thumb/") => serve_thumb(&p["/thumb/".len()..]),

            (Method::Post, "/console") => {
                let mut body = String::new();
                let _ = request.as_reader().read_to_string(&mut body);
                push_console(&body);
                json_response("{\"ok\":true}".into())
            }
            (Method::Get, "/console") => json_response(console_json()),

            (Method::Post, "/shutdown") => {
                let _ = json_response("{\"ok\":true}".into());
                println!("shutdown requested — exiting");
                let _ = request.respond(json_response("{\"ok\":true}".into()));
                std::process::exit(0);
            }

            _ => not_found(),
        };

        let _ = request.respond(response);
    }
    Ok(())
}

// ────────────────────────────────────────────────────────────────────────
// command handlers
// ────────────────────────────────────────────────────────────────────────

fn handle_generate(
    ctype: String,
    filename: Option<String>,
    body: Vec<u8>,
) -> Response<std::io::Cursor<Vec<u8>>> {
    // JSON body => library pick: {"mpn":"..."}.
    if ctype.contains("application/json") {
        let v: serde_json::Value = match serde_json::from_slice(&body) {
            Ok(v) => v,
            Err(e) => return error_response(400, format!("{{\"error\":\"bad json: {e}\"}}")),
        };
        let mpn = v.get("mpn").and_then(|s| s.as_str()).unwrap_or("");
        if mpn.is_empty() {
            return error_response(400, "{\"error\":\"missing mpn\"}".into());
        }
        return match ChipPaths::for_mpn(mpn) {
            Ok(chip) => {
                let dir = chip.dir.clone();
                start_generation("library", mpn.to_string(), dir, chip);
                json_response(state_json())
            }
            Err(e) => error_response(404, format!("{{\"error\":\"{e}\"}}")),
        };
    }

    // Otherwise: a raw STEP upload.
    if body.is_empty() {
        return error_response(400, "{\"error\":\"empty body — POST a STEP file\"}".into());
    }
    if !looks_like_step(&body) {
        return error_response(
            400,
            "{\"error\":\"that does not look like a STEP file (no ISO-10303-21 header)\"}".into(),
        );
    }
    let stem = sanitize_stem(filename.as_deref().unwrap_or("upload"));
    let dir = sessions_root().join(&stem);
    if let Err(e) = std::fs::create_dir_all(&dir) {
        return error_response(500, format!("{{\"error\":\"mkdir: {e}\"}}"));
    }
    // Clear any stale thumbnails from a previous run of the same file.
    if let Ok(entries) = std::fs::read_dir(&dir) {
        for e in entries.flatten() {
            let p = e.path();
            let keep = p
                .file_name()
                .and_then(|n| n.to_str())
                .map(|n| n.ends_with(".step"))
                .unwrap_or(false);
            if !keep {
                let _ = std::fs::remove_file(&p);
            }
        }
    }
    let step_path = dir.join(format!("{stem}.step"));
    if let Err(e) = std::fs::write(&step_path, &body) {
        return error_response(500, format!("{{\"error\":\"write step: {e}\"}}"));
    }
    let chip = ChipPaths::for_dir(dir.clone(), &stem);
    start_generation("upload", stem, dir, chip);
    json_response(state_json())
}

fn handle_regenerate() -> Response<std::io::Cursor<Vec<u8>>> {
    let (name, dir) = {
        let g = STATE.lock().unwrap();
        match g.as_ref() {
            Some(s) => (s.name.clone(), s.dir.clone()),
            None => return error_response(409, "{\"error\":\"nothing to regenerate yet\"}".into()),
        }
    };
    // ChipPaths::for_dir works for both upload + library dirs (same layout).
    let chip = ChipPaths::for_dir(dir.clone(), &name);
    let source = if dir.starts_with(library::root()) {
        "library"
    } else {
        "upload"
    };
    start_generation(source, name, dir, chip);
    json_response(state_json())
}

/// Override the up orientation that drives the extended icons. Persists the
/// choice to info.json (so adom-symbol and everything downstream read it too),
/// then re-renders ONLY the "Chip icons" tab (outlines + name-laser) from the
/// new axis — the six shaded poses don't depend on the choice, so they stay.
fn handle_set_up_axis(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
    let v: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::json!({}));
    let axis = v.get("axis").and_then(|a| a.as_str()).unwrap_or("");
    if axis.is_empty() {
        return error_response(400, "{\"error\":\"missing axis\"}".into());
    }
    let orient = render_3d::orient_from_token(axis);

    let (name, dir, gen) = {
        let g = STATE.lock().unwrap();
        match g.as_ref() {
            Some(s) => (s.name.clone(), s.dir.clone(), s.generation),
            None => return error_response(409, "{\"error\":\"no chip loaded\"}".into()),
        }
    };
    let chip = ChipPaths::for_dir(dir, &name);
    if let Err(e) = render_3d::set_up_axis(&chip, orient) {
        return error_response(500, format!("{{\"error\":\"write info.json: {e}\"}}"));
    }

    // Drop the stale derived slots, flip to running, and bust the image cache
    // (bump `started`) so the regenerated icons re-fetch instead of showing the
    // old axis from cache.
    {
        let mut g = STATE.lock().unwrap();
        if let Some(s) = g.as_mut() {
            if s.generation == gen {
                s.up_axis = orient.json_key().to_string();
                s.up_axis_source = "override".to_string();
                s.slots.retain(|x| x.kind != "named" && x.kind != "outline");
                s.started = now_epoch();
                s.running = true;
            }
        }
    }

    let chip2 = chip.clone();
    std::thread::spawn(move || {
        if let Some(step) = chip2.step.as_ref() {
            let _ = render_3d::render_family_extras(&chip2, step);
        }
        if is_current(gen) {
            append_family_slots(gen, &chip2);
        }
        let mut g = STATE.lock().unwrap();
        if let Some(s) = g.as_mut() {
            if s.generation == gen {
                s.running = false;
            }
        }
    });

    json_response(state_json())
}

// ────────────────────────────────────────────────────────────────────────
// generation: build slots, spawn the render thread
// ────────────────────────────────────────────────────────────────────────

fn start_generation(source: &'static str, name: String, dir: PathBuf, chip: ChipPaths) {
    let generation = GEN.fetch_add(1, Ordering::SeqCst) + 1;
    let (slots, plans) = build_slots_and_plans(&chip);
    let (up_orient, up_source) = render_3d::up_axis_decision(&chip);

    {
        let mut g = STATE.lock().unwrap();
        *g = Some(Session {
            generation,
            name,
            source,
            dir,
            started: now_epoch(),
            running: !plans.is_empty(),
            slots,
            up_axis: up_orient.json_key().to_string(),
            up_axis_source: up_source.to_string(),
        });
    }

    if plans.is_empty() {
        return;
    }
    std::thread::spawn(move || render_loop(generation, chip, plans));
}

/// Build the display slots (the whole chip-icon family) and the render plan.
/// chip-thumbnailer renders the chip body, so every slot here is a CHIP icon:
/// the shaded 3D orientations and their transparent alpha-keyed icons. The
/// name-lasered + vector-outline variants (produced by service-step2glb) are
/// shown when they already exist in the chip dir.
fn build_slots_and_plans(chip: &ChipPaths) -> (Vec<Slot>, Vec<Plan>) {
    let mut slots = Vec::new();
    let mut plans = Vec::new();
    let have_step = chip.step.is_some();

    // 3D: six face-up orientations. Under each orientation: the transparent
    // icon (the headline asset adom-symbol consumes), then the shaded md / sm /
    // lg renders. Rendered md-first so the icon + canonical view fill first.
    for orient in ThreeDOrientation::all() {
        let group = format!("Chip {}", short_orient(orient));

        // Transparent icon (keyed from the Md shaded render; no own job).
        let icon_id = format!("icon-{}", orient.json_key());
        if have_step {
            slots.push(Slot {
                group: group.clone(),
                ..pending_slot(&icon_id, "icon", &group, "transparent icon")
            });
        } else {
            slots.push(skipped_slot(&icon_id, "icon", &group, "transparent icon", "no .step in the source"));
        }

        for size in [ThreeDSize::Md, ThreeDSize::Sm, ThreeDSize::Lg] {
            let id = format!("3d-{}-{}", orient.json_key(), size.label());
            let label = format!("shaded · {}", size.label());
            let out = chip.three_d_thumb_path(orient, size);
            if have_step {
                slots.push(Slot {
                    group: group.clone(),
                    ..pending_slot(&id, "3d", &group, &label)
                });
                plans.push(Plan { id, job: Job::ThreeD(orient, size), out });
            } else {
                slots.push(skipped_slot(&id, "3d", &group, &label, "no .step in the source"));
            }
        }
    }

    // Display any name-lasered + vector-outline icons already produced for this
    // chip by service-step2glb (read-only tiles, with provenance).
    push_existing_assets(chip, &mut slots);

    (slots, plans)
}

/// Add read-only display slots for name-lasered + vector-outline icons that
/// exist in the chip dir (produced by service-step2glb's emboss/HLR pass).
fn push_existing_assets(chip: &ChipPaths, slots: &mut Vec<Slot>) {
    // Placement-debug tile: explains how the part-number placement was derived
    // (full bbox in red, top-slice bbox in cyan). Sits first in the icons tab.
    let dbg_file = format!("{}-3d-iso-named-debug.png", chip.mpn);
    if chip.dir.join(&dbg_file).is_file() {
        let mut s = pending_slot("named-debug", "named", "Placement", "full bbox (red) + top-slice bbox (cyan)");
        s.status = "done".into();
        s.file = Some(dbg_file.clone());
        if let Some(m) = inspect(&chip.dir.join(&dbg_file)) {
            s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
            s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
        }
        slots.push(s);
    }
    let named = [
        ("named-icon", format!("{}-3d-iso-named-icon.png", chip.mpn), "name-lasered icon (facing)"),
        ("named", format!("{}-3d-iso-named.png", chip.mpn), "name-lasered (facing, opaque)"),
        ("named90-icon", format!("{}-3d-iso-named90-icon.png", chip.mpn), "name-lasered icon (right-angle)"),
        ("named90", format!("{}-3d-iso-named90.png", chip.mpn), "name-lasered (right-angle, opaque)"),
    ];
    for (id, file, label) in named {
        if chip.dir.join(&file).is_file() {
            let mut s = pending_slot(id, "named", "Name-lasered", label);
            s.status = "done".into();
            s.file = Some(file.clone());
            if let Some(m) = inspect(&chip.dir.join(&file)) {
                s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
                s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
            }
            slots.push(s);
        }
    }
    // Vector outlines: <mpn>-3d-outline-<style>[-named].svg
    let mut outlines: Vec<String> = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&chip.dir) {
        let prefix = format!("{}-3d-outline-", chip.mpn);
        for e in rd.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            if name.starts_with(&prefix) && name.ends_with(".svg") {
                outlines.push(name);
            }
        }
    }
    outlines.sort();
    for file in outlines {
        let style = file
            .trim_start_matches(&format!("{}-3d-outline-", chip.mpn))
            .trim_end_matches(".svg")
            .to_string();
        let mut s = pending_slot(&format!("outline-{style}"), "outline", "Vector outlines", &style);
        s.status = "done".into();
        s.file = Some(file.clone());
        if let Some(m) = inspect(&chip.dir.join(&file)) {
            s.format = Some(m.format); s.width = Some(m.width); s.height = Some(m.height);
            s.size_bytes = Some(m.size_bytes); s.transparent = Some(m.transparent); s.light_tile = Some(m.light_tile);
        }
        slots.push(s);
    }
}

fn render_loop(generation: u64, chip: ChipPaths, plans: Vec<Plan>) {
    for plan in plans {
        // Bail if a newer generation superseded us.
        if !is_current(generation) {
            return;
        }
        if !set_status(generation, &plan.id, "rendering") {
            return;
        }

        let Job::ThreeD(o, s) = plan.job;
        let result = render_3d::render_orient_size(&chip, o, s);

        if !is_current(generation) {
            return;
        }

        match result {
            Ok(_) => {
                let meta = inspect(&plan.out);
                finish_slot(generation, &plan.id, &plan.out, meta);
                // The Md render also keys the transparent icon — finish its slot.
                if s == ThreeDSize::Md {
                    // The as-is tile shows the stable true-as-is icon, not the
                    // no-suffix file (which gets clobbered with the chosen pose
                    // for adom-symbol — that's what made as-is look like Y-up).
                    let icon_out = if o == ThreeDOrientation::AsIs {
                        chip.dir.join(format!("{}-3d-iso-asis-icon.png", chip.mpn))
                    } else {
                        chip.three_d_icon_path(o)
                    };
                    let icon_meta = inspect(&icon_out);
                    finish_slot(generation, &format!("icon-{}", o.json_key()), &icon_out, icon_meta);
                }
            }
            Err(e) => {
                fail_slot(generation, &plan.id, format!("{e}"));
                if s == ThreeDSize::Md {
                    fail_slot(generation, &format!("icon-{}", o.json_key()), "shaded render failed".into());
                }
            }
        }
    }

    // Generate the vector-outline + name-lasered family (service OCCT), then
    // surface the produced files as new slots so they appear in the grid.
    if is_current(generation) {
        if let Some(step) = chip.step.as_ref() {
            let _ = render_3d::render_family_extras(&chip, step);
            if is_current(generation) {
                append_family_slots(generation, &chip);
            }
        }
    }

    // Mark the session done.
    let mut g = STATE.lock().unwrap();
    if let Some(s) = g.as_mut() {
        if s.generation == generation {
            s.running = false;
        }
    }
}

/// After the family extras are generated, append display slots for the new
/// outline / name-lasered files (skipping ids already present).
fn append_family_slots(generation: u64, chip: &ChipPaths) {
    let mut extra: Vec<Slot> = Vec::new();
    push_existing_assets(chip, &mut extra);
    let mut g = STATE.lock().unwrap();
    let Some(s) = g.as_mut() else { return };
    if s.generation != generation {
        return;
    }
    let have: std::collections::HashSet<String> = s.slots.iter().map(|x| x.id.clone()).collect();
    for slot in extra {
        if !have.contains(&slot.id) {
            s.slots.push(slot);
        }
    }
}

// ── slot helpers ────────────────────────────────────────────────────────

fn pending_slot(id: &str, kind: &'static str, group: &str, label: &str) -> Slot {
    Slot {
        id: id.into(),
        kind,
        label: label.into(),
        group: group.into(),
        status: "pending".into(),
        file: None,
        format: None,
        width: None,
        height: None,
        size_bytes: None,
        transparent: None,
        light_tile: None,
        error: None,
    }
}

fn skipped_slot(id: &str, kind: &'static str, group: &str, label: &str, why: &str) -> Slot {
    Slot {
        status: "skipped".into(),
        error: Some(why.into()),
        ..pending_slot(id, kind, group, label)
    }
}

fn is_current(generation: u64) -> bool {
    STATE
        .lock()
        .unwrap()
        .as_ref()
        .map(|s| s.generation == generation)
        .unwrap_or(false)
}

fn set_status(generation: u64, id: &str, status: &str) -> bool {
    let mut g = STATE.lock().unwrap();
    let Some(s) = g.as_mut() else { return false };
    if s.generation != generation {
        return false;
    }
    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
        slot.status = status.into();
    }
    true
}

fn finish_slot(generation: u64, id: &str, out: &Path, meta: Option<ImgMeta>) {
    let mut g = STATE.lock().unwrap();
    let Some(s) = g.as_mut() else { return };
    if s.generation != generation {
        return;
    }
    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
        slot.status = "done".into();
        slot.file = out
            .file_name()
            .map(|n| n.to_string_lossy().to_string());
        if let Some(m) = meta {
            slot.format = Some(m.format);
            slot.width = Some(m.width);
            slot.height = Some(m.height);
            slot.size_bytes = Some(m.size_bytes);
            slot.transparent = Some(m.transparent);
            slot.light_tile = Some(m.light_tile);
        }
    }
}

fn fail_slot(generation: u64, id: &str, err: String) {
    let mut g = STATE.lock().unwrap();
    let Some(s) = g.as_mut() else { return };
    if s.generation != generation {
        return;
    }
    if let Some(slot) = s.slots.iter_mut().find(|x| x.id == id) {
        slot.status = "failed".into();
        slot.error = Some(err);
    }
}

// ────────────────────────────────────────────────────────────────────────
// image introspection — real dims + true alpha transparency
// ────────────────────────────────────────────────────────────────────────

struct ImgMeta {
    format: String,
    width: u32,
    height: u32,
    size_bytes: u64,
    transparent: bool,
    // Vector outlines drawn in the dark theme color (#0d1117 lines, the
    // "ink on paper" styles thin/bold) are invisible on a dark tile, so the
    // UI puts them on a light tile instead.
    light_tile: bool,
}

fn inspect(path: &Path) -> Option<ImgMeta> {
    let size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
    let ext = path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    if ext == "svg" {
        let txt = std::fs::read_to_string(path).ok()?;
        let (width, height) = svg_dims(&txt);
        // An SVG is opaque only if it paints a full-canvas background rect; the
        // vector outlines paint no background, so they are transparent. (The
        // old heuristic wrongly keyed on the presence of #0d1117 anywhere,
        // which mislabeled the dark-LINE styles as opaque.)
        let transparent = !has_full_canvas_rect(&txt, width, height);
        // Dark-line styles (thin/bold draw strokes in #0d1117 / #191919) are
        // invisible on a dark tile — flag them so the UI shows a light tile.
        let light_tile = transparent
            && (txt.contains("stroke=\"#0d1117\"") || txt.contains("stroke=\"#191919\""));
        return Some(ImgMeta {
            format: "SVG".into(),
            width,
            height,
            size_bytes,
            transparent,
            light_tile,
        });
    }

    // PNG: decode the header for true dimensions, then scan the alpha
    // channel for any sub-255 pixel so "transparent" reflects reality, not
    // just "has an alpha channel".
    let file = std::fs::File::open(path).ok()?;
    let decoder = png::Decoder::new(file);
    let mut reader = decoder.read_info().ok()?;
    let (width, height, has_trns) = {
        let info = reader.info();
        (info.width, info.height, info.trns.is_some())
    };
    let mut buf = vec![0u8; reader.output_buffer_size()];
    let frame = reader.next_frame(&mut buf).ok()?;
    let bytes = &buf[..frame.buffer_size()];
    let (out_color, _bd) = reader.output_color_type();
    let transparent = match out_color {
        png::ColorType::Rgba => bytes.chunks_exact(4).any(|p| p[3] < 255),
        png::ColorType::GrayscaleAlpha => bytes.chunks_exact(2).any(|p| p[1] < 255),
        _ => has_trns,
    };
    Some(ImgMeta {
        format: "PNG".into(),
        width,
        height,
        size_bytes,
        transparent,
        light_tile: false,
    })
}

/// True if the SVG paints a rectangle covering the whole canvas (an opaque
/// background). Used to tell a baked-background SVG from a bare line drawing.
fn has_full_canvas_rect(txt: &str, w: u32, h: u32) -> bool {
    // Look for a <rect> sized to 100% or the exact viewBox dims with a fill.
    let wh = format!("width=\"{w}\"");
    let hh = format!("height=\"{h}\"");
    txt.contains("<rect")
        && (txt.contains("width=\"100%\"")
            || (txt.contains(&wh) && txt.contains(&hh)))
        && txt.contains("fill=\"#")
}

fn svg_dims(txt: &str) -> (u32, u32) {
    // Prefer viewBox; fall back to width/height attributes.
    if let Some(c) = regex_once(r#"viewBox\s*=\s*["']\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)"#, txt)
    {
        return (c.0.round() as u32, c.1.round() as u32);
    }
    let w = regex_one(r#"width\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);
    let h = regex_one(r#"height\s*=\s*["']([\d.]+)"#, txt).unwrap_or(0.0);
    (w.round() as u32, h.round() as u32)
}

fn regex_once(pat: &str, txt: &str) -> Option<(f64, f64)> {
    let re = regex::Regex::new(pat).ok()?;
    let c = re.captures(txt)?;
    Some((c.get(1)?.as_str().parse().ok()?, c.get(2)?.as_str().parse().ok()?))
}
fn regex_one(pat: &str, txt: &str) -> Option<f64> {
    let re = regex::Regex::new(pat).ok()?;
    let c = re.captures(txt)?;
    c.get(1)?.as_str().parse().ok()
}

// ────────────────────────────────────────────────────────────────────────
// JSON serialization (hand-rolled so we don't have to derive Serialize on
// the state types, which carry non-serde enums)
// ────────────────────────────────────────────────────────────────────────

fn state_json() -> String {
    let g = STATE.lock().unwrap();
    match g.as_ref() {
        None => "{\"hasSession\":false}".into(),
        Some(s) => {
            let slots: Vec<serde_json::Value> = s
                .slots
                .iter()
                .map(|x| {
                    serde_json::json!({
                        "id": x.id,
                        "kind": x.kind,
                        "label": x.label,
                        "group": x.group,
                        "status": x.status,
                        "file": x.file,
                        "format": x.format,
                        "width": x.width,
                        "height": x.height,
                        "sizeBytes": x.size_bytes,
                        "transparent": x.transparent,
                        "lightTile": x.light_tile,
                        "error": x.error,
                        // Which tab the box lives under, and (for orientation
                        // tiles) which axis it represents so the UI can drive
                        // the up-orientation picker.
                        "tab": slot_tab(x.kind),
                        "axis": slot_axis(&x.id, x.kind),
                    })
                })
                .collect();
            let up_orient = render_3d::orient_from_token(&s.up_axis);
            serde_json::json!({
                "hasSession": true,
                "name": s.name,
                "source": s.source,
                "running": s.running,
                "started": s.started,
                "total": s.slots.len(),
                "done": s.slots.iter().filter(|x| x.status == "done").count(),
                "failed": s.slots.iter().filter(|x| x.status == "failed").count(),
                // The orientation decision that drives the "Chip icons" tab.
                "upAxis": s.up_axis,
                "upAxisLabel": short_orient(up_orient),
                "upAxisSource": s.up_axis_source,
                "slots": slots,
            })
            .to_string()
        }
    }
}

fn library_json() -> String {
    let mut entries = Vec::new();
    if let Ok(mpns) = library::list_mpns() {
        for mpn in mpns {
            if let Ok(chip) = ChipPaths::for_mpn(&mpn) {
                if chip.step.is_some() {
                    entries.push(serde_json::json!({
                        "mpn": mpn,
                        "hasSym": chip.kicad_sym.is_some(),
                        "hasMod": chip.kicad_mod.is_some(),
                    }));
                }
            }
        }
    }
    serde_json::json!({ "chips": entries }).to_string()
}

// ────────────────────────────────────────────────────────────────────────
// thumb serving + small helpers
// ────────────────────────────────────────────────────────────────────────

fn serve_thumb(name: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    // Defend against path traversal — basenames only.
    if name.is_empty() || name.contains('/') || name.contains("..") {
        return not_found();
    }
    let dir = {
        let g = STATE.lock().unwrap();
        match g.as_ref() {
            Some(s) => s.dir.clone(),
            None => return not_found(),
        }
    };
    let path = dir.join(name);
    let ct = match path.extension().and_then(|e| e.to_str()) {
        Some("svg") => "image/svg+xml",
        Some("png") => "image/png",
        _ => "application/octet-stream",
    };
    match std::fs::read(&path) {
        Ok(bytes) => Response::from_data(bytes)
            .with_header(hdr("Content-Type", ct))
            .with_header(hdr("Cache-Control", "no-store")),
        Err(_) => not_found(),
    }
}

fn looks_like_step(body: &[u8]) -> bool {
    let head_len = body.len().min(512);
    let head = String::from_utf8_lossy(&body[..head_len]);
    head.contains("ISO-10303-21") || head.contains("ISO-10303")
}

fn sanitize_stem(name: &str) -> String {
    let stem = Path::new(name)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("upload");
    let cleaned: String = stem
        .chars()
        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
        .collect();
    let cleaned = cleaned.trim_matches('_').to_string();
    if cleaned.is_empty() {
        "upload".into()
    } else {
        cleaned
    }
}

/// Which tab a slot's box lives under: the shaded poses + their transparent
/// icons are the orientation *picker* (Tab 1); the name-laser + vector outlines
/// are the icons *derived* from the chosen orientation (Tab 2).
fn slot_tab(kind: &str) -> &'static str {
    match kind {
        "3d" | "icon" => "orientation",
        _ => "icons",
    }
}

/// The orientation axis (json_key) an orientation-tile represents, parsed from
/// its slot id ("3d-yUp-md" -> "yUp", "icon-yUp" -> "yUp"). None for the
/// derived (icons-tab) slots, which aren't tied to a single pickable axis.
fn slot_axis(id: &str, kind: &str) -> Option<String> {
    match kind {
        "3d" => id.splitn(3, '-').nth(1).map(String::from),
        "icon" => id.strip_prefix("icon-").map(String::from),
        _ => None,
    }
}

fn short_orient(o: ThreeDOrientation) -> &'static str {
    match o {
        ThreeDOrientation::AsIs => "as-is (Z-up)",
        ThreeDOrientation::ZDown => "Z-down",
        ThreeDOrientation::YUp => "Y-up",
        ThreeDOrientation::YDown => "Y-down",
        ThreeDOrientation::XUp => "X-up",
        ThreeDOrientation::XDown => "X-down",
    }
}

fn sessions_root() -> PathBuf {
    if let Ok(p) = std::env::var("CHIP_THUMBNAILER_SESSIONS") {
        return PathBuf::from(p);
    }
    std::env::temp_dir().join("chip-thumbnailer-sessions")
}

fn push_console(line: &str) {
    let mut c = CONSOLE.lock().unwrap();
    c.push(line.to_string());
    let len = c.len();
    if len > 500 {
        c.drain(0..len - 500);
    }
}
fn console_json() -> String {
    let c = CONSOLE.lock().unwrap();
    serde_json::json!({ "lines": *c }).to_string()
}

fn header_value(req: &tiny_http::Request, field: &str) -> Option<String> {
    req.headers()
        .iter()
        .find(|h| h.field.as_str().as_str().eq_ignore_ascii_case(field))
        .map(|h| h.value.as_str().to_string())
}

fn strip_proxy_prefix(url: &str) -> String {
    // Turn "/proxy/8794/api/state" into "/api/state".
    if let Some(rest) = url.strip_prefix("/proxy/") {
        if let Some(slash) = rest.find('/') {
            return rest[slash..].to_string();
        }
    }
    url.to_string()
}

fn hdr(name: &str, value: &str) -> Header {
    Header::from_bytes(name.as_bytes(), value.as_bytes()).unwrap()
}
fn html_response(body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body.to_string())
        .with_header(hdr("Content-Type", "text/html; charset=utf-8"))
}
fn json_response(body: String) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body).with_header(hdr("Content-Type", "application/json"))
}
fn error_response(code: u16, body: String) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(body)
        .with_status_code(code)
        .with_header(hdr("Content-Type", "application/json"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string("not found")
        .with_status_code(404)
        .with_header(hdr("Content-Type", "text/plain"))
}

// ────────────────────────────────────────────────────────────────────────
// proxy URL + Hydrogen tab open
// ────────────────────────────────────────────────────────────────────────

fn proxy_url_for_port(port: u16) -> String {
    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
        return format!("https://{host}/proxy/{port}/");
    }
    if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
        return format!("https://{slug}.adom.cloud/proxy/{port}/");
    }
    if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
        if tpl.contains("{{port}}") {
            return tpl.replace("{{port}}", &port.to_string());
        }
    }
    format!("http://127.0.0.1:{port}/")
}

pub fn open_in_hydrogen(port: u16) -> Result<()> {
    let url = proxy_url_for_port(port);
    println!("opening {url} in Hydrogen webview");
    let status = std::process::Command::new("adom-cli")
        .args([
            "hydrogen",
            "webview",
            "open-or-refresh",
            "--url",
            &url,
            "--name",
            "Chip Thumbnailer",
        ])
        .status()
        .context("running adom-cli hydrogen webview open-or-refresh")?;
    if !status.success() {
        anyhow::bail!("adom-cli webview open returned non-zero exit");
    }
    Ok(())
}