//! Thin HTTP client to service-step2glb. No CAD logic lives here.
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;
use std::time::Duration;

/// Baked-in default. Override at runtime with `STEP2GLB_SERVICE_API`.
/// Canonical shared instance (john/service-step2glb container). The service-occt
/// repo rename is repo-only; the occt-fbnsqrdkehdj container is an untargeted hot
/// spare — do not repoint here without a fleet-wide migration (wiki server +
/// docs + this CLI together).
pub const DEFAULT_SERVICE_URL: &str = "https://step2glb-gmdoncpxdwx0.adom.cloud";

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

fn client_id() -> String {
    std::env::var("STEP2GLB_CLIENT")
        .unwrap_or_else(|_| format!("step2glb-cli/{}", env!("CARGO_PKG_VERSION")))
}

fn set_identity(req: ureq::Request, job_name: &str) -> ureq::Request {
    req.set("X-Client", &client_id())
       .set("X-Job-Name", job_name)
}

#[derive(Debug, Deserialize)]
pub struct Health {
    pub ok: bool,
    pub service: String,
    pub version: String,
    pub uptime_seconds: u64,
    pub converts: u64,
    pub kicad_base: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Material {
    pub name: Option<String>,
    #[serde(rename = "baseColor")]
    pub base_color: Option<Vec<f64>>,
    pub metal: Option<f64>,
    pub roughness: Option<f64>,
}

#[derive(Debug, Clone)]
pub struct ConvertResult {
    pub size_bytes: u64,
    pub meshes: u32,
    pub nodes: u32,
    pub materials: Vec<Material>,
    pub duration_ms: u64,
}

#[derive(Debug, Clone)]
pub struct ThumbnailResult {
    pub size_bytes: u64,
    pub width: u32,
    pub height: u32,
    pub pose: String,
    pub src_kind: String,
    pub up_axis: String,
    pub bbox_mm: Option<serde_json::Value>,
    pub duration_ms: u64,
}

pub fn health() -> Result<Health, String> {
    let url = format!("{}/health", service_url());
    let r = ureq::get(&url).timeout(Duration::from_secs(5)).call()
        .map_err(|e| format!("GET {url} failed: {e}"))?;
    r.into_json::<Health>().map_err(|e| format!("health JSON parse: {e}"))
}

/// POST /convert with STEP body. Gzips large files to speed upload.
pub fn convert_bytes(step: &[u8], out_path: &Path) -> Result<ConvertResult, String> {
    let job_name = format!("convert-{}", out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));
    let url = format!("{}/convert", service_url());
    let size_mb = step.len() as f64 / (1024.0 * 1024.0);

    // Gzip large STEP files — they're text and compress 5x, dramatically
    // reducing upload time and avoiding proxy timeouts.
    let (body, encoding): (Vec<u8>, Option<&str>) = if size_mb > 10.0 {
        use std::io::Write;
        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        encoder.write_all(step).map_err(|e| format!("gzip: {e}"))?;
        let compressed = encoder.finish().map_err(|e| format!("gzip finish: {e}"))?;
        eprintln!("Compressing {:.1}MB → {:.1}MB for upload", size_mb, compressed.len() as f64 / (1024.0 * 1024.0));
        (compressed, Some("gzip"))
    } else {
        (step.to_vec(), None)
    };

    let mut req = set_identity(ureq::post(&url), &job_name)
        .set("Content-Type", "application/step")
        .timeout(Duration::from_secs(300));
    if let Some(enc) = encoding {
        req = req.set("Content-Encoding", enc);
    }
    let r = req.send_bytes(&body)
        .map_err(|e| format!("POST {url} failed: {e}"))?;
    write_response(r, out_path)
}

/// Molecule-mode conversion options (#99). `board` is (filename, bytes) — the
/// server sniffs .kicad_pcb vs .brd from the content; the filename just aids
/// logs. Silk PNGs are white-on-transparent overlays.
pub struct MoleculeOpts {
    pub board: Option<(String, Vec<u8>)>,
    pub pin: String,
    pub silk_top: Option<Vec<u8>>,
    pub silk_bottom: Option<Vec<u8>>,
}

/// Molecule job outcome: the GLB stats, the FULL job-status JSON (anchor/gold/
/// footprint stats — printed generically so new service fields surface without
/// a CLI rebuild), and which side artifacts were written beside the GLB.
pub struct MoleculeResult {
    pub convert: ConvertResult,
    pub stats: serde_json::Value,
    pub footprint_path: Option<std::path::PathBuf>,
    pub symbol_path: Option<std::path::PathBuf>,
}

/// POST /convert?molecule=true&pin=… as multipart (step + board + silks), poll
/// the job, download the GLB and — when the job produced them — the footprint
/// and symbol JSON beside it (`<stem>_footprint.json` / `<stem>_symbol.json`).
pub fn convert_molecule(step: &[u8], opts: &MoleculeOpts, out_path: &Path) -> Result<MoleculeResult, String> {
    let job_name = format!(
        "molecule-{}",
        out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown")
    );
    let url = format!(
        "{}/convert?molecule=true&pin={}",
        service_url(),
        urlencode(&opts.pin)
    );

    let boundary = "----step2glb-molecule-7f9c2ad41b";
    let mut body: Vec<u8> = Vec::new();
    let mut add_part = |name: &str, filename: &str, bytes: &[u8], body: &mut Vec<u8>| {
        body.extend_from_slice(
            format!(
                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
            )
            .as_bytes(),
        );
        body.extend_from_slice(bytes);
        body.extend_from_slice(b"\r\n");
    };
    add_part("step", "in.step", step, &mut body);
    if let Some((fname, b)) = &opts.board {
        add_part("board", fname, b, &mut body);
    }
    if let Some(b) = &opts.silk_top {
        add_part("silk_top", "silk_top.png", b, &mut body);
    }
    if let Some(b) = &opts.silk_bottom {
        add_part("silk_bottom", "silk_bottom.png", b, &mut body);
    }
    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());

    let r = set_identity(ureq::post(&url), &job_name)
        .set(
            "Content-Type",
            &format!("multipart/form-data; boundary={boundary}"),
        )
        .timeout(Duration::from_secs(300))
        .send_bytes(&body)
        .map_err(|e| format!("POST {url} failed: {e}"))?;

    let status = r.status();
    let body_s = r.into_string().unwrap_or_default();
    if status != 202 && status != 200 {
        return Err(format!("service returned HTTP {status}: {body_s}"));
    }
    let job: serde_json::Value =
        serde_json::from_str(&body_s).map_err(|_| format!("unexpected response: {body_s}"))?;
    let job_id = job["job_id"]
        .as_str()
        .ok_or_else(|| format!("no job_id in response: {body_s}"))?;
    eprintln!("Molecule conversion queued (job {job_id}). Polling...");
    poll_molecule_job(job_id, out_path)
}

fn poll_molecule_job(job_id: &str, out_path: &Path) -> Result<MoleculeResult, String> {
    let base = service_url();
    let poll_url = format!("{base}/jobs/{job_id}");

    for round in 1..=240 {
        // 240 × 5s = 20 minutes; molecule boards convert in well under that.
        std::thread::sleep(Duration::from_secs(5));
        let r = ureq::get(&poll_url)
            .timeout(Duration::from_secs(15))
            .call()
            .map_err(|e| format!("poll {poll_url} failed: {e}"))?;
        let status: serde_json::Value =
            serde_json::from_str(&r.into_string().unwrap_or_default()).unwrap_or_default();

        match status["status"].as_str().unwrap_or("unknown") {
            "complete" => {
                let result_url = format!("{base}/jobs/{job_id}/result");
                let r = ureq::get(&result_url)
                    .timeout(Duration::from_secs(120))
                    .call()
                    .map_err(|e| format!("GET {result_url} failed: {e}"))?;
                if r.status() != 200 {
                    let b = r.into_string().unwrap_or_default();
                    return Err(format!("result download HTTP error: {b}"));
                }
                let convert = read_glb_response(r, out_path)?;
                let footprint_path = fetch_side_artifact(&base, job_id, "footprint", out_path);
                let symbol_path = fetch_side_artifact(&base, job_id, "symbol", out_path);
                return Ok(MoleculeResult {
                    convert,
                    stats: status,
                    footprint_path,
                    symbol_path,
                });
            }
            "failed" => {
                let e = status["error"].as_str().unwrap_or("unknown error");
                return Err(format!("molecule job {job_id} failed: {e}"));
            }
            st => {
                if round % 6 == 0 {
                    eprintln!("  [{round}] still {st}...");
                }
            }
        }
    }
    Err(format!("molecule job {job_id} timed out after 20 minutes"))
}

/// GET /jobs/{id}/footprint|symbol → write `<glb stem>_<kind>.json` beside the
/// GLB. 404 (job produced none — e.g. no board attached) → None, not an error.
fn fetch_side_artifact(
    base: &str,
    job_id: &str,
    kind: &str,
    out_glb: &Path,
) -> Option<std::path::PathBuf> {
    let url = format!("{base}/jobs/{job_id}/{kind}");
    let r = ureq::get(&url).timeout(Duration::from_secs(30)).call().ok()?;
    if r.status() != 200 {
        return None;
    }
    let stem = out_glb.file_stem()?.to_str()?.to_string();
    let path = out_glb.with_file_name(format!("{stem}_{kind}.json"));
    let body = r.into_string().ok()?;
    std::fs::write(&path, body).ok()?;
    Some(path)
}

/// POST /features with STEP body. Returns the features JSON as a string
/// (the service emits the canonical format directly; we don't re-shape it
/// here so future schema additions land without a CLI rebuild).
pub fn extract_features(step: &[u8]) -> Result<String, String> {
    let url = format!("{}/features", service_url());
    let r = set_identity(ureq::post(&url), "extract-features")
        .set("Content-Type", "application/step")
        .timeout(Duration::from_secs(60))
        .send_bytes(step)
        .map_err(|e| format!("POST {url} failed: {e}"))?;
    let status = r.status();
    if status != 200 {
        let body = r.into_string().unwrap_or_default();
        return Err(format!("service returned HTTP {status}: {body}"));
    }
    r.into_string().map_err(|e| format!("read body: {e}"))
}

/// POST /thumbnail with STEP or GLB body. Streams the PNG to `out_path` and
/// parses the X-* response headers into a `ThumbnailResult`. The service
/// magic-byte sniffs by default; pass `kind` to force ("step", "glb", or
/// "auto").
pub fn thumbnail_bytes(
    body: &[u8],
    out_path: &Path,
    width: u32,
    height: u32,
    pose: &str,
    kind: &str,
    up_axis: &str,
) -> Result<ThumbnailResult, String> {
    // Pick the Content-Type from the kind hint so HTTP middleware /
    // proxies have a real type to log. The body content always wins on
    // the server side via magic-byte sniff.
    let content_type = match kind {
        "glb" => "model/gltf-binary",
        _ => "application/step",
    };
    let url = format!(
        "{}/thumbnail?width={}&height={}&pose={}&kind={}&upAxis={}",
        service_url(),
        width,
        height,
        urlencode(pose),
        urlencode(kind),
        urlencode(up_axis),
    );
    let job_name = format!("thumbnail-{}-{}", pose, out_path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"));
    let r = set_identity(ureq::post(&url), &job_name)
        .set("Content-Type", content_type)
        .timeout(Duration::from_secs(180))
        .send_bytes(body)
        .map_err(|e| format!("POST {url} failed: {e}"))?;

    let status = r.status();
    if status != 200 {
        let resp_body = r.into_string().unwrap_or_default();
        return Err(format!("service returned HTTP {status}: {resp_body}"));
    }

    let width_h = header_u32(&r, "X-Width");
    let height_h = header_u32(&r, "X-Height");
    let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));
    let pose_h = r.header("X-Pose").unwrap_or(pose).to_string();
    let src_kind_h = r.header("X-Src-Kind").unwrap_or(kind).to_string();
    let up_axis_h = r.header("X-Up-Axis").unwrap_or(up_axis).to_string();
    let bbox_mm = r
        .header("X-Bbox-Mm")
        .and_then(|s| serde_json::from_str(s).ok());

    let mut file = std::fs::File::create(out_path)
        .map_err(|e| format!("create {}: {e}", out_path.display()))?;
    let mut reader = r.into_reader();
    let size_bytes = std::io::copy(&mut reader, &mut file)
        .map_err(|e| format!("write {}: {e}", out_path.display()))?;
    file.flush().ok();

    Ok(ThumbnailResult {
        size_bytes,
        width: width_h,
        height: height_h,
        pose: pose_h,
        src_kind: src_kind_h,
        up_axis: up_axis_h,
        bbox_mm,
        duration_ms,
    })
}

#[derive(Debug, Clone, Deserialize)]
pub struct BatchThumbnailEntry {
    #[serde(rename = "upAxis")]
    pub up_axis: String,
    pub size: String,
    pub width: u32,
    pub height: u32,
    #[serde(rename = "sizeBytes")]
    pub size_bytes: u64,
    #[serde(rename = "dataB64")]
    pub data_b64: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct BatchThumbnailResult {
    pub ok: bool,
    #[serde(rename = "srcKind")]
    pub src_kind: String,
    pub pose: String,
    #[serde(rename = "bboxMm")]
    pub bbox_mm: Option<serde_json::Value>,
    #[serde(rename = "loadDurationMs")]
    pub load_duration_ms: u64,
    #[serde(rename = "totalDurationMs")]
    pub total_duration_ms: u64,
    #[serde(rename = "thumbnailCount")]
    pub thumbnail_count: u32,
    pub thumbnails: Vec<BatchThumbnailEntry>,
    #[serde(default, rename = "renderWarnings")]
    pub render_warnings: Vec<String>,
}

/// POST /thumbnail-batch with STEP body — load STEP once, render N×M variants
/// in one V3d session. ~5–10× faster than per-variant /thumbnail calls.
pub fn thumbnail_batch_bytes(
    body: &[u8],
    orientations: &str,
    sizes: &str,
    pose: &str,
) -> Result<BatchThumbnailResult, String> {
    let url = format!(
        "{}/thumbnail-batch?orientations={}&sizes={}&pose={}&kind=step",
        service_url(),
        urlencode(orientations),
        urlencode(sizes),
        urlencode(pose),
    );
    let r = set_identity(ureq::post(&url), "thumbnail-batch")
        .set("Content-Type", "application/step")
        .timeout(Duration::from_secs(300))
        .send_bytes(body)
        .map_err(|e| format!("POST {url} failed: {e}"))?;
    let status = r.status();
    if status != 200 {
        let body = r.into_string().unwrap_or_default();
        return Err(format!("service returned HTTP {status}: {body}"));
    }
    r.into_json::<BatchThumbnailResult>()
        .map_err(|e| format!("batch JSON parse: {e}"))
}

/// GET /convert?library=<L>&name=<N> — service fetches STEP from service-kicad
/// and converts in one round-trip.
pub fn convert_from_library(lib_slash_name: &str, out_path: &Path) -> Result<ConvertResult, String> {
    let (lib, name) = lib_slash_name.rsplit_once('/').ok_or_else(|| {
        format!("bad path {lib_slash_name:?}; expected <Library>/<Name>")
    })?;
    let url = format!(
        "{}/convert?library={}&name={}",
        service_url(),
        urlencode(lib),
        urlencode(name),
    );
    let r = ureq::get(&url)
        .timeout(Duration::from_secs(120))
        .call()
        .map_err(|e| format!("GET {url} failed: {e}"))?;
    write_response(r, out_path)
}

fn write_response(r: ureq::Response, out_path: &Path) -> Result<ConvertResult, String> {
    let status = r.status();

    // Async job queue: server returns 202 with {job_id, poll} for large files.
    // Poll until complete, then download the result.
    if status == 202 {
        let body = r.into_string().unwrap_or_default();
        let job: serde_json::Value = serde_json::from_str(&body)
            .map_err(|_| format!("async 202 but invalid JSON: {body}"))?;
        let job_id = job["job_id"].as_str()
            .ok_or_else(|| format!("async 202 but no job_id: {body}"))?;
        eprintln!("Large file queued for async conversion (job {job_id}). Polling...");
        return poll_job(job_id, out_path);
    }

    if status != 200 {
        let body = r.into_string().unwrap_or_default();
        return Err(format!("service returned HTTP {status}: {body}"));
    }
    read_glb_response(r, out_path)
}

fn poll_job(job_id: &str, out_path: &Path) -> Result<ConvertResult, String> {
    let base = service_url();
    let poll_url = format!("{base}/jobs/{job_id}");
    let result_url = format!("{base}/jobs/{job_id}/result");

    for round in 1..=120 {  // up to 60 minutes (120 × 30s)
        std::thread::sleep(Duration::from_secs(30));
        let r = ureq::get(&poll_url).timeout(Duration::from_secs(15)).call()
            .map_err(|e| format!("poll {poll_url} failed: {e}"))?;
        let body = r.into_string().unwrap_or_default();
        let status: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
        let st = status["status"].as_str().unwrap_or("unknown");

        match st {
            "complete" => {
                let dur = status["duration_seconds"].as_f64().unwrap_or(0.0);
                eprintln!("Job {job_id} complete ({dur:.0}s). Downloading GLB...");
                let r = ureq::get(&result_url).timeout(Duration::from_secs(120)).call()
                    .map_err(|e| format!("GET {result_url} failed: {e}"))?;
                let st = r.status();
                if st != 200 {
                    let body = r.into_string().unwrap_or_default();
                    return Err(format!("result download HTTP {st}: {body}"));
                }
                return read_glb_response(r, out_path);
            }
            "failed" => {
                let err = status["error"].as_str().unwrap_or("unknown error");
                return Err(format!("async job {job_id} failed: {err}"));
            }
            _ => {
                if round % 4 == 0 {
                    eprintln!("  [{round}] still {st}...");
                }
            }
        }
    }
    Err(format!("async job {job_id} timed out after 60 minutes of polling"))
}

fn read_glb_response(r: ureq::Response, out_path: &Path) -> Result<ConvertResult, String> {
    let meshes = header_u32(&r, "X-Meshes");
    let nodes = header_u32(&r, "X-Nodes");
    let duration_ms = u64::from(header_u32(&r, "X-Duration-Ms"));
    let mats_json = r.header("X-Materials-Json").unwrap_or("[]").to_string();
    let materials: Vec<Material> = serde_json::from_str(&mats_json).unwrap_or_default();
    // Stream body to disk.
    let mut file = std::fs::File::create(out_path).map_err(|e| format!("create {}: {e}", out_path.display()))?;
    let mut reader = r.into_reader();
    let size_bytes = std::io::copy(&mut reader, &mut file).map_err(|e| format!("write {}: {e}", out_path.display()))?;
    file.flush().ok();
    Ok(ConvertResult { size_bytes, meshes, nodes, materials, duration_ms })
}

fn header_u32(r: &ureq::Response, name: &str) -> u32 {
    r.header(name).and_then(|s| s.parse().ok()).unwrap_or(0)
}

fn urlencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for b in s.bytes() {
        if matches!(b, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{:02X}", b));
        }
    }
    out
}