//! Axum router, handlers, and API endpoints.
//!
//! Route groups:
//!
//! | Group            | Purpose                                               |
//! |------------------|-------------------------------------------------------|
//! | `/`              | Web UI (served from `openpxe-webui`)                 |
//! | `/boot.ipxe`     | Top-level iPXE menu                                   |
//! | `/boot/_*.ipxe`  | Submenu scripts (hierarchy: linux, windows, tools, …) |
//! | `/boot/<id>.ipxe`| Per-entry boot script                                 |
//! | `/ipxe/<file>`   | Bundled iPXE binaries + wimboot + memtest             |
//! | `/iso/<id>.iso`  | Raw ISO with Range support                            |
//! | `/iso/<id>/*`    | Files inside the ISO (for wimboot & kernel/initrd)    |
//! | `/api/*`         | JSON/HTML API for the web UI                          |

use crate::auth as auth_api;
use crate::ipxe_script::{
    render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
    render_queue_entry, render_shell, render_tools_menu, render_util,
};
use crate::iso_fs;
use crate::log_stream;
use crate::state::AppState;
use crate::terminal;
use axum::{
    body::{Body, Bytes},
    extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
    http::{header, HeaderMap, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    routing::{delete, get, post, put},
    Json, Router,
};
use openpxe_core::{
    ext_for_mime, BootEvent, ClientEvent, Error, Settings, SsoConfig, ALLOWED_LOGO_MIMES,
    MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoCategory, IsoMeta, IsoSource, SmbAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::trace::TraceLayer;

pub fn build_router(state: AppState) -> Router {
    Router::new()
        // Web UI (fully offline — no CDN, no external fonts/images).
        .route("/", get(index))
        .route("/assets/app.js", get(ui_js))
        .route("/assets/app.css", get(ui_css))
        .route("/assets/logo.svg", get(ui_logo))
        .route("/assets/loader.svg", get(ui_loader))
        // v0.4.6: PXE menu logo — the raster form of the operator's
        // uploaded mark, served so iPXE's `console --picture` can
        // overlay it on the boot menu. SVG uploads 404 here (iPXE
        // can't rasterize SVG); we deliberately don't bundle a
        // pre-rendered PNG fallback because iPXE's ASCII wordmark
        // banner already provides the always-visible branding.
        .route("/branding/pxe-logo", get(ui_pxe_logo))
        // iPXE script endpoints.
        .route("/boot.ipxe", get(boot_top_menu))
        .route("/boot/:filename", get(boot_sub))
        // Bundled binaries and raw ISO access.
        .route("/ipxe/:name", get(ipxe_binary))
        .route("/iso/:filename", get(iso_raw))
        .route("/iso/:id/*path", get(iso_file))
        // Container health/readiness probes. `/healthz` is always 200 OK
        // while the HTTP task is alive. `/readyz` additionally requires at
        // least one bundled iPXE binary (without one, no client can PXE).
        .route("/healthz", get(healthz))
        .route("/readyz", get(readyz))
        // JSON API.
        .route("/api/isos", get(api_list_isos).post(api_upload_iso))
        .route("/api/isos/:id", delete(api_delete_iso))
        .route("/api/uploads", post(api_upload_begin))
        .route(
            "/api/uploads/:upload_id",
            put(api_upload_chunk).delete(api_upload_abort),
        )
        // Per-ISO password prompt. PUT body `{ "password": "..." }`
        // sets, `{ "password": null }` (or DELETE) clears.
        .route(
            "/api/isos/:id/password",
            axum::routing::put(api_set_iso_password).delete(api_clear_iso_password),
        )
        // v0.4.4: per-ISO menu category (Os / Tools). Drives whether the
        // image appears under Linux/Windows Installers (default) or in
        // the Tools submenu next to memtest / shell / NIC info.
        .route(
            "/api/isos/:id/category",
            axum::routing::put(api_set_iso_category),
        )
        // v0.4.4: filesystem free-space telemetry for the ISO directory's
        // volume — surfaced as a small card on the Storage tab so the
        // operator knows when they're about to run out of room.
        .route("/api/storage/disk", get(api_storage_disk))
        // v0.4.4: operator-controlled WebUI branding overrides
        // (custom logo). Multipart upload to POST; DELETE clears.
        .route(
            "/api/branding/logo",
            post(api_branding_upload).delete(api_branding_clear),
        )
        // v0.4.4: self-rendered API reference, served as JSON so the UI
        // can format it consistently with the rest of the chrome. Lives
        // under the Settings tab — operators chasing an integration get
        // it in-product instead of having to fetch the OpenAPI YAML.
        .route("/api/docs", get(api_docs))
        // v0.4.5: Sonarr/Radarr-style admin Forms auth. First-run
        // /setup creates the single admin account; /login validates;
        // /logout revokes the session; /me powers the front-end's
        // "should I show the setup page, the login page, or the
        // dashboard?" decision. /me/credentials rotates the admin's
        // username/password.
        .route("/api/setup", post(auth_api::api_setup))
        .route("/api/login", post(auth_api::api_login))
        .route("/api/logout", post(auth_api::api_logout))
        .route("/api/me", get(auth_api::api_me))
        .route(
            "/api/me/credentials",
            put(auth_api::api_update_credentials),
        )
        // v0.4.5: SAML SSO configuration (FleetDM-shaped, storage-only).
        // The actual sign-in flow lands in a later release; this just
        // gives operators a place to paste their IdP metadata today.
        .route("/api/sso", get(api_sso_get).put(api_sso_put))
        .route("/api/clients", get(api_list_clients))
        .route("/api/status", get(api_status))
        .route("/api/settings", get(api_get_settings).put(api_put_settings))
        .route("/api/queue", get(api_list_queue))
        .route("/api/queue/join", get(api_queue_join))
        .route("/api/queue/poll/:entry_id", get(api_queue_poll))
        .route("/api/queue/assign", post(api_queue_assign))
        .route("/api/queue/:entry_id", delete(api_queue_release))
        // v0.4.65: SMB share manager (userspace via smbclient). The
        // kernel-mount NFS routes that v0.4.64 shipped are gone — they
        // didn't work on hosts whose kernel lacked the nfs client
        // modules (Unraid), and no container-side configuration could
        // load a host kernel module. `smbclient` speaks SMB over a
        // plain TCP socket in userspace, works in every container.
        .route("/api/smb-shares", get(api_smb_shares_list).post(api_smb_shares_add))
        .route("/api/smb-shares/:id", delete(api_smb_shares_remove))
        .route("/api/smb-shares/:id/scan", post(api_smb_shares_scan))
        // Phase 4: Network info (read-only) + DNS edit.
        .route("/api/network", get(api_network).put(api_network_put))
        // Phase 4: live-log stream + recent buffer for the Terminal tab.
        .route("/api/log/stream", get(log_stream::stream))
        .route("/api/log/recent", get(log_stream::recent))
        .route("/api/log/clear", post(log_stream::clear))
        // Phase 4: operator terminal commands (whitelisted).
        .route("/api/terminal", post(terminal::run_command))
        // Phase 5: per-MAC host bindings. Operator
        // pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
        .route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
        .route("/api/hosts/:mac", delete(api_hosts_remove))
        // Rolling "host log" of boot events: what image actually
        // started installing on what MAC/IP, and when. Persisted to disk.
        .route("/api/boot-log", get(api_boot_log))
        // Phase 5: Prometheus scrape endpoint. Plain text exposition
        // format. No auth — the metrics surface is intentionally
        // boring (counts, no payloads).
        .route("/metrics", get(api_metrics))
        // v0.4.5: Forms-auth middleware. Layered *after* `.route(...)`
        // calls so it applies uniformly; passes everything through when
        // no admin is configured (tests + fresh installs ride this path).
        // The allowlist inside `auth_api::require_auth` keeps PXE-essential
        // endpoints reachable for iPXE clients that can't authenticate.
        .layer(axum::middleware::from_fn_with_state(
            state.clone(),
            auth_api::require_auth,
        ))
        .layer(TraceLayer::new_for_http())
        // 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
        .layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
        .with_state(state)
}

// ─── SSO config endpoints ─────────────────────────────────────────────────

async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
    // We deliberately do not redact the metadata — the operator who's
    // signed in needs to be able to round-trip it. /api/sso requires
    // the auth middleware anyway, so unauthenticated callers can't see
    // it once admin is configured.
    Json(state.sso.snapshot())
}

async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
    match state.sso.replace(body) {
        Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
        Err(Error::Invalid(msg)) => {
            (StatusCode::BAD_REQUEST, msg).into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

// ─── UI ────────────────────────────────────────────────────────────────────

async fn index(State(state): State<AppState>) -> Response {
    // The asset version pin in index.html (`?v=…`) is what makes
    // browsers re-fetch JS/CSS after an upgrade. We use the OpenPXE
    // binary version — every release ships a new value, every release
    // forces a fresh URL on each asset.
    let html = openpxe_webui::index_html(&state.public_base_url, env!("CARGO_PKG_VERSION"));
    (
        [
            (
                header::CONTENT_TYPE,
                HeaderValue::from_static("text/html; charset=utf-8"),
            ),
            // index.html itself must never be cached — that's how the
            // browser learns about a new `?v=…` value for the assets.
            (
                header::CACHE_CONTROL,
                HeaderValue::from_static("no-cache, must-revalidate"),
            ),
        ],
        html,
    )
        .into_response()
}

/// Cache-Control header value used for the bundled JS/CSS/SVG assets.
/// We pin a 1-day TTL so a long-lived deployment doesn't re-fetch the
/// same bytes on every page-load, but require revalidation — combined
/// with the `?v=<version>` query string in index.html, the practical
/// upper bound on caching across an upgrade is "until the operator
/// reloads".
const ASSET_CACHE_CONTROL: HeaderValue =
    HeaderValue::from_static("no-cache, must-revalidate");

async fn ui_js() -> Response {
    (
        [
            (
                header::CONTENT_TYPE,
                HeaderValue::from_static("application/javascript"),
            ),
            (header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
        ],
        openpxe_webui::app_js(),
    )
        .into_response()
}

async fn ui_css() -> Response {
    (
        [
            (header::CONTENT_TYPE, HeaderValue::from_static("text/css")),
            (header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
        ],
        openpxe_webui::app_css(),
    )
        .into_response()
}

async fn ui_logo(State(state): State<AppState>) -> Response {
    // Custom override first; fall back to the bundled rainbow-horizon
    // SVG. We resolve the override on each request rather than caching
    // because operators may upload/clear from the Settings tab while the
    // server is live, and we want them to see their change immediately
    // without bouncing the binary.
    if let Some(path) = state.branding.logo_path() {
        let mime = state
            .branding
            .logo_mime()
            .unwrap_or_else(|| "image/svg+xml".to_string());
        match tokio::fs::read(&path).await {
            Ok(bytes) => {
                let ct = match HeaderValue::from_str(&mime) {
                    Ok(v) => v,
                    Err(_) => HeaderValue::from_static("application/octet-stream"),
                };
                return (
                    [
                        (header::CONTENT_TYPE, ct),
                        (
                            header::CACHE_CONTROL,
                            HeaderValue::from_static("no-cache, max-age=0"),
                        ),
                    ],
                    bytes,
                )
                    .into_response();
            }
            Err(e) => {
                tracing::warn!(
                    target: "openpxe::http::branding",
                    error = %e, "failed to read custom logo; falling back to bundled"
                );
            }
        }
    }
    (
        [
            (
                header::CONTENT_TYPE,
                HeaderValue::from_static("image/svg+xml"),
            ),
            (header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
        ],
        openpxe_webui::logo_svg(),
    )
        .into_response()
}

/// v0.4.61: PXE menu logo composed for the iPXE `console --picture`
/// call. The operator can upload any raster image (PNG / JPEG / WebP /
/// GIF) of any aspect ratio; this handler decodes it, draws it
/// centered-top onto a fixed 1024×768 canvas, and returns PNG bytes.
/// That gives the same look as iVentoy regardless of what the operator
/// uploaded — a portrait logo, a wide wordmark, a square monogram all
/// land in the same place on the boot screen.
///
/// SVG uploads still 404 here — iPXE can't rasterize SVG, and rather
/// than haul in `resvg` we ask the operator to provide a raster when
/// they want a custom PXE-side logo. (The WebUI keeps using the SVG.)
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
    let Some(path) = state.branding.logo_path() else {
        return (StatusCode::NOT_FOUND, "no custom logo configured").into_response();
    };
    let Some(mime) = state.branding.logo_mime() else {
        return (StatusCode::NOT_FOUND, "no mime recorded").into_response();
    };
    if mime == "image/svg+xml" {
        return (
            StatusCode::NOT_FOUND,
            "operator-uploaded logo is SVG; PXE menu requires a raster (PNG / JPEG / WebP / GIF)",
        )
            .into_response();
    }
    let bytes = match tokio::fs::read(&path).await {
        Ok(b) => b,
        Err(e) => {
            return (
                StatusCode::NOT_FOUND,
                format!("custom logo unreadable: {e}"),
            )
                .into_response();
        }
    };
    // Compose to a fixed 1024×768 PNG so the PXE menu paints the logo
    // centered-top regardless of the operator's source dimensions. The
    // `image` crate is pure-Rust + sync; offload to a blocking task
    // because Lanczos resampling on a 4K source can take tens of
    // milliseconds and we don't want to block the executor.
    let composed =
        match tokio::task::spawn_blocking(move || openpxe_iso_store::pxe_logo::compose_pxe_logo(&bytes))
            .await
        {
            Ok(Ok(png)) => png,
            Ok(Err(e)) => {
                tracing::warn!(
                    target: "openpxe::http::branding",
                    error = %e, "failed to compose PXE logo PNG"
                );
                return (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("failed to compose PXE logo: {e}"),
                )
                    .into_response();
            }
            Err(e) => {
                return (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    format!("pxe-logo task failed: {e}"),
                )
                    .into_response();
            }
        };
    (
        [
            (header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
            (header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
        ],
        composed,
    )
        .into_response()
}

async fn ui_loader() -> Response {
    (
        [
            (
                header::CONTENT_TYPE,
                HeaderValue::from_static("image/svg+xml"),
            ),
            (header::CACHE_CONTROL, ASSET_CACHE_CONTROL),
        ],
        openpxe_webui::loader_svg(),
    )
        .into_response()
}

// ─── iPXE scripts ──────────────────────────────────────────────────────────

fn text_plain(body: String) -> Response {
    (
        [(
            header::CONTENT_TYPE,
            HeaderValue::from_static("text/plain; charset=utf-8"),
        )],
        body,
    )
        .into_response()
}

/// Top-level boot script. Honors per-MAC host bindings: if the
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
    State(state): State<AppState>,
    peer: Option<ConnectInfo<SocketAddr>>,
    Query(p): Query<BootMenuParams>,
) -> Response {
    // `ConnectInfo` is only populated when axum was started with
    // `into_make_service_with_connect_info` (production path). Tests
    // call the router via `oneshot`, which skips that wiring — we
    // tolerate it by treating the peer as unknown rather than 500ing.
    let peer_ip = peer.map(|c| c.0.ip());
    state
        .metrics
        .record_http(openpxe_core::HttpRoute::BootScript);
    let isos = state.iso_store.list();
    let settings = state.settings.snapshot();
    let base = &state.public_base_url;

    // Per-MAC override: if the client identified itself and we have a
    // binding, chain directly. The chain target falls back to the menu
    // on failure so a stale / misconfigured binding can't lock a client
    // out — it just shows the menu.
    if let Some(mac) = p.mac.as_deref() {
        if let Some(binding) = state.hosts.lookup(mac) {
            tracing::info!(
                target: "openpxe::http",
                mac = %binding.mac, target = %binding.target,
                "host binding applied"
            );
            // Pre-record the host-binding event. Reserved menu shortcuts
            // (`_local`, `_queue`, …) are operator-driven non-imaging
            // targets — recording them would clutter the Host log with
            // routine console activity, so we skip those and only record
            // for real boot-entry ids.
            if !binding.target.starts_with('_') {
                let title = lookup_entry_title(&isos, &binding.target);
                state.boot_log.record(&BootEvent {
                    timestamp: time::OffsetDateTime::now_utc(),
                    mac: Some(binding.mac.clone()),
                    ip: peer_ip,
                    target_id: binding.target.clone(),
                    target_title: title,
                });
            }
            let target = binding.target;
            let bound_mac = binding.mac;
            // Reserved menu shortcuts are emitted as `_xxx`; per-entry
            // boot scripts are at `/boot/<id>.ipxe`. Both share the same
            // `/boot/<name>` route, so the URL is identical. We forward
            // `?mac=` so the per-entry handler can record the boot into
            // the Host log without depending on iPXE substitution at
            // this stage.
            return text_plain(format!(
                "#!ipxe\n\
                 echo OpenPXE: per-MAC binding -> {target}\n\
                 chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
            ));
        }
    }

    text_plain(render_menu(&isos, &settings, base))
}

/// Best-effort human title for a boot entry id — falls back to the id
/// itself if the ISO has been deleted between record-time and now.
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
    for iso in isos {
        for e in &iso.boot_entries {
            if e.id == target_id {
                // ISO filename plus the entry title gives the operator
                // both "which image" and "which variant" (e.g. wimboot
                // vs sanboot) at a glance.
                return format!("{} — {}", iso.filename, e.title);
            }
        }
    }
    target_id.to_string()
}

#[derive(Debug, Deserialize)]
struct BootMenuParams {
    /// Client MAC, supplied by iPXE via `${mac}` variable in
    /// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
    /// fall back to the menu unconditionally.
    mac: Option<String>,
}

#[derive(Debug, Deserialize)]
struct BootSubParams {
    /// iPXE-supplied password token. Sent by the prompt script as
    /// `?token=${password:uristring}` so special chars survive URL
    /// encoding. Absent on the first request — that's how we know the
    /// client hasn't been prompted yet.
    token: Option<String>,
    /// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
    /// render. Optional — older bookmarks may omit it; the boot log
    /// just records `None` in that case rather than refusing to boot.
    mac: Option<String>,
}

async fn boot_sub(
    State(state): State<AppState>,
    peer: Option<ConnectInfo<SocketAddr>>,
    AxumPath(filename): AxumPath<String>,
    Query(p): Query<BootSubParams>,
) -> Response {
    let peer_ip = peer.map(|c| c.0.ip());
    // `/boot/<name>.ipxe` where `<name>` is either one of our reserved
    // submenu names (prefixed `_`) or a boot entry id.
    let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
    let isos = state.iso_store.list();
    let settings = state.settings.snapshot();
    let base = &state.public_base_url;
    let script = match name {
        "_local" => render_local_hdd(base),
        "_linux_menu" => render_family_menu(&isos, base, false),
        "_windows_menu" => render_family_menu(&isos, base, true),
        "_tools_menu" => render_tools_menu(&isos, base),
        "_util" => render_util(base),
        "_shell" => render_shell(base),
        "_nic" => render_nic_info(base),
        "_queue" => render_queue_entry(base),
        other => {
            for iso in &isos {
                for entry in &iso.boot_entries {
                    if entry.id == other {
                        // Password prompt. If the ISO has a password set
                        // we block the actual boot script behind it:
                        //   - no token         -> render a prompt
                        //   - wrong token      -> render auth-fail
                        //   - correct token    -> serve the boot script
                        // ISO without a password ignores the token
                        // entirely, so per-MAC bookmarks stay simple.
                        if iso.is_password_protected() {
                            match p.token.as_deref() {
                                None | Some("") => {
                                    return text_plain(crate::ipxe_script::render_password_prompt(
                                        &entry.id,
                                        &iso.filename,
                                        base,
                                    ));
                                }
                                Some(token) => {
                                    match state.iso_store.verify_password(&iso.id, token) {
                                        Ok(true) => { /* fall through to render the entry */ }
                                        Ok(false) => {
                                            // Don't log the candidate — just the
                                            // mac (when iPXE supplies one) and
                                            // the entry id, so an operator can
                                            // see brute-force attempts in the
                                            // live log.
                                            tracing::warn!(
                                                target: "openpxe::http::boot",
                                                entry = %other,
                                                "wrong password supplied for protected boot entry"
                                            );
                                            return text_plain(
                                                crate::ipxe_script::render_password_failed(
                                                    &entry.id, base,
                                                ),
                                            );
                                        }
                                        Err(e) => {
                                            tracing::error!(
                                                target: "openpxe::http::boot",
                                                entry = %other, error = %e,
                                                "password verify failed unexpectedly"
                                            );
                                            return (
                                                StatusCode::INTERNAL_SERVER_ERROR,
                                                "password check failed",
                                            )
                                                .into_response();
                                        }
                                    }
                                }
                            }
                        }
                        // Record the boot event. This is the canonical
                        // moment: password gate (if any) passed, and the
                        // script is about to be served — i.e. the client
                        // is genuinely about to start imaging.
                        let mac_normalized = p
                            .mac
                            .as_deref()
                            .map(openpxe_core::normalize_mac)
                            .filter(|m| !m.is_empty());
                        state.boot_log.record(&BootEvent {
                            timestamp: time::OffsetDateTime::now_utc(),
                            mac: mac_normalized,
                            ip: peer_ip,
                            target_id: entry.id.clone(),
                            target_title: format!("{} — {}", iso.filename, entry.title),
                        });
                        return text_plain(render_entry(entry, &settings, base));
                    }
                }
            }
            return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
        }
    };
    text_plain(script)
}

// ─── bundled iPXE binaries (memtest lives here too) ───────────────────────

async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
    if name.contains('/') || name.contains('\\') {
        return (StatusCode::BAD_REQUEST, "invalid name").into_response();
    }
    let Some(bytes) = asset_bytes(&name) else {
        return (StatusCode::NOT_FOUND, "no such ipxe asset").into_response();
    };
    (
        [
            (
                header::CONTENT_TYPE,
                HeaderValue::from_static("application/octet-stream"),
            ),
            (header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
        ],
        bytes,
    )
        .into_response()
}

// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────

async fn iso_raw(
    State(state): State<AppState>,
    AxumPath(filename): AxumPath<String>,
    headers: HeaderMap,
) -> Response {
    let id = filename.strip_suffix(".iso").unwrap_or(&filename);
    // v0.4.65: SMB-sourced ISOs have no on-disk path — they're
    // streamed live from the remote share via `smbclient`. We look
    // up the meta first to decide whether to take the path-based
    // local route or the subprocess-based SMB route.
    let Some(meta) = state.iso_store.get(id) else {
        return (StatusCode::NOT_FOUND, "no such iso").into_response();
    };
    match &meta.source {
        IsoSource::Local => {
            let Some(path) = state.iso_store.iso_path_for(id) else {
                return (StatusCode::NOT_FOUND, "no such iso").into_response();
            };
            match stream_file_range(&path, headers.get(header::RANGE)).await {
                Ok(r) => r,
                Err(e) => {
                    (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
                }
            }
        }
        IsoSource::Smb {
            share_id,
            relative_path,
        } => {
            // Range requests aren't supported for SMB sources in
            // v0.4.65 — smbclient's CLI can't seek mid-stream. iPXE
            // chain loading and ISO sanboot do whole-file sequential
            // reads, so this works in practice. A 416 here lets the
            // client fall back to a full GET if it tried a range.
            if headers.get(header::RANGE).is_some() {
                return Response::builder()
                    .status(StatusCode::RANGE_NOT_SATISFIABLE)
                    .header(header::CONTENT_RANGE, format!("bytes */{}", meta.size_bytes))
                    .body(Body::empty())
                    .unwrap();
            }
            match state.smb_shares.stream_iso(share_id, relative_path).await {
                Ok(stream) => {
                    let reader = stream.stdout;
                    let body_stream = tokio_util::io::ReaderStream::new(reader);
                    Response::builder()
                        .status(StatusCode::OK)
                        .header(header::CONTENT_TYPE, "application/octet-stream")
                        .header(header::CONTENT_LENGTH, meta.size_bytes)
                        // Tell intermediaries we don't support
                        // ranges on this resource; saves them from
                        // even trying.
                        .header(header::ACCEPT_RANGES, "none")
                        .body(Body::from_stream(body_stream))
                        .unwrap()
                }
                Err(e) => (StatusCode::BAD_GATEWAY, format!("smb stream: {e}")).into_response(),
            }
        }
    }
}

async fn iso_file(
    State(state): State<AppState>,
    AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
    // In-ISO file extraction is only supported for local ISOs — it
    // needs random-access reads into the ISO9660 directory tree, which
    // smbclient's whole-file streaming can't do efficiently. SMB-
    // sourced ISOs use the raw streaming endpoint above instead.
    let Some(iso_path) = state.iso_store.iso_path_for(&id) else {
        return (StatusCode::NOT_FOUND, "no such iso").into_response();
    };
    let p = iso_path.clone();
    let in_path = format!("/{path}");
    let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
        .await
        .ok()
        .flatten();
    let Some(loc) = loc else {
        return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
    };
    match stream_byte_range(&iso_path, loc.offset, loc.length).await {
        Ok(r) => r,
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

async fn stream_file_range(
    path: &std::path::Path,
    range: Option<&HeaderValue>,
) -> anyhow::Result<Response> {
    let meta = tokio::fs::metadata(path).await?;
    let total = meta.len();
    if total == 0 {
        return Ok(Response::builder()
            .status(StatusCode::OK)
            .header(header::CONTENT_TYPE, "application/octet-stream")
            .header(header::ACCEPT_RANGES, "bytes")
            .header(header::CONTENT_LENGTH, 0)
            .body(Body::empty())
            .unwrap());
    }
    let Some((start, end, partial)) = parse_range(range, total) else {
        return Ok(Response::builder()
            .status(StatusCode::RANGE_NOT_SATISFIABLE)
            .header(header::CONTENT_RANGE, format!("bytes */{total}"))
            .body(Body::empty())
            .unwrap());
    };
    let len = end - start + 1;
    let mut file = tokio::fs::File::open(path).await?;
    file.seek(std::io::SeekFrom::Start(start)).await?;
    let reader = file.take(len);
    let stream = tokio_util::io::ReaderStream::new(reader);
    let body = Body::from_stream(stream);
    let status = if partial {
        StatusCode::PARTIAL_CONTENT
    } else {
        StatusCode::OK
    };
    let mut builder = Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "application/octet-stream")
        .header(header::ACCEPT_RANGES, "bytes")
        .header(header::CONTENT_LENGTH, len);
    if partial {
        builder = builder.header(
            header::CONTENT_RANGE,
            format!("bytes {start}-{end}/{total}"),
        );
    }
    Ok(builder.body(body).unwrap())
}

async fn stream_byte_range(
    path: &std::path::Path,
    offset: u64,
    length: u64,
) -> anyhow::Result<Response> {
    let mut file = tokio::fs::File::open(path).await?;
    file.seek(std::io::SeekFrom::Start(offset)).await?;
    let reader = file.take(length);
    let stream = tokio_util::io::ReaderStream::new(reader);
    let body = Body::from_stream(stream);
    Ok(Response::builder()
        .status(StatusCode::OK)
        .header(header::CONTENT_TYPE, "application/octet-stream")
        .header(header::CONTENT_LENGTH, length)
        .body(body)
        .unwrap())
}

fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
    let Some(h) = h else {
        return Some((0, total.saturating_sub(1), false));
    };
    let Ok(s) = h.to_str() else {
        return Some((0, total.saturating_sub(1), false));
    };
    let Some(spec) = s.strip_prefix("bytes=") else {
        return Some((0, total.saturating_sub(1), false));
    };
    let spec = spec.split(',').next().unwrap_or("").trim();
    if let Some(suffix) = spec.strip_prefix('-') {
        if let Ok(n) = suffix.parse::<u64>() {
            let n = n.min(total);
            return Some((total.saturating_sub(n), total.saturating_sub(1), true));
        }
    }
    let mut parts = spec.splitn(2, '-');
    let start = parts
        .next()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    let end = parts
        .next()
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(total.saturating_sub(1));
    if start >= total {
        return None;
    }
    let end = end.min(total.saturating_sub(1));
    if start > end {
        return None;
    }
    Some((start, end, true))
}

// ─── ISO upload / list / delete ───────────────────────────────────────────

async fn api_list_isos(State(state): State<AppState>) -> Json<Vec<IsoMeta>> {
    Json(state.iso_store.list())
}

async fn api_delete_iso(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> StatusCode {
    match state.iso_store.delete(&id).await {
        Ok(()) => StatusCode::NO_CONTENT,
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
    }
}

#[derive(Debug, Deserialize)]
struct SetPasswordBody {
    /// Plaintext password. `null` or empty/whitespace clears the
    /// password (same as a DELETE on this resource). The server hashes
    /// with bcrypt before persisting; the plaintext is never stored.
    password: Option<String>,
}

async fn api_set_iso_password(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
    Json(body): Json<SetPasswordBody>,
) -> Response {
    match state
        .iso_store
        .set_password(&id, body.password.as_deref())
        .await
    {
        Ok(()) => {
            let now_protected = state
                .iso_store
                .get(&id)
                .is_some_and(|m| m.is_password_protected());
            // We deliberately do not log the password value, only
            // whether the ISO ended up protected.
            tracing::info!(
                target: "openpxe::http::iso",
                iso = %id, protected = now_protected,
                "iso password updated"
            );
            StatusCode::NO_CONTENT.into_response()
        }
        Err(openpxe_error_invalid)
            if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
        {
            (StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

async fn api_clear_iso_password(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Response {
    match state.iso_store.set_password(&id, None).await {
        Ok(()) => {
            tracing::info!(
                target: "openpxe::http::iso",
                iso = %id, "iso password cleared"
            );
            StatusCode::NO_CONTENT.into_response()
        }
        Err(e) => (StatusCode::NOT_FOUND, format!("{e}")).into_response(),
    }
}

// ─── ISO category (OS / Tools) ────────────────────────────────────────────

#[derive(Debug, Deserialize)]
struct SetCategoryBody {
    /// `"os"` or `"tools"` — matches `IsoCategory`'s snake_case serde
    /// repr. Anything else returns 400 with the allowed set spelled out.
    category: String,
}

async fn api_set_iso_category(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
    Json(body): Json<SetCategoryBody>,
) -> Response {
    let cat = match body.category.as_str() {
        "os" => IsoCategory::Os,
        "tools" => IsoCategory::Tools,
        other => {
            return (
                StatusCode::BAD_REQUEST,
                format!("unknown category '{other}'; expected one of: os, tools"),
            )
                .into_response();
        }
    };
    match state.iso_store.set_category(&id, cat).await {
        Ok(meta) => {
            tracing::info!(
                target: "openpxe::http::iso",
                iso = %id, category = ?cat,
                "iso category updated"
            );
            (StatusCode::OK, Json(meta)).into_response()
        }
        Err(Error::Invalid(msg)) => (StatusCode::NOT_FOUND, msg).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

// ─── Disk space (Storage tab) ─────────────────────────────────────────────

async fn api_storage_disk(State(state): State<AppState>) -> Json<serde_json::Value> {
    // statvfs on the directory that holds the ISO store. We deliberately
    // don't walk the directory ourselves — the kernel already tracks
    // free/total at the volume level and that's the only number the
    // operator actually cares about for "do I have room for one more
    // 5 GB ISO?". `statvfs` itself lives in iso-store to keep the
    // http-api crate free of `unsafe`.
    let dir = state.iso_store.iso_dir();
    let (total, available) = state.iso_store.disk_usage().unwrap_or((0, 0));
    let used = total.saturating_sub(available);
    Json(json!({
        "path": dir.to_string_lossy(),
        "total_bytes": total,
        "available_bytes": available,
        "used_bytes": used,
    }))
}

// ─── Branding (custom logo) ───────────────────────────────────────────────

async fn api_branding_upload(
    State(state): State<AppState>,
    mut multipart: Multipart,
) -> Response {
    while let Ok(Some(field)) = multipart.next_field().await {
        let name = field.name().unwrap_or("").to_string();
        if name != "file" && name != "logo" {
            continue;
        }
        let mime = field.content_type().unwrap_or("").to_string();
        if !ALLOWED_LOGO_MIMES.iter().any(|m| *m == mime) {
            return (
                StatusCode::BAD_REQUEST,
                format!(
                    "unsupported MIME '{mime}'. Allowed: {}",
                    ALLOWED_LOGO_MIMES.join(", ")
                ),
            )
                .into_response();
        }
        // Pre-read into memory so we can enforce the size cap before
        // hitting disk. Logos are tiny by definition.
        let bytes = match field.bytes().await {
            Ok(b) => b,
            Err(e) => {
                return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response()
            }
        };
        if bytes.len() > MAX_LOGO_BYTES {
            return (
                StatusCode::PAYLOAD_TOO_LARGE,
                format!(
                    "logo too large ({} bytes, max {})",
                    bytes.len(),
                    MAX_LOGO_BYTES
                ),
            )
                .into_response();
        }
        let Some(ext) = ext_for_mime(&mime) else {
            return (StatusCode::BAD_REQUEST, "unsupported MIME").into_response();
        };
        match state.branding.set_logo(&mime, ext, &bytes) {
            Ok(filename) => {
                return (
                    StatusCode::OK,
                    Json(json!({
                        "filename": filename,
                        "mime": mime,
                        "size_bytes": bytes.len(),
                    })),
                )
                    .into_response()
            }
            Err(e) => {
                return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
            }
        }
    }
    (StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}

async fn api_branding_clear(State(state): State<AppState>) -> Response {
    match state.branding.clear_logo() {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

// ─── API reference (Settings → bottom) ────────────────────────────────────

async fn api_docs() -> Json<serde_json::Value> {
    // Hand-curated rather than introspected from axum because:
    //   1. axum's runtime route table doesn't carry parameter docs;
    //   2. the WebUI surfaces this as a readable list, not as an OpenAPI
    //      spec — readers are operators chasing an integration, not
    //      machines.
    // Keep this in lockstep with `build_router` when adding endpoints.
    Json(json!({
        "version": env!("CARGO_PKG_VERSION"),
        "groups": [
            {
                "name": "Status & health",
                "endpoints": [
                    {"method": "GET", "path": "/healthz",
                     "summary": "Liveness — always 200 OK while the HTTP task is alive."},
                    {"method": "GET", "path": "/readyz",
                     "summary": "Readiness — 200 only when iPXE binaries are bundled and the ISO directory is readable."},
                    {"method": "GET", "path": "/api/status",
                     "summary": "Dashboard JSON — versions, counts, settings snapshot, uptime."},
                    {"method": "GET", "path": "/metrics",
                     "summary": "Prometheus text exposition (counters + gauges)."},
                ],
            },
            {
                "name": "ISO images",
                "endpoints": [
                    {"method": "GET", "path": "/api/isos",
                     "summary": "List ISOs (local + NFS) with size, family, boot entries, category."},
                    {"method": "POST", "path": "/api/isos",
                     "summary": "Legacy single-shot multipart upload. Prefer /api/uploads for big files."},
                    {"method": "DELETE", "path": "/api/isos/:id",
                     "summary": "Delete a local ISO and its sidecar metadata."},
                    {"method": "PUT", "path": "/api/isos/:id/password",
                     "summary": "Set or update an ISO's boot password (bcrypt-hashed; plaintext never stored)."},
                    {"method": "DELETE", "path": "/api/isos/:id/password",
                     "summary": "Clear an ISO's boot password."},
                    {"method": "PUT", "path": "/api/isos/:id/category",
                     "summary": "Set the menu category. Body: { \"category\": \"os\" | \"tools\" }."},
                ],
            },
            {
                "name": "Chunked uploads",
                "endpoints": [
                    {"method": "POST", "path": "/api/uploads",
                     "summary": "Begin a chunked upload session. Body: { \"filename\", \"size_bytes\" }."},
                    {"method": "PUT", "path": "/api/uploads/:upload_id",
                     "summary": "Append a chunk. Headers: x-openpxe-upload-offset, x-openpxe-upload-complete."},
                    {"method": "DELETE", "path": "/api/uploads/:upload_id",
                     "summary": "Abort a chunked upload session and remove the .partial file."},
                ],
            },
            {
                "name": "SMB shares",
                "endpoints": [
                    {"method": "GET", "path": "/api/smb-shares",
                     "summary": "List configured SMB shares with connection state and iso counts."},
                    {"method": "POST", "path": "/api/smb-shares",
                     "summary": "Register an SMB share. Body: { server, share, guest, username?, password?, port? }."},
                    {"method": "DELETE", "path": "/api/smb-shares/:id",
                     "summary": "Forget a share and drop its entries from the ISO store."},
                    {"method": "POST", "path": "/api/smb-shares/:id/scan",
                     "summary": "Re-list a share for new ISOs."},
                ],
            },
            {
                "name": "Network",
                "endpoints": [
                    {"method": "GET", "path": "/api/network",
                     "summary": "Detected NIC, server IP, subnet, gateway, advertised base URL."},
                    {"method": "PUT", "path": "/api/network",
                     "summary": "Update the informational DNS server hint (does not run DNS)."},
                ],
            },
            {
                "name": "Settings",
                "endpoints": [
                    {"method": "GET", "path": "/api/settings",
                     "summary": "Current runtime settings (Windows toggle, timeout, dns hint, …)."},
                    {"method": "PUT", "path": "/api/settings",
                     "summary": "Replace runtime settings. Guards against enabling Windows when wimboot isn't bundled."},
                    {"method": "POST", "path": "/api/branding/logo",
                     "summary": "Upload a custom WebUI logo (multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB)."},
                    {"method": "DELETE", "path": "/api/branding/logo",
                     "summary": "Remove the custom logo and revert to the bundled mark."},
                    {"method": "GET", "path": "/branding/pxe-logo",
                     "summary": "Raster form of the operator's logo for the iPXE menu's `console --picture`. SVG uploads 404 here."},
                    {"method": "GET", "path": "/api/sso",
                     "summary": "Current SAML SSO configuration."},
                    {"method": "PUT", "path": "/api/sso",
                     "summary": "Replace SAML SSO configuration. Body: { enabled, idp_name, metadata, metadata_url }."},
                    {"method": "GET", "path": "/api/docs",
                     "summary": "This API reference."},
                ],
            },
            {
                "name": "Auth (Forms)",
                "endpoints": [
                    {"method": "POST", "path": "/api/setup",
                     "summary": "First-run admin bootstrap. Body: { username, password }. Refuses after the admin exists."},
                    {"method": "POST", "path": "/api/login",
                     "summary": "Sign in. Body: { username, password }. Sets the openpxe_session cookie."},
                    {"method": "POST", "path": "/api/logout",
                     "summary": "Revoke the current session and clear the cookie."},
                    {"method": "GET", "path": "/api/me",
                     "summary": "Auth status — { setup_required, authenticated, user }. Always 200."},
                    {"method": "PUT", "path": "/api/me/credentials",
                     "summary": "Rotate the admin's credentials. Body: { current_password, new_username?, new_password? }. Revokes all other sessions on success."},
                ],
            },
            {
                "name": "Storage telemetry",
                "endpoints": [
                    {"method": "GET", "path": "/api/storage/disk",
                     "summary": "Free / used / total bytes for the volume hosting the ISO directory."},
                ],
            },
            {
                "name": "Queued Deployment",
                "endpoints": [
                    {"method": "GET", "path": "/api/queue",
                     "summary": "List queue entries (waiting + assigned)."},
                    {"method": "POST", "path": "/api/queue/assign",
                     "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
                    {"method": "DELETE", "path": "/api/queue/:entry_id",
                     "summary": "Release a queue entry without assigning."},
                ],
            },
            {
                "name": "Hosts & boot log",
                "endpoints": [
                    {"method": "GET", "path": "/api/hosts",
                     "summary": "List per-MAC boot bindings."},
                    {"method": "POST", "path": "/api/hosts",
                     "summary": "Pin a MAC to a boot target. Body: { mac, target, label }."},
                    {"method": "DELETE", "path": "/api/hosts/:mac",
                     "summary": "Remove a binding."},
                    {"method": "GET", "path": "/api/boot-log",
                     "summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
                ],
            },
            {
                "name": "Operator console",
                "endpoints": [
                    {"method": "GET", "path": "/api/clients",
                     "summary": "Live PXE-client registry — MAC, last IP, arch, events."},
                    {"method": "GET", "path": "/api/log/recent",
                     "summary": "Ring of recent server log lines for the Terminal tab."},
                    {"method": "GET", "path": "/api/log/stream",
                     "summary": "Server-Sent Events stream of log lines."},
                    {"method": "POST", "path": "/api/terminal",
                     "summary": "Run a whitelisted operator command. Body: { command }."},
                ],
            },
        ],
    }))
}

async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
    // Walk multipart parts until we find the file. Each branch logs so an
    // operator chasing a "stuck" upload in the Terminal tab can see
    // exactly which stage failed (no field, wrong field name, parser
    // error, mid-stream drop, sha mismatch on finish, etc.).
    loop {
        let field_res = multipart.next_field().await;
        match field_res {
            Ok(Some(mut field)) => {
                if field.name() != Some("file") {
                    tracing::debug!(
                        target: "openpxe::http::upload",
                        field = field.name().unwrap_or("?"),
                        "skipping non-file multipart part"
                    );
                    continue;
                }
                let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
                if !filename.to_ascii_lowercase().ends_with(".iso") {
                    tracing::warn!(
                        target: "openpxe::http::upload",
                        filename = %filename, "rejecting non-.iso upload"
                    );
                    return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
                }
                tracing::info!(
                    target: "openpxe::http::upload",
                    filename = %filename, "upload started"
                );
                let mut handle = match state.iso_store.begin_upload(&filename).await {
                    Ok(h) => h,
                    Err(e) => {
                        tracing::warn!(
                            target: "openpxe::http::upload",
                            filename = %filename, error = %e,
                            "begin_upload rejected (likely duplicate name)"
                        );
                        return (StatusCode::CONFLICT, format!("{e}")).into_response();
                    }
                };
                // Streamed reader loop. We use an explicit `match` instead
                // of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a
                // truncated body from a reverse proxy 524 / network drop)
                // is treated as a failure rather than silently completing
                // with a partial file.
                let mut bytes: u64 = 0;
                let mut next_log_at: u64 = 64 * 1024 * 1024;
                loop {
                    match field.chunk().await {
                        Ok(Some(chunk)) => {
                            if let Err(e) = handle.write_chunk(&chunk).await {
                                tracing::error!(
                                    target: "openpxe::http::upload",
                                    filename = %filename, bytes,
                                    error = %e, "write_chunk failed; aborting"
                                );
                                let _ = handle.abort().await;
                                return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
                                    .into_response();
                            }
                            bytes += chunk.len() as u64;
                            if bytes >= next_log_at {
                                tracing::info!(
                                    target: "openpxe::http::upload",
                                    filename = %filename,
                                    received_bytes = bytes,
                                    "upload streaming"
                                );
                                // Backoff log cadence: 64 MB, 128, 256, …
                                next_log_at = next_log_at.saturating_mul(2);
                            }
                        }
                        Ok(None) => break,
                        Err(e) => {
                            tracing::error!(
                                target: "openpxe::http::upload",
                                filename = %filename, received_bytes = bytes,
                                error = %e,
                                "multipart stream ended with error (likely client \
                                 disconnect or reverse-proxy buffer cap); aborting"
                            );
                            let _ = handle.abort().await;
                            return (
                                StatusCode::BAD_REQUEST,
                                format!(
                                    "upload truncated after {bytes} bytes: {e}. \
                                     If you went through a reverse proxy, try the \
                                     LAN IP directly — large body buffering caps \
                                     (Cloudflare free tier is 100 MB) commonly \
                                     cause this."
                                ),
                            )
                                .into_response();
                        }
                    }
                }
                tracing::info!(
                    target: "openpxe::http::upload",
                    filename = %filename, received_bytes = bytes,
                    "upload body complete; introspecting"
                );
                let meta = match handle.finish(&state.iso_store).await {
                    Ok(m) => m,
                    Err(e) => {
                        tracing::error!(
                            target: "openpxe::http::upload",
                            filename = %filename, error = %e,
                            "finish failed (rename/introspect)"
                        );
                        return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
                    }
                };
                tracing::info!(
                    target: "openpxe::http::upload",
                    iso = %meta.id, size = meta.size_bytes,
                    family = ?meta.introspection.family,
                    entries = meta.boot_entries.len(),
                    "upload finished"
                );
                return (StatusCode::CREATED, Json(meta)).into_response();
            }
            Ok(None) => {
                tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part");
                return (StatusCode::BAD_REQUEST, "no 'file' part").into_response();
            }
            Err(e) => {
                tracing::error!(
                    target: "openpxe::http::upload",
                    error = %e,
                    "multipart parser error before reading any field"
                );
                return (
                    StatusCode::BAD_REQUEST,
                    format!("multipart parse error: {e}"),
                )
                    .into_response();
            }
        }
    }
}

#[derive(Debug, Deserialize)]
struct UploadBeginBody {
    filename: String,
    #[serde(default)]
    size_bytes: Option<u64>,
}

async fn api_upload_begin(
    State(state): State<AppState>,
    Json(body): Json<UploadBeginBody>,
) -> Response {
    match state
        .uploads
        .begin(&state.iso_store, &body.filename, body.size_bytes)
        .await
    {
        Ok(started) => {
            tracing::info!(
                target: "openpxe::http::upload",
                upload_id = %started.upload_id,
                iso = %started.iso_id,
                filename = %started.filename,
                expected_size = ?body.size_bytes,
                "chunked upload started"
            );
            (StatusCode::CREATED, Json(started)).into_response()
        }
        Err(Error::Invalid(e)) if e.contains("already exists") => {
            (StatusCode::CONFLICT, e).into_response()
        }
        Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
        Err(e) => {
            tracing::error!(target: "openpxe::http::upload", error = %e, "chunked upload begin failed");
            (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
        }
    }
}

async fn api_upload_chunk(
    State(state): State<AppState>,
    AxumPath(upload_id): AxumPath<String>,
    headers: HeaderMap,
    chunk: Bytes,
) -> Response {
    let Some(offset) = parse_u64_header(&headers, "x-openpxe-upload-offset") else {
        return (
            StatusCode::BAD_REQUEST,
            "missing or invalid x-openpxe-upload-offset",
        )
            .into_response();
    };
    let complete = bool_header(&headers, "x-openpxe-upload-complete");

    match state
        .uploads
        .append(&state.iso_store, &upload_id, offset, chunk, complete)
        .await
    {
        Ok(crate::uploads::UploadAppend::Progress { offset }) => (
            StatusCode::ACCEPTED,
            Json(json!({
                "ok": true,
                "upload_id": upload_id,
                "offset": offset,
                "complete": false,
            })),
        )
            .into_response(),
        Ok(crate::uploads::UploadAppend::Complete { offset, iso }) => {
            tracing::info!(
                target: "openpxe::http::upload",
                upload_id = %upload_id,
                iso = %iso.id,
                size = iso.size_bytes,
                family = ?iso.introspection.family,
                entries = iso.boot_entries.len(),
                "chunked upload finished"
            );
            (
                StatusCode::CREATED,
                Json(json!({
                    "ok": true,
                    "upload_id": upload_id,
                    "offset": offset,
                    "complete": true,
                    "iso": iso,
                })),
            )
                .into_response()
        }
        Err(Error::Invalid(e)) if e.starts_with("expected offset") => {
            (StatusCode::CONFLICT, e).into_response()
        }
        Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
            (StatusCode::NOT_FOUND, e).into_response()
        }
        Err(Error::Invalid(e)) => (StatusCode::BAD_REQUEST, e).into_response(),
        Err(e) => {
            tracing::error!(
                target: "openpxe::http::upload",
                upload_id = %upload_id,
                error = %e,
                "chunked upload failed"
            );
            (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response()
        }
    }
}

async fn api_upload_abort(
    State(state): State<AppState>,
    AxumPath(upload_id): AxumPath<String>,
) -> Response {
    match state.uploads.abort(&upload_id).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(Error::Invalid(e)) if e.starts_with("no such upload") => {
            (StatusCode::NOT_FOUND, e).into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

fn parse_u64_header(headers: &HeaderMap, name: &'static str) -> Option<u64> {
    headers.get(name)?.to_str().ok()?.trim().parse::<u64>().ok()
}

fn bool_header(headers: &HeaderMap, name: &'static str) -> bool {
    headers
        .get(name)
        .and_then(|v| v.to_str().ok())
        .map(str::trim)
        .is_some_and(|v| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES"))
}

// ─── health / readiness ───────────────────────────────────────────────────

async fn healthz() -> Response {
    // Simple liveness — HTTP task is responsive. Does not touch storage or
    // other subsystems so we never fail for downstream reasons.
    (
        [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
        "ok\n",
    )
        .into_response()
}

async fn readyz(State(state): State<AppState>) -> Response {
    // Readiness — can we actually serve clients?
    //   1. At least one iPXE binary must be bundled (without one, TFTP 404s
    //      and no client boots).
    //   2. The ISO directory must exist and be readable.
    let assets = openpxe_ipxe_assets::list_assets();
    let mut problems: Vec<&str> = Vec::new();
    if assets.is_empty() {
        problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
    }
    let iso_dir_ok = state.iso_store.list_ok();
    if !iso_dir_ok {
        problems.push("iso directory not readable");
    }
    if problems.is_empty() {
        (
            [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
            "ready\n",
        )
            .into_response()
    } else {
        let body = format!("not ready:\n- {}\n", problems.join("\n- "));
        (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
    }
}

// ─── clients + status + settings ──────────────────────────────────────────

async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({ "clients": state.clients.list() }))
}

async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
    let smb = state.smb.as_ref().map(|s| s.snapshot());
    // v0.4.65: NFS replaced with SMB shares. The dashboard metric
    // shape stays similar (count + reachable) so the UI doesn't have
    // to change its top-line tiles.
    let smb_shares = state.smb_shares.list();
    let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
    let isos = state.iso_store.list();
    let clients = state.clients.list();
    let queue_entries = state.queue.list();
    // Phase 4: dashboard tracks "imaging" as queue entries with an assignment
    // already issued — they're the ones actively chaining a boot script.
    let imaging = queue_entries
        .iter()
        .filter(|entry| entry.assigned_target.is_some())
        .count();
    let waiting = queue_entries.len() - imaging;
    // Side-effect: push gauge values out to the Prometheus surface.
    // Doing it here (in the most-frequently-polled endpoint) keeps the
    // gauges fresh without a dedicated scrape-time hook.
    state.metrics.set_iso_count(isos.len() as u64);
    state.metrics.set_client_count(clients.len() as u64);
    state
        .metrics
        .set_queue_counts(queue_entries.len() as u64, imaging as u64);
    state.metrics.set_nfs_active(smb_reachable as u64);
    state.metrics.record_http(openpxe_core::HttpRoute::Api);
    let now = time::OffsetDateTime::now_utc();
    let uptime_secs = (now - state.started_at).whole_seconds().max(0);
    Json(json!({
        "version": env!("CARGO_PKG_VERSION"),
        "public_base_url": state.public_base_url,
        "iso_count": isos.len(),
        "client_count": clients.len(),
        "queue_count": queue_entries.len(),
        "imaging_count": imaging,
        "waiting_count": waiting,
        "ipxe_assets": openpxe_ipxe_assets::list_assets(),
        "settings": state.settings.snapshot(),
        "smb": smb,
        // Keep the field names for now so existing UI bindings on
        // `iso_count2` / `client_count2` / sidebar counters keep
        // working. They cover "external storage shares" generically;
        // v0.4.65 the source is SMB instead of NFS.
        "smb_share_count": smb_shares.len(),
        "smb_share_reachable": smb_reachable,
        "host_bindings": state.hosts.len(),
        "custom_logo": state.branding.has_logo(),
        "uptime_secs": uptime_secs,
        "started_at": state.started_at,
        "nic_name": state.nic_name,
        "subnet_mask": state.subnet_mask,
        "gateway": state.gateway,
    }))
}

async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
    Json(state.settings.snapshot())
}

async fn api_put_settings(
    State(state): State<AppState>,
    Json(mut new): Json<Settings>,
) -> Response {
    // Guardrail: Windows boot requires the wimboot shim to be bundled.
    // Without it, clients chain a non-existent /ipxe/wimboot and stall.
    if new.windows_enabled {
        let assets = openpxe_ipxe_assets::list_assets();
        if !assets.iter().any(|n| n == "wimboot") {
            return (
                StatusCode::BAD_REQUEST,
                "cannot enable Windows: 'wimboot' binary is not bundled. \
                 Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
                 the container. See docs/architecture.md for details.",
            )
                .into_response();
        }
    }
    new.smb_host_override = new.smb_host_override.trim().to_string();

    // Detect whether this PUT changes the Windows toggle, so we only
    // restart smbd when it actually flipped.
    let was_enabled = state.settings.snapshot().windows_enabled;
    let want_enabled = new.windows_enabled;
    state.settings.replace(new);

    if let Some(smb) = &state.smb {
        match (was_enabled, want_enabled) {
            (false, true) => {
                let _ = smb.start();
            }
            (true, false) => {
                smb.stop();
            }
            (true, true) => {
                let _ = smb.reconcile();
            }
            (false, false) => {}
        }
    }
    StatusCode::NO_CONTENT.into_response()
}

// ─── Queued Deployment API ─────────────────────────────────────────────────

async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({
        "entries": state.queue.list(),
        "count": state.queue.list().len(),
    }))
}

#[derive(Debug, Deserialize)]
struct QueueJoinParams {
    /// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
    /// the HTTP request so we receive a plain colon-separated MAC.
    mac: Option<String>,
}

/// Called by iPXE via `chain --replace`. We respond with a tiny iPXE
/// script that hard-loops on `/api/queue/poll/<id>`. iPXE keeps fetching
/// until poll returns an actual boot script.
async fn api_queue_join(
    State(state): State<AppState>,
    Query(p): Query<QueueJoinParams>,
    headers: HeaderMap,
) -> Response {
    let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
    let ip = headers
        .get("x-forwarded-for")
        .and_then(|h| h.to_str().ok())
        .and_then(|s| s.split(',').next())
        .and_then(|s| s.trim().parse().ok());

    let queue_entry = state.queue.join(&mac, ip, None);
    state.clients.record(
        &mac,
        ip,
        None,
        ClientEvent::HttpScriptFetch {
            target: "queue-join".into(),
        },
    );

    let base = &state.public_base_url;
    // ASCII only - some iPXE builds on firmware consoles mangle non-ASCII.
    let script = format!(
        "#!ipxe\n\
         echo\n\
         echo ==========================================\n\
         echo   Queued Deployment - Queue Position {}\n\
         echo   Waiting for operator to assign an image\n\
         echo   (Ctrl-B returns to the iPXE shell)\n\
         echo ==========================================\n\
         chain {base}/api/queue/poll/{}\n",
        queue_entry.position, queue_entry.id
    );
    text_plain(script)
}

/// Long-poll endpoint. Waits up to 25s for an assignment; if none, returns
/// a script that loops back to itself. 25s keeps us well inside typical
/// HTTP idle timeouts for iPXE and intermediaries.
async fn api_queue_poll(
    State(state): State<AppState>,
    AxumPath(entry_id): AxumPath<String>,
) -> Response {
    let Some(notify) = state.queue.notifier(&entry_id) else {
        // Queue entry was released; send client back to the main menu.
        let base = &state.public_base_url;
        return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
    };

    // Wait for an assignment or timeout.
    let _ = tokio::time::timeout(Duration::from_secs(25), notify.notified()).await;

    let snap = state.queue.touch(&entry_id);
    let base = &state.public_base_url;
    match snap {
        // Bind `target` directly so we can't observe an Option::None between
        // the guard and the unwrap (the old code had a race with concurrent
        // `release`). We also do NOT release the queue entry here — the web UI
        // operator releases it explicitly, which keeps a record of "this
        // machine was assigned image X" visible until the client is known
        // to have started. Clients that retry on transient network errors
        // still get a valid boot script instead of falling back to the
        // menu.
        Some(g) if g.assigned_target.is_some() => {
            let target = g.assigned_target.clone().unwrap_or_default();
            tracing::info!(
                target: "openpxe::queue",
                entry_id=%entry_id, mac=%g.mac, target=%target,
                "queue assignment delivered"
            );
            text_plain(format!(
                "#!ipxe\n\
                 echo Queue assignment received: {target}\n\
                 chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
            ))
        }
        Some(g) => {
            // No assignment yet - loop and re-poll. Repaint position so the
            // UI count stays accurate if other queue entries were released meanwhile.
            text_plain(format!(
                "#!ipxe\n\
                 echo Queue Position {} - still waiting\n\
                 chain {base}/api/queue/poll/{entry_id}\n",
                g.position
            ))
        }
        None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
    }
}

#[derive(Debug, Deserialize)]
struct QueueAssignBody {
    /// Boot entry id (from `BootEntry::id`). Same one used in
    /// `/boot/<id>.ipxe`.
    target: String,
    /// Queue entry ids to assign. Empty = assign to all currently queued clients.
    entry_ids: Vec<String>,
}

async fn api_queue_assign(
    State(state): State<AppState>,
    Json(body): Json<QueueAssignBody>,
) -> Json<serde_json::Value> {
    let ids = if body.entry_ids.is_empty() {
        state
            .queue
            .list()
            .into_iter()
            .map(|g| g.id)
            .collect::<Vec<_>>()
    } else {
        body.entry_ids
    };
    // Guard: target must exist as a BootEntry id.
    let found = state
        .iso_store
        .list()
        .into_iter()
        .any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
    if !found {
        return Json(json!({ "ok": false, "error": "unknown target" }));
    }
    let n = state.queue.assign(&ids, &body.target);
    Json(json!({ "ok": true, "assigned": n, "target": body.target }))
}

async fn api_queue_release(
    State(state): State<AppState>,
    AxumPath(entry_id): AxumPath<String>,
) -> StatusCode {
    match state.queue.release(&entry_id) {
        Some(_) => StatusCode::NO_CONTENT,
        None => StatusCode::NOT_FOUND,
    }
}

// ─── SMB share API (v0.4.65) ───────────────────────────────────────────────
//
// Replaces the NFS share manager from v0.4.64. The wire shape is similar
// — a {shares: [...]} list, a POST that returns either the share or a
// structured {error, stderr, hint} body — so the UI can render both the
// same way.

async fn api_smb_shares_list(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({ "shares": state.smb_shares.list() }))
}

async fn api_smb_shares_add(
    State(state): State<AppState>,
    Json(req): Json<SmbAddRequest>,
) -> Response {
    match state.smb_shares.add(req).await {
        Ok(s) => (StatusCode::CREATED, Json(s)).into_response(),
        Err(err) => (StatusCode::BAD_REQUEST, Json(err)).into_response(),
    }
}

async fn api_smb_shares_remove(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Response {
    match state.smb_shares.remove(&id).await {
        Ok(()) => StatusCode::NO_CONTENT.into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
    }
}

async fn api_smb_shares_scan(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Response {
    match state.smb_shares.rescan(&id).await {
        Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

// ─── Network info API ──────────────────────────────────────────────────────

async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({
        "server_ip":  state.public_base_url
            .strip_prefix("http://")
            .unwrap_or(&state.public_base_url),
        "nic_name":   state.nic_name,
        "subnet_mask": state.subnet_mask,
        "gateway":     state.gateway,
        "dns_server":  state.settings.snapshot().dns_server,
        "public_base_url": state.public_base_url,
    }))
}

#[derive(Debug, Deserialize)]
struct NetworkPut {
    /// Operators can set or clear an informational DNS hint. Server IP /
    /// NIC / mask / gateway are auto-detected and not editable from the
    /// UI — changing them in the wrong direction would silently break
    /// PXE for every client.
    dns_server: String,
}

async fn api_network_put(
    State(state): State<AppState>,
    Json(body): Json<NetworkPut>,
) -> StatusCode {
    let mut s = state.settings.snapshot();
    s.dns_server = body.dns_server.trim().to_string();
    state.settings.replace(s);
    StatusCode::NO_CONTENT
}

// ─── Per-MAC host bindings ────────────────────────────────────────────────

async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({ "hosts": state.hosts.list() }))
}

#[derive(Debug, Deserialize)]
struct HostsUpsertBody {
    mac: String,
    target: String,
    #[serde(default)]
    label: String,
}

async fn api_hosts_upsert(
    State(state): State<AppState>,
    Json(body): Json<HostsUpsertBody>,
) -> Response {
    let mac = body.mac.trim();
    if mac.is_empty() {
        return (StatusCode::BAD_REQUEST, "mac is required").into_response();
    }
    // Sanity-check the target if the operator supplied a real boot
    // entry id (anything starting with `_` is a reserved menu shortcut
    // and exists by definition).
    let target = body.target.trim();
    if !target.starts_with('_')
        && !state
            .iso_store
            .list()
            .into_iter()
            .any(|i| i.boot_entries.iter().any(|e| e.id == target))
    {
        return (
            StatusCode::BAD_REQUEST,
            format!("unknown boot entry: {target}"),
        )
            .into_response();
    }
    let binding = state.hosts.upsert(mac, target, body.label.trim());
    (StatusCode::CREATED, Json(binding)).into_response()
}

async fn api_hosts_remove(
    State(state): State<AppState>,
    AxumPath(mac): AxumPath<String>,
) -> StatusCode {
    if state.hosts.remove(&mac) {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

// ─── Boot event log ───────────────────────────────────────────────────────

async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
    Json(json!({ "events": state.boot_log.list() }))
}

// ─── Prometheus metrics ───────────────────────────────────────────────────

async fn api_metrics(State(state): State<AppState>) -> Response {
    // Refresh gauges from live state before rendering — keeps the
    // scrape "honest" without making /api/status the only path that
    // updates them.
    state
        .metrics
        .set_iso_count(state.iso_store.list().len() as u64);
    state
        .metrics
        .set_client_count(state.clients.list().len() as u64);
    let queue_entries = state.queue.list();
    let imaging = queue_entries
        .iter()
        .filter(|entry| entry.assigned_target.is_some())
        .count();
    state
        .metrics
        .set_queue_counts(queue_entries.len() as u64, imaging as u64);
    // v0.4.65: gauge tracks reachable external-storage shares. With
    // NFS removed it now reflects SMB share reachability instead.
    state
        .metrics
        .set_nfs_active(state.smb_shares.list().iter().filter(|m| m.reachable).count() as u64);

    let now = time::OffsetDateTime::now_utc();
    let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
    let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
    (
        [(
            header::CONTENT_TYPE,
            HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
        )],
        body,
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn range_full() {
        let (s, e, p) = parse_range(None, 1000).unwrap();
        assert_eq!((s, e, p), (0, 999, false));
    }
    #[test]
    fn range_open_ended() {
        let h = HeaderValue::from_static("bytes=500-");
        let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
        assert_eq!((s, e, p), (500, 999, true));
    }
    #[test]
    fn range_suffix() {
        let h = HeaderValue::from_static("bytes=-100");
        let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
        assert_eq!((s, e, p), (900, 999, true));
    }
    #[test]
    fn range_explicit() {
        let h = HeaderValue::from_static("bytes=10-99");
        let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
        assert_eq!((s, e, p), (10, 99, true));
    }

    #[test]
    fn range_rejects_out_of_bounds_start() {
        let h = HeaderValue::from_static("bytes=1000-");
        let got = parse_range(Some(&h), 1000);
        assert_eq!(got, None);
    }

    #[test]
    fn range_rejects_start_after_end() {
        let h = HeaderValue::from_static("bytes=99-10");
        let got = parse_range(Some(&h), 1000);
        assert_eq!(got, None);
    }
}
