//! End-to-end HTTP integration test.
//!
//! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Gated Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback.
//!
//! This is the closest we can get to "real PXE client" without QEMU; the
//! TFTP leg is separately unit-tested in `crates/tftp`. Between the two,
//! every HTTP endpoint a real client touches is covered by a test.

use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir;
use tower::ServiceExt;

/// Build a tiny valid ISO9660 blob with volume label "ALPINE-TEST" so
/// introspection identifies it as Alpine.
fn fake_alpine_iso() -> Vec<u8> {
    let mut buf = vec![0u8; 32 * 2048];
    let off = 16 * 2048;
    buf[off] = 0x01;
    buf[off + 1..off + 6].copy_from_slice(b"CD001");
    buf[off + 6] = 0x01;
    let label = b"ALPINE-TEST".to_vec();
    let mut padded = label.clone();
    padded.resize(32, b' ');
    buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
    let term = 17 * 2048;
    buf[term] = 0xFF;
    buf[term + 1..term + 6].copy_from_slice(b"CD001");
    buf[term + 6] = 0x01;
    buf
}

fn multipart_iso_body(filename: &str, bytes: &[u8]) -> (String, Vec<u8>) {
    let boundary = "----PxeForgeTestBoundary1234";
    let mut body = Vec::new();
    body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
    body.extend_from_slice(
        format!(
            "Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n"
        ).as_bytes(),
    );
    body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
    body.extend_from_slice(bytes);
    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
    let ct = format!("multipart/form-data; boundary={boundary}");
    (ct, body)
}

async fn get(router: &axum::Router, path: &str) -> (StatusCode, Vec<u8>) {
    let res = router
        .clone()
        .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
        .await
        .unwrap();
    let status = res.status();
    let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
    (status, body)
}

async fn post_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
    let res = router
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(path)
                .header("content-type", "application/json")
                .body(Body::from(body.to_owned()))
                .unwrap(),
        )
        .await
        .unwrap();
    let status = res.status();
    let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec();
    (status, body)
}

async fn build_state() -> (AppState, tempfile::TempDir) {
    let dir = tempdir().unwrap();
    let iso_store = IsoStore::new(dir.path().join("isos"));
    iso_store.ensure_dirs().await.unwrap();
    let clients = ClientRegistry::new();
    let gates = GateQueue::new();
    let settings = SettingsStore::load_or_default(dir.path());
    let nfs = NfsManager::new(dir.path(), iso_store.clone());
    iso_store.set_nfs_root(nfs.mount_root());
    let log_bus = LogBus::new(64);
    let state = AppState {
        iso_store,
        clients,
        gates,
        settings,
        smb: None,
        nfs,
        log_bus,
        started_at: time::OffsetDateTime::now_utc(),
        public_base_url: "http://127.0.0.1".into(),
        nic_name: "lo".into(),
        subnet_mask: "255.0.0.0".into(),
        gateway: "127.0.0.1".into(),
    };
    (state, dir)
}

#[tokio::test]
async fn health_and_ready_endpoints() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = get(&app, "/healthz").await;
    assert_eq!(s, StatusCode::OK);
    assert_eq!(b, b"ok\n");
    // /readyz should 503 when no iPXE binaries bundled at test time — this
    // actually depends on whether CI has fetched them. Accept either.
    let (s2, _) = get(&app, "/readyz").await;
    assert!(
        s2 == StatusCode::OK || s2 == StatusCode::SERVICE_UNAVAILABLE,
        "unexpected readyz status: {s2}"
    );
}

#[tokio::test]
async fn upload_introspects_and_generates_boot_entry() {
    let (state, _dir) = build_state().await;
    let app = build_router(state.clone());

    let iso = fake_alpine_iso();
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::CREATED, "upload failed");

    // Confirm the ISO shows up in the menu.
    let (_, menu) = get(&app, "/boot.ipxe").await;
    let menu = String::from_utf8(menu).unwrap();
    assert!(menu.contains("Linux Installers"), "menu missing Linux submenu:\n{menu}");

    let (_, linux) = get(&app, "/boot/_linux_menu.ipxe").await;
    let linux = String::from_utf8(linux).unwrap();
    assert!(linux.contains("fake-alpine-linux"), "linux submenu missing entry:\n{linux}");
    assert!(linux.contains("[ 0 MB]") || linux.contains("[  0 MB]"),
        "size label missing in {linux}");

    // Per-entry boot script should include kernel + initrd URLs + boot.
    let (_, entry) = get(&app, "/boot/fake-alpine-linux.ipxe").await;
    let entry = String::from_utf8(entry).unwrap();
    assert!(entry.contains("kernel http://127.0.0.1/iso/fake-alpine/boot/vmlinuz-lts"));
    assert!(entry.contains("initrd http://127.0.0.1/iso/fake-alpine/boot/initramfs-lts"));
    assert!(entry.contains("boot || goto failed"));
}

#[tokio::test]
async fn iso_range_request_slices_correctly() {
    let (state, _dir) = build_state().await;
    let app = build_router(state.clone());
    let iso = fake_alpine_iso();
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &iso);
    app.clone()
        .oneshot(
            Request::builder()
                .method("POST").uri("/api/isos")
                .header("content-type", ct)
                .body(Body::from(body)).unwrap()).await.unwrap();

    // Range bytes=0x8000-0x8005 should return the PVD signature byte.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .uri("/iso/fake-alpine.iso")
                .header(header::RANGE, "bytes=32768-32773")
                .body(Body::empty()).unwrap())
        .await.unwrap();
    assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT);
    let slice = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
    assert_eq!(slice[0], 0x01); // PVD type
    assert_eq!(&slice[1..6], b"CD001");
}

#[tokio::test]
async fn gated_deployment_full_flow() {
    let (state, _dir) = build_state().await;
    let app = build_router(state.clone());

    // Upload an ISO so the target exists.
    let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
    app.clone()
        .oneshot(Request::builder().method("POST").uri("/api/isos")
            .header("content-type", ct).body(Body::from(body)).unwrap())
        .await.unwrap();

    // Two clients join.
    let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await;
    let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await;
    let s1 = String::from_utf8(join1).unwrap();
    let s2 = String::from_utf8(join2).unwrap();
    assert!(s1.contains("Gate Position 1"));
    assert!(s2.contains("Gate Position 2"));

    let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
        .unwrap().to_string();
    let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
        .unwrap().to_string();

    // Kick off a long-poll for client 1 in the background. Then assign.
    let app2 = app.clone();
    let poll_future = tokio::spawn(async move {
        let uri = format!("/api/gate/poll/{gate1_id}");
        get(&app2, &uri).await
    });

    // Give the poll a moment to register its notify subscription.
    tokio::time::sleep(std::time::Duration::from_millis(50)).await;

    // Operator assigns.
    let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#);
    let (s, b) = post_json(&app, "/api/gate/assign", &body).await;
    assert_eq!(s, StatusCode::OK);
    let assign_json = String::from_utf8(b).unwrap();
    assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");

    // Now assign to gate 1 too so the background poll wakes.
    let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#);
    post_json(&app, "/api/gate/assign", &body).await;

    let (poll_status, poll_body) = poll_future.await.unwrap();
    assert_eq!(poll_status, StatusCode::OK);
    let poll_s = String::from_utf8(poll_body).unwrap();
    assert!(
        poll_s.contains("chain http://127.0.0.1/boot/fake-alpine-linux.ipxe"),
        "poll response should chain the boot script:\n{poll_s}"
    );
    // Retry-on-error fallback must be present.
    assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"),
        "retry fallback missing");

    // Bad target must be rejected.
    let (_, bad) = post_json(&app, "/api/gate/assign",
        r#"{"target":"does-not-exist","gate_ids":[]}"#).await;
    let bad_s = String::from_utf8(bad).unwrap();
    assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
}

#[tokio::test]
async fn settings_put_persists_across_reads() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let body = serde_json::json!({
        "boot_menu_timeout_secs": 42,
        "timeout_action": "local_hdd",
        "windows_enabled": false,
        "smb_host_override": "",
        "extra_kernel_args": "console=ttyS0",
        "default_local_hdd": true,
        "gate_wait_max_secs": 0
    }).to_string();

    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/settings")
                .header("content-type", "application/json")
                .body(Body::from(body))
                .unwrap())
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    let (_, g) = get(&app, "/api/settings").await;
    let got: serde_json::Value = serde_json::from_slice(&g).unwrap();
    assert_eq!(got["boot_menu_timeout_secs"], 42);
    assert_eq!(got["timeout_action"], "local_hdd");
    assert_eq!(got["extra_kernel_args"], "console=ttyS0");

    // And the menu should now use the new timeout.
    let (_, menu) = get(&app, "/boot.ipxe").await;
    let menu = String::from_utf8(menu).unwrap();
    assert!(menu.contains("--timeout 42000"),
        "menu should reflect 42s timeout:\n{menu}");
    assert!(menu.contains("--default local"),
        "menu should default to local:\n{menu}");
}

#[tokio::test]
async fn reboot_and_firmware_exit_in_tools_menu() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (_, tools) = get(&app, "/boot/_tools_menu.ipxe").await;
    let tools = String::from_utf8(tools).unwrap();
    assert!(tools.contains("Reboot Computer"),
        "tools menu missing Reboot item:\n{tools}");
    assert!(tools.contains("Exit and continue BIOS boot"),
        "tools menu missing firmware-exit item:\n{tools}");
    assert!(tools.contains("&& reboot"),
        "reboot command not wired:\n{tools}");
    assert!(tools.contains("&& exit 0"),
        "firmware exit command not wired:\n{tools}");
}

#[tokio::test]
async fn ui_assets_served_offline() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    for (path, ct) in [
        ("/", "text/html"),
        ("/assets/app.js", "application/javascript"),
        ("/assets/app.css", "text/css"),
        ("/assets/logo.svg", "image/svg+xml"),
    ] {
        let res = app
            .clone()
            .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
            .await.unwrap();
        assert_eq!(res.status(), StatusCode::OK, "{path} not 200");
        let got = res.headers()
            .get(header::CONTENT_TYPE).unwrap()
            .to_str().unwrap();
        assert!(got.starts_with(ct), "{path} ct={got}, expected {ct}");
    }
}

#[tokio::test]
async fn no_external_urls_in_generated_ipxe() {
    // Sanity check that nothing we serve points off-server.
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    for path in ["/boot.ipxe", "/boot/_tools_menu.ipxe", "/boot/_linux_menu.ipxe",
                 "/boot/_shell.ipxe", "/boot/_nic.ipxe", "/boot/_local.ipxe"] {
        let (_, body) = get(&app, path).await;
        let s = String::from_utf8(body).unwrap();
        // The only URLs we should emit are relative to our own public_base_url.
        for url in ["github.com", "googleapis", "cdn.", "cdnjs", "unpkg", "jsdelivr"] {
            assert!(!s.contains(url), "{path} references external host {url}:\n{s}");
        }
        // Confirm URLs are all ours.
        for line in s.lines() {
            if let Some(idx) = line.find("http://") {
                let rest = &line[idx..];
                assert!(rest.starts_with("http://127.0.0.1"),
                    "{path} references non-public-base URL: {line}");
            }
        }
    }
}

// ── Phase 4 integration tests ────────────────────────────────────────────

#[tokio::test]
async fn nfs_add_with_bad_export_is_rejected() {
    // Validation must happen before we shell out to /bin/mount —
    // otherwise the operator sees opaque kernel errors instead of a
    // clear "your export must start with /" hint.
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = post_json(
        &app,
        "/api/nfs",
        r#"{"server":"10.0.0.5","export":"isos","version":"v41","read_only":true}"#,
    )
    .await;
    assert_eq!(s, StatusCode::BAD_REQUEST);
    let msg = String::from_utf8_lossy(&b);
    assert!(msg.contains("export"), "expected validation hint, got: {msg}");
}

#[tokio::test]
async fn nfs_list_starts_empty() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let (s, b) = get(&app, "/api/nfs").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
}

#[tokio::test]
async fn terminal_help_and_status_round_trip() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // Empty command -> help banner.
    let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert!(v["output"].as_str().unwrap().contains("PXEForge terminal"));

    // status -> contains the version banner.
    let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    let out = v["output"].as_str().unwrap();
    assert!(out.starts_with("PXEForge"), "unexpected status output: {out}");
    assert!(out.contains("isos:"), "status missing iso line: {out}");

    // Unknown command -> ok=false plus help hint.
    let (_, b) = post_json(&app, "/api/terminal", r#"{"command":"frobnicate"}"#).await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["ok"], false);
    assert!(v["output"].as_str().unwrap().contains("unknown command"));
}

#[tokio::test]
async fn log_recent_returns_buffered_lines() {
    // The terminal command we issued seeds the log bus, so a follow-up
    // /api/log/recent must surface those lines as JSON.
    let (state, _dir) = build_state().await;
    let app = build_router(state);
    let _ = post_json(&app, "/api/terminal", r#"{"command":"version"}"#).await;
    let (s, b) = get(&app, "/api/log/recent").await;
    assert_eq!(s, StatusCode::OK);
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    let lines = v["lines"].as_array().expect("lines array");
    assert!(!lines.is_empty(), "log buffer should have at least one line");
    // Every entry should have the canonical timestamp/level/target/message.
    for l in lines {
        for k in ["timestamp", "level", "target", "message"] {
            assert!(l.get(k).is_some(), "missing field {k} in log line: {l}");
        }
    }
}

#[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() {
    let (state, _dir) = build_state().await;
    let app = build_router(state);

    // GET starts blank.
    let (_, b) = get(&app, "/api/network").await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["dns_server"], "");
    assert_eq!(v["nic_name"], "lo");

    // PUT updates only the DNS field.
    let res = app
        .clone()
        .oneshot(
            Request::builder()
                .method("PUT")
                .uri("/api/network")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"dns_server":"10.0.0.1"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(res.status(), StatusCode::NO_CONTENT);

    let (_, b) = get(&app, "/api/network").await;
    let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
    assert_eq!(v["dns_server"], "10.0.0.1");
}
