123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
//! adom-footprint live app server — the AI-drivable surface (app-creator §7/§7b/§8).
//! Same shell as adom-symbol: GET / (UI), GET /state (poller), POST /load,
//! POST /eval + GET /eval/pending + POST /eval/:id/result + GET /eval/:id,
//! POST/GET /console, POST /shutdown, GET /favicon.svg. No gallia, no Node.

use anyhow::Result;
use serde_json::{json, Value};
use std::collections::{HashMap, VecDeque};
use std::io::Read;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use tiny_http::{Header, Method, Response, Server};

#[derive(Default, Clone)]
pub struct AppState {
    pub footprint_name: String,
    pub svg: String,
    pub pad_count: usize,
    pub description: String,
    pub tags: String,
    /// Raw `.kicad_mod` retained for "Send to" delivery (empty if loaded via SVG).
    pub kicad_mod: String,
    /// Library folder name (the chip's `<MPN>/` dir) — what the left library rail
    /// highlights as "current". Empty when not loaded from a chip-fetcher library.
    pub mpn: String,
}

static STATE: Mutex<Option<AppState>> = Mutex::new(None);
/// Root of the chip-fetcher library (a folder of `<MPN>/` dirs) — powers the left
/// library rail. Set from `serve --file <…/MPN/MPN.kicad_mod>`, else auto-detected.
static LIBRARY_ROOT: Mutex<Option<String>> = Mutex::new(None);
/// On-disk path of the currently-loaded `.kicad_mod` — what `/save` writes back to
/// (so text-placement edits persist per-footprint). None for an in-memory push.
static LOADED_PATH: Mutex<Option<String>> = Mutex::new(None);
static EVAL_PENDING: Mutex<VecDeque<(u64, String)>> = Mutex::new(VecDeque::new());
static EVAL_RESULTS: Mutex<Option<HashMap<u64, Value>>> = Mutex::new(None);
static CONSOLE: Mutex<Option<Vec<String>>> = Mutex::new(None);
static EVAL_ID: AtomicU64 = AtomicU64::new(1);

const APP_HTML: &str = include_str!("app.html");
const AV_CSS: &str = include_str!("viewer_core.css");
const AV_JS: &str = include_str!("viewer_core.js");

/// The app HTML with the shared viewer core (window.AV + av- CSS) injected — the
/// SAME core the static embed uses, so the two viewers can't drift.
fn app_html() -> String {
    APP_HTML
        .replace("/*__AV_CSS__*/", AV_CSS)
        .replace("/*__AV_JS__*/", AV_JS)
}
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="6.5" y="9" width="3" height="6" rx="0.6"/><rect x="14.5" y="9" width="3" height="6" rx="0.6"/><path d="M6 6.2h1.2"/></svg>"##;

pub fn set_state(s: AppState) {
    *STATE.lock().unwrap() = Some(s);
}

pub fn set_library_root(p: String) {
    *LIBRARY_ROOT.lock().unwrap() = Some(p);
}

pub fn set_loaded_path(p: Option<String>) {
    *LOADED_PATH.lock().unwrap() = p;
}

/// The library root: the explicitly-set one (from `serve --file`), else the first
/// chip-fetcher library we can find on disk — so the rail + `?mpn=` work even when
/// the app was launched without a file.
fn library_root() -> Option<String> {
    if let Some(r) = LIBRARY_ROOT.lock().unwrap().clone() {
        return Some(r);
    }
    let home = std::env::var("HOME").unwrap_or_default();
    for c in [
        format!("{home}/project/chip-fetcher/library"),
        "chip-fetcher/library".to_string(),
        "/home/adom/project/chip-fetcher/library".to_string(),
    ] {
        if std::path::Path::new(&c).is_dir() {
            return Some(c);
        }
    }
    None
}

/// Pick the footprint file to load for a chip: prefer the canonical `<mpn>.kicad_mod`
/// (what adom-lbr exports from), then the Fusion/Altium variants, then any `.kicad_mod`.
fn footprint_file_for(dir: &std::path::Path, mpn: &str) -> Option<std::path::PathBuf> {
    for cand in [
        format!("{mpn}.kicad_mod"),
        format!("{mpn}.fusion.kicad_mod"),
        format!("{mpn}.altium.kicad_mod"),
        format!("{mpn}.manufacturer.kicad_mod"),
    ] {
        let p = dir.join(&cand);
        if p.is_file() {
            return Some(p);
        }
    }
    std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).find(|p| {
        p.extension().map(|x| x == "kicad_mod").unwrap_or(false)
    })
}

/// Every chip in the library (a `<MPN>/` dir with at least one `.kicad_mod`),
/// sorted, with the currently-loaded one flagged. Powers the left library rail.
fn list_chips() -> Vec<(String, bool)> {
    let root = match library_root() {
        Some(r) => r,
        None => return vec![],
    };
    let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            let p = e.path();
            if !p.is_dir() {
                continue;
            }
            let name = e.file_name().to_string_lossy().to_string();
            if footprint_file_for(&p, &name).is_some() {
                let is_cur = name == cur;
                out.push((name, is_cur));
            }
        }
    }
    out.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
    out
}

/// Load a chip's footprint from the library into app state (rendered with the
/// solder-paste group baked, like /load). Returns false if not found.
fn load_chip(mpn: &str) -> bool {
    let root = match library_root() {
        Some(r) => r,
        None => return false,
    };
    let dir = std::path::Path::new(&root).join(mpn);
    let file = match footprint_file_for(&dir, mpn) {
        Some(f) => f,
        None => return false,
    };
    let content = match std::fs::read_to_string(&file) {
        Ok(c) => c,
        Err(_) => return false,
    };
    let name = crate::first_footprint_name(&content).unwrap_or_else(|| mpn.to_string());
    match crate::render::render_footprint_svg_with_paste(&content, crate::bbox_from_mod(&content)) {
        Ok(svg) => {
            *LOADED_PATH.lock().unwrap() = Some(file.to_string_lossy().to_string());
            set_state(AppState {
                footprint_name: name,
                svg,
                pad_count: content.matches("(pad ").count(),
                description: String::new(),
                tags: String::new(),
                kicad_mod: content,
                mpn: mpn.to_string(),
            });
            true
        }
        Err(_) => false,
    }
}

/// The 3D preview PNG for the current chip (chip-thumbnailer output), if any.
fn chip_3d_png() -> Option<Vec<u8>> {
    let root = library_root()?;
    let mpn = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();
    if mpn.is_empty() {
        return None;
    }
    let dir = std::path::Path::new(&root).join(&mpn);
    for cand in [
        format!("{mpn}-3d-iso-named.png"),
        format!("{mpn}-3d-iso.png"),
        format!("{mpn}-3d-iso-lg.png"),
    ] {
        if let Ok(b) = std::fs::read(dir.join(&cand)) {
            return Some(b);
        }
    }
    None
}

/// Chip metadata for the right-panel "Chip" details — so the user can see WHAT
/// part they're working on, not just an anonymous pad pattern. Merged from the
/// library's `<mpn>-footprint.extracted.json` and `info.json`.
fn chip_details(mpn: &str) -> Value {
    let root = match library_root() {
        Some(r) => r,
        None => return Value::Null,
    };
    if mpn.is_empty() {
        return Value::Null;
    }
    let dir = std::path::Path::new(&root).join(mpn);
    let read_json = |name: String| -> Value {
        std::fs::read_to_string(dir.join(name))
            .ok()
            .and_then(|s| serde_json::from_str::<Value>(&s).ok())
            .unwrap_or(Value::Null)
    };
    let ext = read_json(format!("{mpn}-footprint.extracted.json"));
    let info = read_json("info.json".to_string());
    // Prefer the extraction, fall back to info.json.
    let pick = |k: &str| -> Value {
        let a = ext.get(k).cloned().unwrap_or(Value::Null);
        if a.is_null() { info.get(k).cloned().unwrap_or(Value::Null) } else { a }
    };
    let has3d = dir.join(format!("{mpn}-3d-iso-named.png")).is_file()
        || dir.join(format!("{mpn}-3d-iso.png")).is_file();
    json!({
        "mpn": mpn,
        "manufacturer": pick("manufacturer"),
        "package": ext.get("package").cloned().unwrap_or(Value::Null),
        "bodyDimensions": ext.get("bodyDimensions").cloned().unwrap_or(Value::Null),
        "padCount": ext.get("padCount").cloned().unwrap_or(Value::Null),
        "family": info.get("family").cloned().unwrap_or(Value::Null),
        "description": pick("description"),
        "datasheet": info.get("datasheet_url").cloned().unwrap_or(Value::Null),
        "has3d": has3d,
    })
}

fn state_json() -> String {
    match &*STATE.lock().unwrap() {
        Some(s) => {
            let aps = crate::paste::paste_apertures(&s.kicad_mod);
            let dot_count = crate::paste::paste_overlay_svg(&aps, 0.0, 0.0).2;
            let (minx, miny, maxx, maxy) = crate::bbox_from_mod(&s.kicad_mod);
            json!({
                "footprintName": s.footprint_name, "svg": s.svg, "padCount": s.pad_count,
                "description": s.description, "tags": s.tags, "hasMod": !s.kicad_mod.is_empty(),
                "mpn": s.mpn,
                "pasteApertures": aps.len(),
                "pasteDots": dot_count,
                // For the >NAME/>VALUE editor: current text positions + the
                // keep-out bbox (pads ∪ rects) the Auto-layout button clears.
                "bbox": { "minx": minx, "miny": miny, "maxx": maxx, "maxy": maxy },
                "text": crate::fp_text_state(&s.kicad_mod),
                // For the right-panel "Chip" details (what part is this?).
                "chip": chip_details(&s.mpn),
                // Whether the placement editor can persist to disk (loaded from a file).
                "canSave": LOADED_PATH.lock().unwrap().is_some() && !s.kicad_mod.is_empty(),
            })
            .to_string()
        }
        None => "{}".to_string(),
    }
}

pub fn run(port: u16) -> Result<()> {
    *EVAL_RESULTS.lock().unwrap() = Some(HashMap::new());
    *CONSOLE.lock().unwrap() = Some(Vec::new());
    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
    eprintln!("[adom-footprint] serving on http://127.0.0.1:{port}");
    register_port(port, "adom-footprint", "PCB footprint viewer live app (AI-drivable)");

    for mut req in server.incoming_requests() {
        let url = req.url().split('?').next().unwrap_or("/").to_string();
        let method = req.method().clone();
        let mut body = String::new();
        if matches!(method, Method::Post) {
            let _ = req.as_reader().read_to_string(&mut body);
        }
        let resp = route(&method, &url, &body);
        let _ = req.respond(resp);
        if url == "/shutdown" && matches!(method, Method::Post) {
            break;
        }
    }
    unregister_port(port);
    Ok(())
}

/// Best-effort HD port registration (app-creator §6a / adom-port-hints).
/// Raw TCP so we add no HTTP-client dependency; never blocks serving, never
/// fails the app if HD is absent (outside Hydrogen Desktop).
fn register_port(port: u16, name: &str, reason: &str) {
    let body = format!(
        "{{\"port\":{port},\"action\":\"register\",\"name\":\"{name}\",\"reason\":\"{reason}\",\"expose\":\"internal\"}}"
    );
    std::thread::spawn(move || hd_post(&body));
}
fn unregister_port(port: u16) {
    hd_post(&format!("{{\"port\":{port},\"action\":\"unregister\"}}"));
}
fn hd_post(body: &str) {
    use std::io::Write;
    use std::net::{TcpStream, ToSocketAddrs};
    use std::time::Duration;
    let addr = match ("host.docker.internal", 47084u16).to_socket_addrs().ok().and_then(|mut a| a.next()) {
        Some(a) => a,
        None => return,
    };
    if let Ok(mut s) = TcpStream::connect_timeout(&addr, Duration::from_millis(800)) {
        let _ = s.set_write_timeout(Some(Duration::from_millis(800)));
        let req = format!(
            "POST /port-forward HTTP/1.1\r\nHost: host.docker.internal\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            body.len(),
            body
        );
        let _ = s.write_all(req.as_bytes());
    }
}

fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    match (method, url) {
        (Method::Get, "/") | (Method::Get, "/index.html") => html(&app_html()),
        // The standalone interactive embed for the currently-loaded footprint —
        // opened by the app's "Embed" button (new tab), the same lean viewer the
        // `embed` CLI writes to a file.
        (Method::Get, "/embed") => {
            let (name, km) = {
                let g = STATE.lock().unwrap();
                match g.as_ref() {
                    Some(s) if !s.kicad_mod.is_empty() => (s.footprint_name.clone(), s.kicad_mod.clone()),
                    _ => (String::new(), String::new()),
                }
            };
            if km.is_empty() {
                html("<!doctype html><meta charset=utf-8><body style=\"background:#0d1117;color:#8b949e;font-family:sans-serif;padding:24px\">No footprint loaded to embed.</body>")
            } else {
                match crate::build_embed_html(&km, &name) {
                    Ok(h) => html(&h),
                    Err(e) => { let _ = e; html("<!doctype html><body>embed render failed</body>") }
                }
            }
        }
        (Method::Get, "/favicon.svg") => svg(FAVICON),
        (Method::Get, "/state") => json_resp(&state_json()),
        // Left library rail: list every chip in the chip-fetcher library, mirror its
        // project groupings, and load a chosen chip's footprint.
        (Method::Get, "/api/chips") => {
            let arr: Vec<Value> = list_chips().into_iter().map(|(m, c)| json!({ "mpn": m, "current": c })).collect();
            let cur = STATE.lock().unwrap().as_ref().map(|s| s.mpn.clone()).unwrap_or_default();
            json_resp(&json!({ "chips": arr, "current": cur }).to_string())
        }
        (Method::Get, "/api/projects") => {
            let home = std::env::var("HOME").unwrap_or_default();
            let path = std::path::Path::new(&home).join(".config/adom-chip-fetcher/projects.json");
            json_resp(&std::fs::read_to_string(&path).unwrap_or_else(|_| "{\"projects\":{}}".to_string()))
        }
        // The current chip's 3D preview PNG (serves state.mpn; the client cache-busts
        // with a ?mpn= query, which we ignore — the path is what's routed).
        (Method::Get, "/api/chip3d") => match chip_3d_png() {
            Some(b) => png(b),
            None => not_found(),
        },
        (Method::Post, "/load-chip") => {
            let mpn = serde_json::from_str::<Value>(body).ok()
                .and_then(|v| v.get("mpn").and_then(|x| x.as_str()).map(String::from))
                .unwrap_or_default();
            if mpn.is_empty() {
                json_err(400, "mpn required")
            } else if load_chip(&mpn) {
                json_resp(&json!({ "ok": true, "mpn": mpn }).to_string())
            } else {
                json_err(404, "chip not found in library")
            }
        }
        (Method::Post, "/load") => {
            // An in-memory push has no source file → can't /save back to disk.
            *LOADED_PATH.lock().unwrap() = None;
            if let Ok(v) = serde_json::from_str::<Value>(body) {
                // Preferred: push raw .kicad_mod; the server renders it via
                // service-kicad (mirrors adom-step's /api/load). This is how
                // chip-fetcher drives the app — push a file, it renders.
                if let Some(km) = v.get("kicadMod").and_then(|x| x.as_str()) {
                    let name = v
                        .get("footprintName")
                        .and_then(|x| x.as_str())
                        .map(String::from)
                        .or_else(|| crate::first_footprint_name(km))
                        .unwrap_or_else(|| "footprint".to_string());
                    // Bake the paste group so the live Layers panel's InstaPCB
                    // toggle can show/hide it — but the client defaults it OFF
                    // (app.html: paste-dots is opt-in), so the initial view is clean.
                    match crate::render::render_footprint_svg_with_paste(km, crate::bbox_from_mod(km)) {
                        Ok(svg) => set_state(AppState {
                            footprint_name: name,
                            svg,
                            pad_count: km.matches("(pad ").count(),
                            description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                            tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                            kicad_mod: km.to_string(),
                            mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        }),
                        Err(e) => return json_err(422, &e.to_string()),
                    }
                } else {
                    set_state(AppState {
                        footprint_name: v.get("footprintName").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        svg: v.get("svg").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        pad_count: v.get("padCount").and_then(|x| x.as_u64()).unwrap_or(0) as usize,
                        description: v.get("description").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        tags: v.get("tags").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        kicad_mod: v.get("kicadMod").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                        mpn: v.get("mpn").and_then(|x| x.as_str()).unwrap_or("").to_string(),
                    });
                }
            }
            json_resp("{\"ok\":true}")
        }
        (Method::Post, "/eval") => {
            let code = serde_json::from_str::<Value>(body)
                .ok()
                .and_then(|v| v.get("code").and_then(|c| c.as_str()).map(String::from))
                .unwrap_or_default();
            let id = EVAL_ID.fetch_add(1, Ordering::SeqCst);
            EVAL_PENDING.lock().unwrap().push_back((id, code));
            json_resp(&json!({ "id": id.to_string() }).to_string())
        }
        (Method::Get, "/eval/pending") => match EVAL_PENDING.lock().unwrap().pop_front() {
            Some((id, code)) => json_resp(&json!({ "id": id.to_string(), "code": code }).to_string()),
            None => json_resp("{}"),
        },
        (Method::Get, "/console") => {
            let log = CONSOLE.lock().unwrap().clone().unwrap_or_default();
            json_resp(&json!({ "lines": log }).to_string())
        }
        (Method::Post, "/console") => {
            if let Ok(v) = serde_json::from_str::<Value>(body) {
                let line = format!(
                    "[{}] {}",
                    v.get("level").and_then(|l| l.as_str()).unwrap_or("log"),
                    v.get("msg").and_then(|m| m.as_str()).unwrap_or("")
                );
                if let Some(buf) = CONSOLE.lock().unwrap().as_mut() {
                    buf.push(line);
                    let len = buf.len();
                    if len > 500 {
                        buf.drain(0..len - 500);
                    }
                }
            }
            json_resp("{\"ok\":true}")
        }
        (Method::Post, "/deliver") => {
            let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));
            let target = v.get("target").and_then(|x| x.as_str()).unwrap_or("kicad").to_string();
            let (name, km) = match &*STATE.lock().unwrap() {
                Some(s) => (s.footprint_name.clone(), s.kicad_mod.clone()),
                None => (String::new(), String::new()),
            };
            json_resp(&crate::deliver::deliver(&name, &km, &target).to_string())
        }
        // Bake new >NAME/>VALUE positions into the loaded .kicad_mod so delivery
        // and export carry the placement the user set in the editor.
        (Method::Post, "/text") => {
            let v = serde_json::from_str::<Value>(body).unwrap_or(json!({}));
            let num = |k: &str, sub: &str| v.get(k).and_then(|o| o.get(sub)).and_then(|n| n.as_f64());
            match (num("reference", "x"), num("reference", "y"), num("value", "x"), num("value", "y")) {
                (Some(rx), Some(ry), Some(vx), Some(vy)) => {
                    let mut st = STATE.lock().unwrap();
                    match st.as_mut() {
                        Some(s) if !s.kicad_mod.is_empty() => {
                            s.kicad_mod = crate::rewrite_fp_text(&s.kicad_mod, rx, ry, vx, vy);
                            // Optional font-size normalization (Auto-layout scales tiny parts).
                            if let Some(rs) = num("reference", "size") {
                                s.kicad_mod = crate::set_fp_text_size(&s.kicad_mod, "reference", rs);
                            }
                            if let Some(vs) = num("value", "size") {
                                s.kicad_mod = crate::set_fp_text_size(&s.kicad_mod, "value", vs);
                            }
                            json_resp(&json!({ "ok": true, "text": crate::fp_text_state(&s.kicad_mod) }).to_string())
                        }
                        _ => json_err(422, "no .kicad_mod loaded to edit"),
                    }
                }
                _ => json_err(400, "need reference.{x,y} and value.{x,y}"),
            }
        }
        // Persist the current (text-edited) .kicad_mod back to its library file, so
        // placement changes stick per-footprint. Backs the original up once to
        // `<file>.orig` before the first overwrite.
        (Method::Post, "/save") => {
            let path = LOADED_PATH.lock().unwrap().clone();
            let km = STATE.lock().unwrap().as_ref().map(|s| s.kicad_mod.clone()).unwrap_or_default();
            match path {
                Some(p) if !km.is_empty() => {
                    let orig = format!("{p}.orig");
                    if !std::path::Path::new(&orig).exists() {
                        if let Ok(existing) = std::fs::read(&p) {
                            let _ = std::fs::write(&orig, existing);
                        }
                    }
                    match std::fs::write(&p, &km) {
                        Ok(_) => json_resp(&json!({ "ok": true, "path": p }).to_string()),
                        Err(e) => json_err(500, &e.to_string()),
                    }
                }
                _ => json_err(422, "no file to save (footprint was pushed, not loaded from disk)"),
            }
        }
        (Method::Post, "/shutdown") => json_resp("{\"ok\":true}"),
        _ if url.starts_with("/eval/") => {
            let rest = &url["/eval/".len()..];
            if let Some(id_str) = rest.strip_suffix("/result") {
                if matches!(method, Method::Post) {
                    if let (Ok(id), Ok(v)) = (id_str.parse::<u64>(), serde_json::from_str::<Value>(body)) {
                        if let Some(map) = EVAL_RESULTS.lock().unwrap().as_mut() {
                            map.insert(id, v);
                        }
                    }
                    return json_resp("{\"ok\":true}");
                }
            }
            if let Ok(id) = rest.parse::<u64>() {
                let map = EVAL_RESULTS.lock().unwrap();
                if let Some(r) = map.as_ref().and_then(|m| m.get(&id)) {
                    return json_resp(&json!({ "done": true, "result": r }).to_string());
                }
                return json_resp("{\"done\":false}");
            }
            not_found()
        }
        _ => not_found(),
    }
}

fn hdr(ct: &str) -> Header {
    Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap()
}
fn html(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))
}
fn svg(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(hdr("image/svg+xml"))
}
fn json_resp(s: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string(s).with_header(hdr("application/json"))
}
fn png(data: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_data(data).with_header(hdr("image/png"))
}
fn not_found() -> Response<std::io::Cursor<Vec<u8>>> {
    Response::from_string("{\"error\":\"not found\"}").with_status_code(404).with_header(hdr("application/json"))
}
fn json_err(code: u16, msg: &str) -> Response<std::io::Cursor<Vec<u8>>> {
    let body = json!({ "ok": false, "error": msg }).to_string();
    Response::from_string(body).with_status_code(code).with_header(hdr("application/json"))
}