//! Axum router, handlers, and API endpoints.
//!
//! Route groups:
//!
//! | Group            | Purpose                                               |
//! |------------------|-------------------------------------------------------|
//! | `/`              | Web UI (served from `pxeforge-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::ipxe_script::{
    render_entry, render_family_menu, render_gate_entry, render_local_hdd,
    render_menu, render_nic_info, 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,
    extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
    http::{header, HeaderMap, HeaderValue, StatusCode},
    response::{IntoResponse, Response},
    routing::{delete, get, post},
    Json, Router,
};
use pxeforge_core::{ClientEvent, Settings};
use pxeforge_ipxe_assets::asset_bytes;
use pxeforge_iso_store::{IsoMeta, NfsAddRequest};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tower_http::trace::TraceLayer;

pub fn build_router(state: AppState) -> Router {
    Router::new()
        // Web UI (fully offline — no CDN, no external fonts/images).
        .route("/", get(index))
        .route("/assets/app.js", get(ui_js))
        .route("/assets/app.css", get(ui_css))
        .route("/assets/logo.svg", get(ui_logo))
        // iPXE script endpoints.
        .route("/boot.ipxe", get(boot_top_menu))
        .route("/boot/:filename", get(boot_sub))
        // Bundled binaries and raw ISO access.
        .route("/ipxe/:name", get(ipxe_binary))
        .route("/iso/:filename", get(iso_raw))
        .route("/iso/:id/*path", get(iso_file))
        // Container health/readiness probes. `/healthz` is always 200 OK
        // while the HTTP task is alive. `/readyz` additionally requires at
        // least one bundled iPXE binary (without one, no client can PXE).
        .route("/healthz", get(healthz))
        .route("/readyz", get(readyz))
        // JSON API.
        .route("/api/isos", get(api_list_isos).post(api_upload_iso))
        .route("/api/isos/:id", delete(api_delete_iso))
        .route("/api/clients", get(api_list_clients))
        .route("/api/status", get(api_status))
        .route("/api/settings", get(api_get_settings).put(api_put_settings))
        .route("/api/gate", get(api_list_gates))
        .route("/api/gate/join", get(api_gate_join))
        .route("/api/gate/poll/:gate_id", get(api_gate_poll))
        .route("/api/gate/assign", post(api_gate_assign))
        .route("/api/gate/:gate_id", delete(api_gate_release))
        // Phase 4: NFS share manager.
        .route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
        .route("/api/nfs/:id", delete(api_nfs_remove))
        .route("/api/nfs/:id/scan", post(api_nfs_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))
        .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)
}

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

async fn index(State(state): State<AppState>) -> Response {
    let html = pxeforge_webui::index_html(&state.public_base_url);
    ([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
        .into_response()
}

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

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

async fn ui_logo() -> Response {
    (
        [(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
        pxeforge_webui::logo_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()
}

async fn boot_top_menu(State(state): State<AppState>) -> Response {
    let isos = state.iso_store.list();
    let settings = state.settings.snapshot();
    text_plain(render_menu(&isos, &settings, &state.public_base_url))
}

async fn boot_sub(
    State(state): State<AppState>,
    AxumPath(filename): AxumPath<String>,
) -> Response {
    // `/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(base),
        "_util"          => render_util(base),
        "_shell"         => render_shell(base),
        "_nic"           => render_nic_info(base),
        "_gate"          => render_gate_entry(base),
        other => {
            for iso in &isos {
                for entry in &iso.boot_entries {
                    if entry.id == other {
                        return text_plain(render_entry(entry, &settings, base));
                    }
                }
            }
            return (StatusCode::NOT_FOUND, "no such boot entry").into_response();
        }
    };
    text_plain(script)
}

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

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

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

async fn iso_raw(
    State(state): State<AppState>,
    AxumPath(filename): AxumPath<String>,
    headers: HeaderMap,
) -> Response {
    let id = filename.strip_suffix(".iso").unwrap_or(&filename);
    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(),
    }
}

async fn iso_file(
    State(state): State<AppState>,
    AxumPath((id, path)): AxumPath<(String, String)>,
) -> Response {
    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();
    let (start, end, partial) = parse_range(range, total);
    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) -> (u64, u64, bool) {
    let Some(h) = h else { return (0, total.saturating_sub(1), false); };
    let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
    let Some(spec) = s.strip_prefix("bytes=") else { return (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 (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));
    (start, end.min(total.saturating_sub(1)), 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,
    }
}

async fn api_upload_iso(
    State(state): State<AppState>,
    mut multipart: Multipart,
) -> Response {
    while let Ok(Some(mut field)) = multipart.next_field().await {
        if field.name() != Some("file") { continue; }
        let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
        if !filename.to_ascii_lowercase().ends_with(".iso") {
            return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
        }
        let mut handle = match state.iso_store.begin_upload(&filename).await {
            Ok(h) => h,
            Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
        };
        while let Ok(Some(chunk)) = field.chunk().await {
            if let Err(e) = handle.write_chunk(&chunk).await {
                let _ = handle.abort().await;
                return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
            }
        }
        let meta = match handle.finish(&state.iso_store).await {
            Ok(m) => m,
            Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
        };
        return (StatusCode::CREATED, Json(meta)).into_response();
    }
    (StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}

// ─── 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 = pxeforge_ipxe_assets::list_assets();
    let mut problems: Vec<&str> = Vec::new();
    if assets.is_empty() {
        problems.push("no iPXE binaries bundled (run scripts/fetch-ipxe.sh before building)");
    }
    let iso_dir_ok = state.iso_store.list_ok();
    if !iso_dir_ok {
        problems.push("iso directory not readable");
    }
    if problems.is_empty() {
        ([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n").into_response()
    } else {
        let body = format!("not ready:\n- {}\n", problems.join("\n- "));
        (StatusCode::SERVICE_UNAVAILABLE, body).into_response()
    }
}

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

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

async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
    let smb = state.smb.as_ref().map(|s| s.snapshot());
    let nfs = state.nfs.list();
    let nfs_active = nfs.iter().filter(|m| m.mounted).count();
    let isos = state.iso_store.list();
    let gates = state.gates.list();
    // Phase 4: dashboard tracks "imaging" as gates with an assignment
    // already issued — they're the ones actively chaining a boot script.
    let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
    let waiting = gates.len() - imaging;
    let now = time::OffsetDateTime::now_utc();
    let uptime_secs = (now - state.started_at).whole_seconds().max(0);
    Json(json!({
        "version": env!("CARGO_PKG_VERSION"),
        "public_base_url": state.public_base_url,
        "iso_count": isos.len(),
        "client_count": state.clients.list().len(),
        "gate_count": gates.len(),
        "imaging_count": imaging,
        "waiting_count": waiting,
        "ipxe_assets": pxeforge_ipxe_assets::list_assets(),
        "settings": state.settings.snapshot(),
        "smb": smb,
        "nfs_count": nfs.len(),
        "nfs_active": nfs_active,
        "uptime_secs": uptime_secs,
        "started_at": state.started_at,
        "nic_name": state.nic_name,
        "subnet_mask": state.subnet_mask,
        "gateway": state.gateway,
    }))
}

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

async fn api_put_settings(
    State(state): State<AppState>,
    Json(mut new): Json<Settings>,
) -> Response {
    // Guardrail: Windows boot requires the wimboot shim to be bundled.
    // Without it, clients chain a non-existent /ipxe/wimboot and stall.
    if new.windows_enabled {
        let assets = pxeforge_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()
}

// ─── Gated Deployment API ─────────────────────────────────────────────────

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

#[derive(Debug, Deserialize)]
struct GateJoinParams {
    /// 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/gate/poll/<id>`. iPXE keeps fetching
/// until poll returns an actual boot script.
async fn api_gate_join(
    State(state): State<AppState>,
    Query(p): Query<GateJoinParams>,
    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 gate = state.gates.join(&mac, ip, None);
    state.clients.record(
        &mac, ip, None,
        ClientEvent::HttpScriptFetch { target: "gate-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   Gated Deployment - Gate 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/gate/poll/{}\n",
        gate.position, gate.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_gate_poll(
    State(state): State<AppState>,
    AxumPath(gate_id): AxumPath<String>,
) -> Response {
    let Some(notify) = state.gates.notifier(&gate_id) else {
        // Gate 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.gates.touch(&gate_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 gate 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: "pxeforge::gate",
                gate_id=%gate_id, mac=%g.mac, target=%target,
                "gate assignment delivered"
            );
            text_plain(format!(
                "#!ipxe\n\
                 echo Gate assignment received: {target}\n\
                 chain {base}/boot/{target}.ipxe || chain {base}/api/gate/poll/{gate_id}\n"
            ))
        }
        Some(g) => {
            // No assignment yet - loop and re-poll. Repaint position so the
            // UI count stays accurate if other gates were released meanwhile.
            text_plain(format!(
                "#!ipxe\n\
                 echo Gate Position {} - still waiting\n\
                 chain {base}/api/gate/poll/{gate_id}\n",
                g.position
            ))
        }
        None => text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n")),
    }
}

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

async fn api_gate_assign(
    State(state): State<AppState>,
    Json(body): Json<GateAssignBody>,
) -> Json<serde_json::Value> {
    let ids = if body.gate_ids.is_empty() {
        state.gates.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
    } else {
        body.gate_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.gates.assign(&ids, &body.target);
    Json(json!({ "ok": true, "assigned": n, "target": body.target }))
}

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

// ─── NFS share API ─────────────────────────────────────────────────────────

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

async fn api_nfs_add(
    State(state): State<AppState>,
    Json(req): Json<NfsAddRequest>,
) -> Response {
    match state.nfs.add(req).await {
        Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
        // Anything from the manager surfaces as a user-fixable validation
        // error — bad host, kernel without NFS support, missing
        // `mount.nfs`, dead server. We pass the message through verbatim
        // so the UI can show it to the operator.
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

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

async fn api_nfs_scan(
    State(state): State<AppState>,
    AxumPath(id): AxumPath<String>,
) -> Response {
    match state.nfs.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
}

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

    #[test]
    fn range_full() {
        let (s, e, p) = parse_range(None, 1000);
        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);
        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);
        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);
        assert_eq!((s, e, p), (10, 99, true));
    }
}
