//! 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::error::AppError;
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::{
    encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
    LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
};
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{
    render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SftpAddRequest, SmbAddRequest,
    SmbState, UnattendedKind, UnattendedMeta,
};
use serde::{Deserialize, Serialize};
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))
        // v0.5.2: theme-aware brand mark. `?theme=light|dark` selects the
        // operator's per-theme logo slot (falling back across themes, then
        // to the bundled mark). The WebUI swaps `?theme=` on theme toggle.
        .route("/assets/logo.svg", get(ui_logo))
        // v0.5.2: favicon is pinned to the *bundled* OpenPXE mark for
        // continuity — it never follows the operator's custom branding, so
        // the browser-tab icon stays recognisably "OpenPXE".
        .route("/assets/favicon.svg", get(ui_favicon))
        .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))
        // v0.5.2: unattended answer-file *serving* — public (like /iso),
        // because the booting installer fetches these with no session.
        // `/unattended/:id` serves a Kickstart/Preseed with `{{HOSTNAME}}`
        // / `{{IP}}` / `{{MAC}}` substituted from the query string. The
        // 3-segment form is the cloud-init NoCloud seed dir for Ubuntu
        // autoinstall (`…/<ctx>/user-data` + `/meta-data`), where `<ctx>`
        // base64url-encodes the per-host hostname/ip/mac. Management
        // (upload/list/delete) lives under the gated `/api/unattended`.
        .route("/unattended/:id", get(serve_unattended))
        .route("/unattended/:id/:ctx/:sub", get(serve_unattended_seed))
        // 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). v0.5.2: split into three slots — `light` / `dark` /
        // `client`. Multipart upload to POST; DELETE clears one slot.
        .route(
            "/api/branding/logo/:slot",
            post(api_branding_upload).delete(api_branding_clear),
        )
        // v0.5.2: unattended-install answer-file management (gated).
        // Multipart upload, list, delete. Serving is the public
        // `/unattended/*` routes above.
        .route(
            "/api/unattended",
            get(api_unattended_list).post(api_unattended_upload),
        )
        .route("/api/unattended/:id", delete(api_unattended_delete))
        // 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))
        // SAML SSO configuration (FleetDM-shaped). Gated behind auth — the
        // operator pastes their IdP metadata, Entity ID, and toggles here.
        .route("/api/sso", get(api_sso_get).put(api_sso_put))
        // v0.5.1: SAML SP login flow (pre-auth — see the require_auth
        // allowlist). /login redirects to the IdP, /acs consumes the signed
        // response + mints a session, /metadata serves our SP descriptor.
        .route("/api/sso/login", get(crate::saml_routes::sso_login))
        .route("/api/sso/acs", post(crate::saml_routes::sso_acs))
        .route("/api/sso/metadata", get(crate::saml_routes::sso_metadata))
        .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))
        // v0.5.2: per-device deployment profile (auto hostname / IP /
        // unattended file) set from the queue "Profile" button.
        .route("/api/queue/:entry_id/profile", put(api_queue_set_profile))
        .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))
        // v0.4.67: NFSv3 share manager (pure-Rust in-process client).
        // Ships alongside SMB. Routes are parallel so the UI can
        // reuse the same form/error/hint rendering for both.
        .route(
            "/api/nfs-shares",
            get(api_nfs_shares_list).post(api_nfs_shares_add),
        )
        .route("/api/nfs-shares/:id", delete(api_nfs_shares_remove))
        .route("/api/nfs-shares/:id/scan", post(api_nfs_shares_scan))
        // v0.5.5: SFTP-over-SSH share manager (pure-Rust russh client).
        // Parallel to SMB/NFS so the UI reuses the same form/error/hint
        // rendering. Like NFS, SFTP-sourced ISOs support Range requests.
        .route(
            "/api/sftp-shares",
            get(api_sftp_shares_list).post(api_sftp_shares_add),
        )
        .route("/api/sftp-shares/:id", delete(api_sftp_shares_remove))
        .route("/api/sftp-shares/:id/scan", post(api_sftp_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))
        // v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the
        // limited broadcast + the server's own subnet broadcast.
        .route("/api/hosts/:mac/wol", post(api_hosts_wol))
        // 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))
        // v0.5.0: notification config (Advanced tab) + a "send test"
        // probe. GET redacts the SMTP password.
        .route("/api/notify", get(api_notify_get).put(api_notify_put))
        .route("/api/notify/test", post(api_notify_test))
        // v0.5.0: About-tab update check — queries the Gitea releases
        // API and compares against the running version.
        .route("/api/updates/check", get(api_updates_check))
        // 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>,
) -> Result<Json<SsoConfig>, AppError> {
    // v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
    Ok(Json(state.sso.replace(body)?))
}

// ─── 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.
    // logo_rev cache-busts the brand mark / favicon independently of
    // the release version, so an operator who swaps the custom logo
    // sees it update on the next reload without waiting for an upgrade.
    let html = openpxe_webui::index_html(
        &state.public_base_url,
        env!("CARGO_PKG_VERSION"),
        state.branding.logo_rev(),
        state.branding.has_any_web_logo(),
    );
    (
        [
            (
                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()
}

#[derive(Debug, Deserialize)]
struct LogoQuery {
    /// `light` or `dark` — which theme variant the page is currently
    /// showing. Anything else (or absent) resolves to the dark slot,
    /// which matches the default theme.
    #[serde(default)]
    theme: Option<String>,
}

async fn ui_logo(State(state): State<AppState>, Query(q): Query<LogoQuery>) -> 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. The theme query selects the per-theme
    // slot, with cross-theme + bundled fallback handled in BrandingStore.
    let theme_is_light = q.theme.as_deref() == Some("light");
    if let Some((path, mime)) = state.branding.web_logo(theme_is_light) {
        match tokio::fs::read(&path).await {
            Ok(bytes) => {
                let ct = HeaderValue::from_str(&mime)
                    .unwrap_or_else(|_| 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"
                );
            }
        }
    }
    bundled_logo_response()
}

/// Favicon — always the bundled OpenPXE mark, decoupled from operator
/// branding (v0.5.2) so the browser-tab icon stays "OpenPXE" for
/// continuity regardless of any uploaded light/dark logo.
async fn ui_favicon() -> Response {
    bundled_logo_response()
}

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

/// PXE boot-menu background, composed for the iPXE `console --picture`
/// call. Returns a full-screen 1024×768 PNG: a dark field with the
/// operator's uploaded logo across the top, or — when no logo is set —
/// a default OpenPXE mark on the same dark field. Either way the
/// endpoint *always* returns a valid PNG so the menu's `console
/// --picture` paints a real background instead of falling through to
/// bare text (v0.4.69 — replaces the old ASCII wordmark).
///
/// Any raster the operator uploads (PNG / JPEG / WebP / GIF) is decoded
/// and transcoded to PNG here, since iPXE only consumes PNG. SVG
/// uploads can't be rasterized without hauling in `resvg`, so an SVG
/// brand mark falls back to the *default* background for the PXE screen
/// (the WebUI still renders the SVG natively in the top-left).
async fn ui_pxe_logo(State(state): State<AppState>) -> Response {
    // Resolve the operator's raster upload, if any and if it's a format
    // iPXE/our compositor can consume. SVG (or a missing/unreadable
    // file) yields `None`, which composes the default background.
    let raster: Option<Vec<u8>> = match state.branding.client_logo() {
        Some((path, mime)) if mime != "image/svg+xml" => tokio::fs::read(&path).await.ok(),
        _ => None,
    };

    let composed = match tokio::task::spawn_blocking(move || {
        openpxe_iso_store::pxe_logo::compose_pxe_background(raster.as_deref())
    })
    .await
    {
        Ok(Ok(png)) => png,
        Ok(Err(e)) => {
            // A decode failure on the operator's upload shouldn't blank
            // the boot screen — fall back to the default background.
            tracing::warn!(
                target: "openpxe::http::branding",
                error = %e, "PXE background compose failed on upload; using default"
            );
            match tokio::task::spawn_blocking(|| {
                openpxe_iso_store::pxe_logo::compose_pxe_background(None)
            })
            .await
            {
                Ok(Ok(png)) => png,
                _ => {
                    return (
                        StatusCode::INTERNAL_SERVER_ERROR,
                        "failed to compose PXE background",
                    )
                        .into_response();
                }
            }
        }
        Err(e) => {
            return (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("pxe-background task failed: {e}"),
            )
                .into_response();
        }
    };
    (
        [
            (header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
            // No-cache so a freshly uploaded logo paints on the next
            // boot without a stale composite lingering.
            (
                header::CACHE_CONTROL,
                HeaderValue::from_static("no-cache, max-age=0"),
            ),
        ],
        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.clone(),
                            ip: peer_ip,
                            target_id: entry.id.clone(),
                            target_title: format!("{} — {}", iso.filename, entry.title),
                        });
                        // v0.5.0: fire-and-forget notification on the
                        // canonical "a machine is imaging" moment.
                        let who = mac_normalized
                            .clone()
                            .or_else(|| peer_ip.map(|ip| ip.to_string()))
                            .unwrap_or_else(|| "an unknown client".into());
                        spawn_notify(
                            &state,
                            "PXE boot started",
                            &format!("{who} started booting {} ({}).", iso.filename, entry.title),
                        );
                        // v0.5.2: if this MAC has a deployment profile with
                        // an unattended answer file selected (via a host
                        // pin or queue Profile), inject the right kernel
                        // arg so the install runs unattended.
                        let unattended_args = mac_normalized.as_deref().and_then(|m| {
                            resolve_profile(&state, m).and_then(|p| {
                                p.unattended_file
                                    .as_deref()
                                    .and_then(|fid| state.unattended.get(fid))
                                    .and_then(|meta| {
                                        build_unattended_args(base, &meta, Some(m), &p)
                                    })
                            })
                        });
                        return text_plain(render_entry(
                            entry,
                            &settings,
                            base,
                            unattended_args.as_deref(),
                        ));
                    }
                }
            }
            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(),
            }
        }
        IsoSource::Nfs {
            share_id,
            relative_path,
        } => {
            // v0.4.67: NFS sources support Range requests because
            // NFSv3 READ3 takes an explicit offset. We resolve the
            // requested byte range here and pass start/len down to
            // the streamer which seeks into the file via READ3.
            let total = meta.size_bytes;
            let range = match parse_range(headers.get(header::RANGE), total) {
                Some(triple) => triple,
                None if headers.get(header::RANGE).is_some() => {
                    return Response::builder()
                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
                        .header(header::CONTENT_RANGE, format!("bytes */{total}"))
                        .body(Body::empty())
                        .unwrap();
                }
                // No Range header — serve the whole file.
                None => (0, total.saturating_sub(1), false),
            };
            let (start, end, partial) = range;
            let len = if total == 0 { 0 } else { end - start + 1 };
            let max_len = if total == 0 { None } else { Some(len) };
            match state
                .nfs_shares
                .stream_iso(share_id, relative_path, start, max_len)
                .await
            {
                Ok(stream) => {
                    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}"),
                        );
                    }
                    builder.body(body).unwrap()
                }
                Err(e) => (StatusCode::BAD_GATEWAY, format!("nfs stream: {e}")).into_response(),
            }
        }
        IsoSource::Sftp {
            share_id,
            relative_path,
        } => {
            // v0.5.5: SFTP sources support Range requests because SFTP
            // opens a seekable file handle (seek to offset, then bounded
            // reads). Identical handling to the NFS arm above.
            let total = meta.size_bytes;
            let range = match parse_range(headers.get(header::RANGE), total) {
                Some(triple) => triple,
                None if headers.get(header::RANGE).is_some() => {
                    return Response::builder()
                        .status(StatusCode::RANGE_NOT_SATISFIABLE)
                        .header(header::CONTENT_RANGE, format!("bytes */{total}"))
                        .body(Body::empty())
                        .unwrap();
                }
                // No Range header — serve the whole file.
                None => (0, total.saturating_sub(1), false),
            };
            let (start, end, partial) = range;
            let len = if total == 0 { 0 } else { end - start + 1 };
            let max_len = if total == 0 { None } else { Some(len) };
            match state
                .sftp_shares
                .stream_iso(share_id, relative_path, start, max_len)
                .await
            {
                Ok(stream) => {
                    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}"),
                        );
                    }
                    builder.body(body).unwrap()
                }
                Err(e) => (StatusCode::BAD_GATEWAY, format!("sftp 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>,
    AxumPath(slot): AxumPath<String>,
    mut multipart: Multipart,
) -> Response {
    let Some(slot) = LogoSlot::parse(&slot) else {
        return (
            StatusCode::BAD_REQUEST,
            "unknown logo slot; expected light, dark, or client",
        )
            .into_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();
        };
        // The PXE "client" logo is rasterized for the boot screen; an SVG
        // there can't be composited, so steer operators to a raster.
        if slot == LogoSlot::Client && mime == "image/svg+xml" {
            return (
                StatusCode::BAD_REQUEST,
                "the client (PXE) logo must be a raster image (PNG/JPEG/WebP/GIF); SVG can't be painted on the boot screen",
            )
                .into_response();
        }
        match state.branding.set_logo(slot, &mime, ext, &bytes) {
            Ok(filename) => {
                return (
                    StatusCode::OK,
                    Json(json!({
                        "slot": slot.as_str(),
                        "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>,
    AxumPath(slot): AxumPath<String>,
) -> Result<Response, AppError> {
    let Some(slot) = LogoSlot::parse(&slot) else {
        return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
    };
    state.branding.clear_logo(slot)?;
    Ok(StatusCode::NO_CONTENT.into_response())
}

// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
//
// Management (list/upload/delete) is gated behind the auth middleware.
// *Serving* the files to the booting installer is the public
// `/unattended/*` route pair below — the installer has no session.

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

async fn api_unattended_upload(
    State(state): State<AppState>,
    mut multipart: Multipart,
) -> Result<Response, AppError> {
    // v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
    // The multipart-shape 400s (missing field/filename) stay explicit.
    while let Ok(Some(field)) = multipart.next_field().await {
        let name = field.name().unwrap_or("").to_string();
        if name != "file" && name != "unattended" {
            continue;
        }
        let filename = field.file_name().map(str::to_string).unwrap_or_default();
        if filename.trim().is_empty() {
            return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
        }
        let bytes = match field.bytes().await {
            Ok(b) => b,
            Err(e) => {
                return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
            }
        };
        let meta = state.unattended.add(&filename, &bytes).await?;
        return Ok((StatusCode::CREATED, Json(meta)).into_response());
    }
    Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
}

async fn api_unattended_delete(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> StatusCode {
    if state.unattended.remove(&id).await {
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[derive(Debug, Deserialize)]
struct UnattendedServeQuery {
    #[serde(default)]
    mac: Option<String>,
    #[serde(default)]
    hostname: Option<String>,
    #[serde(default)]
    ip: Option<String>,
}

/// Public: serve a Kickstart/Preseed/answer file with `{{HOSTNAME}}` /
/// `{{IP}}` / `{{MAC}}` substituted from the query string. Returns
/// `text/plain` so installers (anaconda, debian-installer, Windows setup
/// fetching over HTTP) read it verbatim.
async fn serve_unattended(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
    Query(q): Query<UnattendedServeQuery>,
) -> Response {
    let Ok(bytes) = state.unattended.read(&id).await else {
        return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
    };
    let content = String::from_utf8_lossy(&bytes);
    let rendered = render_template(
        &content,
        q.mac.as_deref(),
        q.hostname.as_deref(),
        q.ip.as_deref(),
    );
    text_plain(rendered)
}

/// Public: cloud-init NoCloud seed directory for Ubuntu autoinstall. The
/// kernel arg points iPXE/cloud-init at `…/<id>/<ctx>/`; cloud-init then
/// fetches `user-data`, `meta-data`, (and `vendor-data`). `<ctx>`
/// base64url-encodes the per-host hostname/ip/mac so they survive the
/// seedfrom URL (which can't carry a query string).
async fn serve_unattended_seed(
    State(state): State<AppState>,
    AxumPath((id, ctx, sub)): AxumPath<(String, String, String)>,
) -> Response {
    let (mac, hostname, ip) = decode_seed_ctx(&ctx);
    match sub.as_str() {
        "user-data" => {
            let Ok(bytes) = state.unattended.read(&id).await else {
                return (StatusCode::NOT_FOUND, "no such unattended file").into_response();
            };
            let content = String::from_utf8_lossy(&bytes);
            let rendered =
                render_template(&content, mac.as_deref(), hostname.as_deref(), ip.as_deref());
            text_plain(rendered)
        }
        "meta-data" => {
            let host_line = hostname
                .as_deref()
                .map(|h| format!("local-hostname: {h}\n"))
                .unwrap_or_default();
            text_plain(format!("instance-id: openpxe-{id}\n{host_line}"))
        }
        // cloud-init probes vendor-data too; an empty 200 keeps it quiet.
        "vendor-data" => text_plain(String::new()),
        _ => (StatusCode::NOT_FOUND, "unknown seed resource").into_response(),
    }
}

/// Resolve the deployment profile for a booting MAC: a host pin wins, else
/// a queued device's Profile. `None` when neither carries one.
fn resolve_profile(state: &AppState, mac: &str) -> Option<DeployProfile> {
    if let Some(b) = state.hosts.lookup(mac) {
        if !b.profile.is_empty() {
            return Some(b.profile);
        }
    }
    state.queue.profile_for_mac(mac)
}

/// Build the per-host unattended kernel arguments for a Linux entry.
/// Returns `None` for Windows answer files / unclassified uploads (no
/// kernel cmdline injection applies).
fn build_unattended_args(
    base: &str,
    meta: &UnattendedMeta,
    mac: Option<&str>,
    profile: &DeployProfile,
) -> Option<String> {
    let base = base.trim_end_matches('/');
    let id = &meta.id;
    let host = profile.auto_hostname.as_deref();
    let ip = profile.auto_ip.as_deref();
    let query = build_query(&[("mac", mac), ("hostname", host), ("ip", ip)]);
    match meta.kind {
        UnattendedKind::Kickstart => Some(format!("inst.ks={base}/unattended/{id}{query}")),
        UnattendedKind::Preseed => {
            let mut s = format!("auto=true priority=critical url={base}/unattended/{id}{query}");
            if let Some(h) = host {
                s.push_str(" hostname=");
                s.push_str(h);
            }
            Some(s)
        }
        UnattendedKind::Autoinstall => {
            let ctx = encode_seed_ctx(mac, host, ip);
            Some(format!(
                "autoinstall ds=nocloud-net;s={base}/unattended/{id}/{ctx}/"
            ))
        }
        UnattendedKind::AnswerFile | UnattendedKind::Unknown => None,
    }
}

/// Build a `?k=v&…` query string from present key/value pairs, percent-
/// encoding the values. Empty when nothing is present.
fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
    use std::fmt::Write as _;
    let mut out = String::new();
    for (k, v) in pairs {
        if let Some(val) = v {
            out.push(if out.is_empty() { '?' } else { '&' });
            let _ = write!(out, "{k}={}", pct_encode(val));
        }
    }
    out
}

// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.

/// Encode `(hostname, ip, mac)` into a single base64url path segment for
/// the cloud-init seed directory. Empty values become empty fields.
fn encode_seed_ctx(mac: Option<&str>, hostname: Option<&str>, ip: Option<&str>) -> String {
    use base64::Engine as _;
    let raw = format!(
        "{}\n{}\n{}",
        hostname.unwrap_or(""),
        ip.unwrap_or(""),
        mac.unwrap_or("")
    );
    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw.as_bytes())
}

/// Inverse of [`encode_seed_ctx`]; returns `(mac, hostname, ip)`. A bad
/// or empty segment yields all-`None` so the seed still serves (just
/// without per-host substitution).
fn decode_seed_ctx(ctx: &str) -> (Option<String>, Option<String>, Option<String>) {
    use base64::Engine as _;
    let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(ctx.as_bytes()) else {
        return (None, None, None);
    };
    let s = String::from_utf8_lossy(&bytes).into_owned();
    let mut it = s.splitn(3, '\n');
    let clean = |v: Option<&str>| v.map(str::to_string).filter(|x| !x.is_empty());
    let hostname = clean(it.next());
    let ip = clean(it.next());
    let mac = clean(it.next());
    (mac, hostname, ip)
}

// ─── 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": "NFS shares",
                "endpoints": [
                    {"method": "GET", "path": "/api/nfs-shares",
                     "summary": "List configured NFSv3 shares with connection state and iso counts."},
                    {"method": "POST", "path": "/api/nfs-shares",
                     "summary": "Register an NFSv3 share. Body: { server, export, port? }. Auth is AUTH_SYS only; access control is by client IP on the server side."},
                    {"method": "DELETE", "path": "/api/nfs-shares/:id",
                     "summary": "Forget a share and drop its entries from the ISO store."},
                    {"method": "POST", "path": "/api/nfs-shares/:id/scan",
                     "summary": "Re-list a share for new ISOs."},
                ],
            },
            {
                "name": "SFTP shares",
                "endpoints": [
                    {"method": "GET", "path": "/api/sftp-shares",
                     "summary": "List configured SFTP-over-SSH shares with connection state and iso counts."},
                    {"method": "POST", "path": "/api/sftp-shares",
                     "summary": "Register an SFTP share. Body: { server, export, username, port?, password? | private_key? + passphrase? }. The server's SSH host key is pinned trust-on-first-use."},
                    {"method": "DELETE", "path": "/api/sftp-shares/:id",
                     "summary": "Forget a share, drop its entries from the ISO store, and scrub its credentials file."},
                    {"method": "POST", "path": "/api/sftp-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/:slot",
                     "summary": "Upload a custom logo for a slot (light | dark | client). Multipart 'file', PNG/SVG/JPEG/WebP/GIF up to 2 MB. The client slot is raster-only."},
                    {"method": "DELETE", "path": "/api/branding/logo/:slot",
                     "summary": "Remove the custom logo for a slot and revert to the bundled mark."},
                    {"method": "GET", "path": "/branding/pxe-logo",
                     "summary": "Raster form of the operator's 'client' logo for the iPXE menu's `console --picture`. Default background when unset/SVG."},
                    {"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": "Unattended answer files",
                "endpoints": [
                    {"method": "GET", "path": "/api/unattended",
                     "summary": "List uploaded answer files (Kickstart / Preseed / Autoinstall / Windows answer file)."},
                    {"method": "POST", "path": "/api/unattended",
                     "summary": "Upload an answer file (multipart 'file', .ks/.cfg/.seed/.yaml/.yml/.xml/user-data, up to 1 MB)."},
                    {"method": "DELETE", "path": "/api/unattended/:id",
                     "summary": "Delete an uploaded answer file."},
                    {"method": "GET", "path": "/unattended/:id",
                     "summary": "Public: serve an answer file with {{HOSTNAME}}/{{IP}}/{{MAC}} substituted from the query string."},
                ],
            },
            {
                "name": "Queued Deployment",
                "endpoints": [
                    {"method": "GET", "path": "/api/queue",
                     "summary": "List queue entries (waiting + assigned, with any deployment profile)."},
                    {"method": "POST", "path": "/api/queue/assign",
                     "summary": "Assign a target image to queued clients. Body: { target, entry_ids }."},
                    {"method": "PUT", "path": "/api/queue/:entry_id/profile",
                     "summary": "Set a queued device's deployment profile. Body: { auto_hostname?, auto_ip?, unattended_file? }."},
                    {"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, auto_hostname?, auto_ip?, unattended_file? }."},
                    {"method": "DELETE", "path": "/api/hosts/:mac",
                     "summary": "Remove a binding."},
                    {"method": "POST", "path": "/api/hosts/:mac/wol",
                     "summary": "Send a Wake-on-LAN magic packet to a bound MAC (limited + subnet broadcast)."},
                    {"method": "GET", "path": "/api/boot-log",
                     "summary": "Ring of recent boot events (timestamp, mac, ip, target)."},
                ],
            },
            {
                "name": "Notifications & updates",
                "endpoints": [
                    {"method": "GET", "path": "/api/notify",
                     "summary": "Current notification config (SMTP password redacted)."},
                    {"method": "PUT", "path": "/api/notify",
                     "summary": "Replace notification config. Body: { enabled, kind, webhook_url, smtp_* }."},
                    {"method": "POST", "path": "/api/notify/test",
                     "summary": "Send a test notification using the saved config."},
                    {"method": "GET", "path": "/api/updates/check",
                     "summary": "Compare the running version against the latest Gitea release."},
                ],
            },
            {
                "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() }))
}

/// Per-theme branding presence, nested under [`StatusResponse::branding`].
#[derive(Serialize)]
struct BrandingStatus {
    light: bool,
    dark: bool,
    client: bool,
    rev: u64,
}

/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
/// `json!` blob — the typed struct makes the contract with the WebUI
/// compile-checked. Field names ARE the JSON keys; do not rename without
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
/// the wire shape is byte-identical to the previous `json!` output.
#[derive(Serialize)]
struct StatusResponse {
    version: &'static str,
    public_base_url: String,
    iso_count: usize,
    client_count: usize,
    queue_count: usize,
    imaging_count: usize,
    waiting_count: usize,
    ipxe_assets: Vec<String>,
    settings: Settings,
    smb: Option<SmbState>,
    smb_share_count: usize,
    smb_share_reachable: usize,
    nfs_share_count: usize,
    nfs_share_reachable: usize,
    sftp_share_count: usize,
    sftp_share_reachable: usize,
    host_bindings: usize,
    custom_logo: bool,
    branding: BrandingStatus,
    unattended_count: usize,
    uptime_secs: i64,
    started_at: time::OffsetDateTime,
    nic_name: String,
    subnet_mask: String,
    gateway: String,
}

async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
    let smb = state.smb.as_ref().map(|s| s.snapshot());
    // v0.4.65+v0.4.67: external storage shares — SMB (userspace
    // smbclient) and NFS (in-process nfs3_client). Dashboard tile
    // sums both so operators see a single "reachable shares" number.
    let smb_shares = state.smb_shares.list();
    let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
    let nfs_shares = state.nfs_shares.list();
    let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
    // v0.5.5: SFTP shares fold into the same "reachable shares" tile.
    let sftp_shares = state.sftp_shares.list();
    let sftp_reachable = sftp_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 + nfs_reachable + sftp_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(StatusResponse {
        version: env!("CARGO_PKG_VERSION"),
        public_base_url: state.public_base_url.clone(),
        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_share_count: smb_shares.len(),
        smb_share_reachable: smb_reachable,
        // v0.4.67: NFSv3 share counts. The dashboard tile sums these with
        // the SMB counts above ("N shares reachable") so the top-line
        // metric works regardless of protocol mix.
        nfs_share_count: nfs_shares.len(),
        nfs_share_reachable: nfs_reachable,
        // v0.5.5: SFTP share counts, summed into the same dashboard tile.
        sftp_share_count: sftp_shares.len(),
        sftp_share_reachable: sftp_reachable,
        host_bindings: state.hosts.len(),
        custom_logo: state.branding.has_any_web_logo(),
        branding: BrandingStatus {
            light: state.branding.has_logo(LogoSlot::Light),
            dark: state.branding.has_logo(LogoSlot::Dark),
            client: state.branding.has_logo(LogoSlot::Client),
            rev: state.branding.logo_rev(),
        },
        unattended_count: state.unattended.len(),
        uptime_secs,
        started_at: state.started_at,
        nic_name: state.nic_name.clone(),
        subnet_mask: state.subnet_mask.clone(),
        gateway: state.gateway.clone(),
    })
}

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"
            );
            // Carry `?mac=` so the per-entry handler can resolve this
            // device's deployment profile (auto hostname/IP + unattended
            // file) and inject the unattended kernel args, mirroring the
            // pinned-host path.
            let qmac = g.mac.clone();
            text_plain(format!(
                "#!ipxe\n\
                 echo Queue assignment received: {target}\n\
                 chain {base}/boot/{target}.ipxe?mac={qmac} || 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_set_profile(
    State(state): State<AppState>,
    AxumPath(entry_id): AxumPath<String>,
    Json(body): Json<DeployProfile>,
) -> Response {
    let profile = body.normalized();
    if let Err(msg) = validate_profile(&state, &profile) {
        return (StatusCode::BAD_REQUEST, msg).into_response();
    }
    match state.queue.set_profile(&entry_id, profile) {
        Some(entry) => (StatusCode::OK, Json(entry)).into_response(),
        None => (StatusCode::NOT_FOUND, "no such queue entry").into_response(),
    }
}

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(),
    }
}

// ─── NFS share API (v0.4.67) ───────────────────────────────────────────────
//
// Parallel to the SMB shares API. The pure-Rust NFSv3 client
// (`nfs3_client`) gives us in-process listing and streaming, no
// subprocess. Unlike SMB, NFS-sourced ISOs support HTTP Range
// requests — NFSv3 READ3 takes an explicit offset.

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

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

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

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

// ─── SFTP share API (v0.5.5) ───────────────────────────────────────────────
//
// Parallel to the NFS shares API. The pure-Rust `russh` + `russh-sftp`
// client gives us in-process listing and streaming, no subprocess. Like
// NFS (and unlike SMB), SFTP-sourced ISOs support HTTP Range requests —
// SFTP opens a seekable file handle. Auth is password OR SSH private
// key; the server's host key is pinned trust-on-first-use.

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

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

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

async fn api_sftp_shares_scan(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Response {
    match state.sftp_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,
    /// v0.5.2: optional unattended-install profile. Flattened so the
    /// front-end posts `auto_hostname` / `auto_ip` / `unattended_file`
    /// at the top level alongside mac/target/label.
    #[serde(default, flatten)]
    profile: DeployProfile,
}

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 profile = body.profile.normalized();
    if let Err(msg) = validate_profile(&state, &profile) {
        return (StatusCode::BAD_REQUEST, msg).into_response();
    }
    let binding = state.hosts.upsert(mac, target, body.label.trim(), profile);
    (StatusCode::CREATED, Json(binding)).into_response()
}

/// Shared validation for a deployment profile (host pin + queue profile):
/// the referenced unattended file must exist, and a supplied IP must
/// parse. Hostname is free-form (installers vary), so we only length-cap
/// it (done in `DeployProfile::normalized`).
fn validate_profile(state: &AppState, profile: &DeployProfile) -> Result<(), String> {
    if let Some(id) = profile.unattended_file.as_deref() {
        if state.unattended.get(id).is_none() {
            return Err(format!("unknown unattended file: {id}"));
        }
    }
    if let Some(ip) = profile.auto_ip.as_deref() {
        if ip.parse::<std::net::IpAddr>().is_err() {
            return Err(format!("auto_ip is not a valid IP address: {ip}"));
        }
    }
    Ok(())
}

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
    }
}

/// v0.5.0: Wake-on-LAN a bound host. Sends a magic packet to the limited
/// broadcast (255.255.255.255) and the server's own subnet broadcast
/// (computed from the advertised IP + detected mask), which covers the
/// common "same VLAN as OpenPXE" case with zero network config. We only
/// wake MACs that are actually bound — keeps this from being an open
/// "spray packets at any MAC" endpoint.
async fn api_hosts_wol(State(state): State<AppState>, AxumPath(mac): AxumPath<String>) -> Response {
    if state.hosts.lookup(&mac).is_none() {
        return (
            StatusCode::NOT_FOUND,
            "no host binding for that MAC — bind it first",
        )
            .into_response();
    }

    // Compute the server's subnet broadcast from the advertised IP +
    // detected mask so the packet reaches the right VLAN even if the
    // limited broadcast is filtered. Best-effort: skip if either won't
    // parse.
    let server_ip = state
        .public_base_url
        .strip_prefix("http://")
        .unwrap_or(&state.public_base_url)
        .split(':')
        .next()
        .unwrap_or("")
        .parse::<std::net::Ipv4Addr>();
    let mask = state.subnet_mask.parse::<std::net::Ipv4Addr>();
    let mut broadcasts = Vec::new();
    if let (Ok(ip), Ok(m)) = (server_ip, mask) {
        broadcasts.push(wol::subnet_broadcast(ip, m));
    }

    // The send is a blocking std UDP call; push it off the async
    // executor.
    let mac_owned = mac.clone();
    let result = tokio::task::spawn_blocking(move || wol::wake(&mac_owned, &broadcasts)).await;
    match result {
        Ok(Ok(n)) => {
            // Fire-and-forget notification — nice "someone woke a box"
            // signal, never blocks the response.
            spawn_notify(
                &state,
                "Wake-on-LAN sent",
                &format!("OpenPXE sent a Wake-on-LAN magic packet to {mac}."),
            );
            Json(json!({ "ok": true, "broadcasts": n })).into_response()
        }
        Ok(Err(e)) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("wol task failed: {e}"),
        )
            .into_response(),
    }
}

// ─── Notifications (v0.5.0) ───────────────────────────────────────────────

async fn api_notify_get(State(state): State<AppState>) -> Json<NotifyConfig> {
    // Redact the SMTP password before it leaves the process.
    Json(state.notify.snapshot().redacted())
}

async fn api_notify_put(State(state): State<AppState>, Json(cfg): Json<NotifyConfig>) -> Response {
    match state.notify.replace(cfg) {
        Ok(saved) => (StatusCode::OK, Json(saved.redacted())).into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

/// Send a test notification using the *currently saved* config (not the
/// request body) so the operator validates exactly what's persisted.
async fn api_notify_test(State(state): State<AppState>) -> Response {
    let cfg = state.notify.snapshot();
    match crate::notify::send(
        &cfg,
        "OpenPXE test notification",
        "If you're reading this, OpenPXE notifications are wired up correctly. \
         This is a test from the Advanced settings tab.",
    )
    .await
    {
        Ok(()) => Json(json!({ "ok": true })).into_response(),
        Err(e) => (StatusCode::BAD_GATEWAY, e).into_response(),
    }
}

// ─── Update check (v0.5.0) ────────────────────────────────────────────────

/// Query the project's Gitea releases API for the latest published tag
/// and compare it to the running version. This is the only outbound
/// call OpenPXE makes that isn't operator-initiated data movement, and
/// it's strictly on-demand (the About tab's "Check for updates" button)
/// — never a background poll, keeping the air-gapped promise intact.
async fn api_updates_check() -> Response {
    let current = env!("CARGO_PKG_VERSION");
    let Some(api) = gitea_releases_api_url() else {
        return Json(json!({
            "current": current,
            "error": "repository URL not configured at build time",
        }))
        .into_response();
    };

    let client = match reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(8))
        .user_agent(concat!("OpenPXE/", env!("CARGO_PKG_VERSION")))
        .build()
    {
        Ok(c) => c,
        Err(e) => {
            return Json(json!({ "current": current, "error": format!("client: {e}") }))
                .into_response();
        }
    };

    match client.get(&api).send().await {
        Ok(resp) if resp.status().is_success() => {
            let body: serde_json::Value = resp.json().await.unwrap_or(json!({}));
            let latest_tag = body
                .get("tag_name")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let html_url = body
                .get("html_url")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            let update_available = version_is_newer(latest_tag.trim_start_matches('v'), current);
            Json(json!({
                "current": current,
                "latest": latest_tag,
                "update_available": update_available,
                "html_url": html_url,
            }))
            .into_response()
        }
        Ok(resp) => Json(json!({
            "current": current,
            "error": format!("releases API returned HTTP {}", resp.status()),
        }))
        .into_response(),
        Err(e) => Json(json!({
            "current": current,
            "error": format!("could not reach the releases API: {e}"),
        }))
        .into_response(),
    }
}

/// Derive the Gitea `releases/latest` API URL from the compile-time
/// repository URL (`https://host/owner/repo`).
fn gitea_releases_api_url() -> Option<String> {
    let repo = option_env!("CARGO_PKG_REPOSITORY").unwrap_or("");
    let rest = repo
        .strip_prefix("https://")
        .or_else(|| repo.strip_prefix("http://"))?;
    let mut parts = rest.trim_end_matches('/').splitn(3, '/');
    let host = parts.next()?;
    let owner = parts.next()?;
    let name = parts.next()?;
    if host.is_empty() || owner.is_empty() || name.is_empty() {
        return None;
    }
    Some(format!(
        "https://{host}/api/v1/repos/{owner}/{name}/releases/latest"
    ))
}

/// Compare two dotted numeric versions; `true` when `latest` is strictly
/// newer than `current`. Non-numeric / malformed parts compare as 0, so
/// a garbage tag never falsely reports an update.
fn version_is_newer(latest: &str, current: &str) -> bool {
    fn parts(v: &str) -> Vec<u64> {
        v.split('.')
            .map(|p| {
                p.chars()
                    .take_while(char::is_ascii_digit)
                    .collect::<String>()
            })
            .map(|s| s.parse::<u64>().unwrap_or(0))
            .collect()
    }
    let (l, c) = (parts(latest), parts(current));
    for i in 0..l.len().max(c.len()) {
        let lv = l.get(i).copied().unwrap_or(0);
        let cv = c.get(i).copied().unwrap_or(0);
        if lv != cv {
            return lv > cv;
        }
    }
    false
}

/// Fire a notification on a detached task. Never blocks the caller and
/// never surfaces an error — boot/WoL paths must not hinge on a webhook.
fn spawn_notify(state: &AppState, subject: &str, body: &str) {
    let cfg = state.notify.snapshot();
    if !cfg.is_usable() {
        return;
    }
    let subject = subject.to_string();
    let body = body.to_string();
    tokio::spawn(async move {
        if let Err(e) = crate::notify::send(&cfg, &subject, &body).await {
            tracing::warn!(target: "openpxe::notify", "notification send failed: {e}");
        }
    });
}

// ─── 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.67: gauge tracks reachable external-storage shares
    // across both protocols (SMB userspace + NFSv3 in-process).
    let smb_ok = state
        .smb_shares
        .list()
        .iter()
        .filter(|m| m.reachable)
        .count();
    let nfs_ok = state
        .nfs_shares
        .list()
        .iter()
        .filter(|m| m.reachable)
        .count();
    // v0.5.5: SFTP shares fold into the same reachable-shares gauge.
    let sftp_ok = state
        .sftp_shares
        .list()
        .iter()
        .filter(|m| m.reachable)
        .count();
    state
        .metrics
        .set_nfs_active((smb_ok + nfs_ok + sftp_ok) 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::*;

    fn meta(kind: UnattendedKind) -> UnattendedMeta {
        UnattendedMeta {
            id: "ks1".into(),
            filename: "f".into(),
            kind,
            size_bytes: 0,
            uploaded_at: time::OffsetDateTime::UNIX_EPOCH,
        }
    }

    #[test]
    fn unattended_kickstart_arg_carries_query() {
        let p = DeployProfile {
            auto_hostname: Some("node7".into()),
            auto_ip: Some("10.0.0.7".into()),
            unattended_file: Some("ks1".into()),
        };
        let a = build_unattended_args(
            "http://h",
            &meta(UnattendedKind::Kickstart),
            Some("aa:bb:cc:dd:ee:ff"),
            &p,
        )
        .unwrap();
        assert!(a.starts_with("inst.ks=http://h/unattended/ks1?"), "{a}");
        assert!(a.contains("hostname=node7"), "{a}");
        assert!(a.contains("ip=10.0.0.7"), "{a}");
        // MAC colons are percent-encoded.
        assert!(a.contains("mac=aa%3Abb%3Acc%3Add%3Aee%3Aff"), "{a}");
    }

    #[test]
    fn unattended_preseed_appends_hostname_kernel_arg() {
        let p = DeployProfile {
            auto_hostname: Some("deb1".into()),
            ..Default::default()
        };
        let a =
            build_unattended_args("http://h/", &meta(UnattendedKind::Preseed), None, &p).unwrap();
        assert!(
            a.starts_with("auto=true priority=critical url=http://h/unattended/ks1"),
            "{a}"
        );
        assert!(a.ends_with(" hostname=deb1"), "{a}");
    }

    #[test]
    fn unattended_autoinstall_uses_nocloud_seed_dir() {
        let p = DeployProfile {
            auto_hostname: Some("u1".into()),
            auto_ip: Some("10.1.1.5".into()),
            unattended_file: Some("ks1".into()),
        };
        let a = build_unattended_args(
            "http://h",
            &meta(UnattendedKind::Autoinstall),
            Some("aa:bb"),
            &p,
        )
        .unwrap();
        assert!(
            a.starts_with("autoinstall ds=nocloud-net;s=http://h/unattended/ks1/"),
            "{a}"
        );
        assert!(a.ends_with('/'), "seed URL must end with '/': {a}");
        // The ctx segment round-trips back to the per-host values.
        let ctx = a.trim_end_matches('/').rsplit('/').next().unwrap();
        let (mac, host, ip) = decode_seed_ctx(ctx);
        assert_eq!(mac.as_deref(), Some("aa:bb"));
        assert_eq!(host.as_deref(), Some("u1"));
        assert_eq!(ip.as_deref(), Some("10.1.1.5"));
    }

    #[test]
    fn windows_answer_file_is_not_injected() {
        let p = DeployProfile {
            unattended_file: Some("ks1".into()),
            ..Default::default()
        };
        assert!(
            build_unattended_args("http://h", &meta(UnattendedKind::AnswerFile), None, &p)
                .is_none()
        );
    }

    #[test]
    fn seed_ctx_empty_segment_decodes_to_none() {
        let ctx = encode_seed_ctx(None, None, None);
        let (m, h, i) = decode_seed_ctx(&ctx);
        assert!(m.is_none() && h.is_none() && i.is_none());
        // Garbage decodes safely to all-None.
        let (m2, h2, i2) = decode_seed_ctx("!!!not-base64!!!");
        assert!(m2.is_none() && h2.is_none() && i2.is_none());
    }

    #[test]
    fn pct_encode_escapes_reserved() {
        assert_eq!(pct_encode("aa:bb cc"), "aa%3Abb%20cc");
        assert_eq!(pct_encode("node-7.lab_1~"), "node-7.lab_1~");
    }

    #[test]
    fn version_newer_detects_updates() {
        assert!(version_is_newer("0.5.1", "0.5.0"));
        assert!(version_is_newer("0.6.0", "0.5.9"));
        assert!(version_is_newer("1.0.0", "0.9.9"));
        assert!(!version_is_newer("0.5.0", "0.5.0"));
        assert!(!version_is_newer("0.4.69", "0.5.0"));
        // A garbage / empty tag must never falsely report an update.
        assert!(!version_is_newer("", "0.5.0"));
        assert!(!version_is_newer("not-a-version", "0.5.0"));
    }

    #[test]
    fn gitea_api_url_derives_from_repo() {
        // The crate inherits the workspace `repository`, so
        // CARGO_PKG_REPOSITORY is populated and the update check has a
        // real URL to hit (regressed once when the inherit was missing).
        let u = gitea_releases_api_url().expect("repository must be configured at build time");
        assert!(u.contains("/api/v1/repos/"), "got: {u}");
        assert!(u.ends_with("/releases/latest"), "got: {u}");
    }

    #[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);
    }
}
