//! On-disk ISO store with sidecar metadata files.

use crate::entry::{BootEntry, BootKind, KernelArgs};
use crate::introspect::{introspect, DistroFamily, IntrospectionReport};
use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::io::AsyncWriteExt;

/// Where the bytes for an ISO actually live.
///
/// `Local` — uploaded ISO, sits at `<iso_dir>/<id>.iso`.
/// `Smb` (v0.4.65) — remote SMB share, streamed via Samba's
/// userspace `smbclient` CLI subprocess. No kernel mount, no local
/// cache. Sequential whole-file streaming; HTTP Range requests
/// return 416.
/// `Nfs` (v0.4.67) — remote NFSv3 share, streamed via the pure-Rust
/// `nfs3_client` crate (in-process, no subprocess). Same "works in
/// any container" property as SMB, plus Range requests work because
/// NFSv3 READ3 takes an explicit offset.
/// `Sftp` (v0.5.5) — remote SFTP-over-SSH share, streamed via the
/// pure-Rust `russh` + `russh-sftp` crates (in-process). Like NFS it
/// supports HTTP Range requests because SFTP opens a seekable file
/// handle (`SSH_FXP_READ` at offset).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsoSource {
    #[default]
    Local,
    /// v0.4.65: SMB via userspace `smbclient` works in any container.
    Smb {
        share_id: String,
        /// Filename at the share root. We don't support nested paths
        /// in v0.4.65; ISOs live at the top of the share.
        relative_path: String,
    },
    /// v0.4.67: NFSv3 via the in-process `nfs3_client` crate.
    Nfs {
        share_id: String,
        /// Filename at the export root.
        relative_path: String,
    },
    /// v0.5.5: SFTP-over-SSH via the in-process `russh` + `russh-sftp`
    /// crates.
    Sftp {
        share_id: String,
        /// Filename at the export root.
        relative_path: String,
    },
}

/// Where the ISO lands in the PXE menu hierarchy.
///
/// Auto-detected family (Debian, Windows, …) still drives BIOS/UEFI
/// behaviour and per-entry boot args, but the *menu placement* is
/// operator-controlled — an operator who's uploaded a TinyCore live ISO
/// to use as a recovery shim, or a SystemRescue image, can flip its
/// category to `Tools` so it lands next to memtest/shell instead of
/// under Linux Installers.
///
/// Old `meta.json` files without this field deserialize as `Os`, which
/// matches v0.4.1 behaviour (everything goes under OS Installers).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsoCategory {
    /// "OS Installer" — routed via the auto-detected family into the
    /// Linux / Windows installer submenus.
    #[default]
    Os,
    /// "Tool" — surfaced under the Tools menu next to memtest, shell,
    /// NIC info, etc. Family detection still decides BIOS/UEFI vs
    /// wimboot vs sanboot at boot time.
    Tools,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IsoMeta {
    /// Stable slug used in URLs (derived from the uploaded filename).
    pub id: String,
    pub filename: String,
    pub size_bytes: u64,
    pub sha256_hex: Option<String>,
    #[serde(with = "time::serde::rfc3339")]
    pub uploaded_at: OffsetDateTime,
    pub introspection: IntrospectionReport,
    /// Boot entries this ISO currently exposes in the PXE menu. Usually one,
    /// occasionally two (BIOS + UEFI variant for some RHEL ISOs).
    pub boot_entries: Vec<BootEntry>,
    /// Source of the bytes — local upload (default) or NFS mount.
    /// Old `meta.json` files without this field deserialize as `Local`.
    #[serde(default)]
    pub source: IsoSource,
    /// Optional bcrypt hash of an operator-set password. When present,
    /// `/boot/<entry>.ipxe` returns a `read --secret` prompt instead of
    /// the boot script until the client chains back with the correct
    /// `?token=...`. We never store, log, or transmit the plaintext.
    /// Skipped on serialize when None to keep meta.json clean for
    /// the common no-password case.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub password_hash: Option<String>,
    /// Where the ISO sits in the PXE menu hierarchy — operator-controlled,
    /// not driven by family detection. Defaults to [`IsoCategory::Os`].
    #[serde(default)]
    pub category: IsoCategory,
}

impl IsoMeta {
    /// Convenience predicate the HTTP layer + UI can both use.
    #[must_use]
    pub fn is_password_protected(&self) -> bool {
        self.password_hash
            .as_deref()
            .map(str::trim)
            .is_some_and(|h| !h.is_empty())
    }
}

pub struct UploadHandle {
    pub id: String,
    pub partial_path: PathBuf,
    final_path: PathBuf,
    filename: String,
    hasher: Sha256,
    bytes_written: u64,
    file: tokio::fs::File,
}

impl UploadHandle {
    pub async fn write_chunk(&mut self, chunk: &Bytes) -> Result<()> {
        self.file.write_all(chunk).await?;
        self.hasher.update(chunk);
        self.bytes_written += chunk.len() as u64;
        Ok(())
    }

    /// Flush, close, and atomically rename to the final path. Returns the
    /// final `IsoMeta` including introspection results.
    pub async fn finish(mut self, store: &IsoStore) -> Result<IsoMeta> {
        self.file.flush().await?;
        self.file.sync_all().await?;
        drop(self.file);
        tokio::fs::rename(&self.partial_path, &self.final_path).await?;

        let hash = hex::encode(self.hasher.finalize());
        let introspection = {
            let p = self.final_path.clone();
            tokio::task::spawn_blocking(move || introspect(&p))
                .await
                .map_err(|e| Error::Other(e.into()))?
        };

        let boot_entries = generate_boot_entries(&self.id, &self.filename, &introspection);
        let meta = IsoMeta {
            id: self.id.clone(),
            filename: self.filename,
            size_bytes: self.bytes_written,
            sha256_hex: Some(hash),
            uploaded_at: OffsetDateTime::now_utc(),
            introspection,
            boot_entries,
            source: IsoSource::Local,
            password_hash: None,
            category: IsoCategory::default(),
        };
        store.persist_meta(&meta).await?;
        store.insert(meta.clone());
        Ok(meta)
    }

    pub async fn abort(self) -> Result<()> {
        drop(self.file);
        let _ = tokio::fs::remove_file(&self.partial_path).await;
        Ok(())
    }
}

#[derive(Debug, Default)]
struct Inner {
    isos: HashMap<String, IsoMeta>,
}

#[derive(Debug, Clone)]
pub struct IsoStore {
    iso_dir: Arc<PathBuf>,
    inner: Arc<RwLock<Inner>>,
}

impl IsoStore {
    pub fn new(iso_dir: PathBuf) -> Self {
        Self {
            iso_dir: Arc::new(iso_dir),
            inner: Arc::new(RwLock::new(Inner::default())),
        }
    }

    pub async fn ensure_dirs(&self) -> Result<()> {
        tokio::fs::create_dir_all(self.iso_dir.as_path()).await?;
        Ok(())
    }

    /// Scan the ISO directory on startup and load any sidecar `.meta.json`
    /// files. ISOs without metadata are introspected lazily — we don't block
    /// startup on potentially many GB of scanning.
    pub async fn load_from_disk(&self) -> Result<()> {
        self.ensure_dirs().await?;
        let mut entries = tokio::fs::read_dir(self.iso_dir.as_path()).await?;
        while let Some(e) = entries.next_entry().await? {
            let p = e.path();
            if p.extension().and_then(|s| s.to_str()) != Some("json") {
                continue;
            }
            if !p
                .file_name()
                .and_then(|s| s.to_str())
                .is_some_and(|n| n.ends_with(".meta.json"))
            {
                continue;
            }
            if let Ok(text) = tokio::fs::read_to_string(&p).await {
                if let Ok(meta) = serde_json::from_str::<IsoMeta>(&text) {
                    self.insert(meta);
                }
            }
        }
        Ok(())
    }

    fn insert(&self, meta: IsoMeta) {
        self.inner.write().isos.insert(meta.id.clone(), meta);
    }

    async fn persist_meta(&self, meta: &IsoMeta) -> Result<()> {
        let path = self.meta_path(&meta.id);
        let text = serde_json::to_string_pretty(meta).map_err(|e| Error::Other(e.into()))?;
        tokio::fs::write(path, text).await?;
        Ok(())
    }

    fn meta_path(&self, id: &str) -> PathBuf {
        self.iso_dir.join(format!("{id}.meta.json"))
    }

    fn iso_path(&self, id: &str) -> PathBuf {
        self.iso_dir.join(format!("{id}.iso"))
    }

    pub async fn begin_upload(&self, filename: &str) -> Result<UploadHandle> {
        self.ensure_dirs().await?;
        let id = slugify(filename);
        let final_path = self.iso_path(&id);
        if final_path.exists() {
            return Err(Error::Invalid(format!("iso '{id}' already exists")));
        }
        let partial_path = self.iso_dir.join(format!("{id}.partial"));
        if partial_path.exists() {
            return Err(Error::Invalid(format!("iso '{id}' is already uploading")));
        }
        let file = tokio::fs::File::create(&partial_path).await?;
        Ok(UploadHandle {
            id,
            partial_path,
            final_path,
            filename: filename.to_string(),
            hasher: Sha256::new(),
            bytes_written: 0,
            file,
        })
    }

    /// Readiness probe — is the backing directory reachable? Distinct from
    /// "is there content in it", to avoid an empty store failing health.
    #[must_use]
    pub fn list_ok(&self) -> bool {
        std::fs::read_dir(self.iso_dir.as_path()).is_ok()
    }

    #[must_use]
    pub fn list(&self) -> Vec<IsoMeta> {
        let g = self.inner.read();
        let mut v: Vec<_> = g.isos.values().cloned().collect();
        // Newest-first by upload time.
        v.sort_by_key(|m| std::cmp::Reverse(m.uploaded_at));
        v
    }

    #[must_use]
    pub fn get(&self, id: &str) -> Option<IsoMeta> {
        self.inner.read().isos.get(id).cloned()
    }

    /// Resolve an ISO id to its on-disk path, if any. For local
    /// (uploaded) ISOs this is `<iso_dir>/<id>.iso`. For SMB-sourced
    /// ISOs there is no on-disk path — the HTTP handler must stream
    /// via `SmbShareManager::stream_iso` instead. Returns `None` for
    /// SMB sources or when the file is missing.
    pub fn iso_path_for(&self, id: &str) -> Option<PathBuf> {
        let meta = self.get(id)?;
        match &meta.source {
            IsoSource::Local => {
                let path = self.iso_path(id);
                if path.exists() {
                    Some(path)
                } else {
                    None
                }
            }
            // SMB, NFS, and SFTP sources have no local path — they're
            // streamed in-process. Callers must inspect the source
            // kind first and dispatch to the appropriate share
            // manager.
            IsoSource::Smb { .. } | IsoSource::Nfs { .. } | IsoSource::Sftp { .. } => None,
        }
    }

    /// Delete an ISO and its sidecar metadata. Only acts on local
    /// (uploaded) ISOs; for SMB-backed ISOs the operator must remove
    /// the file from the share or unregister the share entirely.
    pub async fn delete(&self, id: &str) -> Result<()> {
        let meta = self.get(id);
        let is_local = matches!(
            meta.as_ref().map(|m| &m.source),
            Some(IsoSource::Local) | None
        );
        if is_local {
            let iso = self.iso_path(id);
            let meta_path = self.meta_path(id);
            let _ = tokio::fs::remove_file(&iso).await;
            let _ = tokio::fs::remove_file(&meta_path).await;
        }
        self.inner.write().isos.remove(id);
        Ok(())
    }

    /// Register an externally-sourced ISO (SMB share, etc.). Used by
    /// `SmbShareManager` after listing a share. We do **not** persist
    /// a `meta.json` on disk for these — the source of truth is the
    /// share itself, and the manager re-scans on startup.
    pub fn register_external(
        &self,
        id: String,
        filename: String,
        size_bytes: u64,
        introspection: IntrospectionReport,
        boot_entries: Vec<BootEntry>,
        source: IsoSource,
    ) {
        let meta = IsoMeta {
            id: id.clone(),
            filename,
            size_bytes,
            sha256_hex: None,
            uploaded_at: OffsetDateTime::now_utc(),
            introspection,
            boot_entries,
            source,
            password_hash: None,
            category: IsoCategory::default(),
        };
        self.inner.write().isos.insert(id, meta);
    }

    /// Drop every entry that belongs to `share_id`. Used by the SMB
    /// and NFS share managers when an operator removes a share, or
    /// before re-scanning to clean out stale entries. The same id
    /// space serves both protocols — share ids are slugified from
    /// `server+share` (SMB) or `server+export` (NFS) and the
    /// protocol-specific prefix prevents collisions.
    pub fn drop_external_source(&self, share_id: &str) {
        let mut g = self.inner.write();
        g.isos.retain(|_, m| match &m.source {
            IsoSource::Smb { share_id: sid, .. }
            | IsoSource::Nfs { share_id: sid, .. }
            | IsoSource::Sftp { share_id: sid, .. } => sid != share_id,
            IsoSource::Local => true,
        });
    }

    /// Set or clear an ISO's boot password.
    ///
    /// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for
    /// an interactive iPXE prompt, slow enough to be hostile to brute
    /// force on a leaked meta.json) and persists.
    ///
    /// `None` removes the password — the next /boot/<id>.ipxe request
    /// returns the script directly without a prompt.
    ///
    /// We never store, log, or transmit the plaintext.
    pub async fn set_password(&self, id: &str, password: Option<&str>) -> Result<()> {
        let new_hash = match password {
            None => None,
            Some(pw) => {
                let pw = pw.trim();
                if pw.is_empty() {
                    None
                } else {
                    let h = bcrypt::hash(pw, bcrypt::DEFAULT_COST)
                        .map_err(|e| Error::Other(e.into()))?;
                    Some(h)
                }
            }
        };

        // Update in-memory + grab a clone for persistence outside the lock.
        let updated = {
            let mut g = self.inner.write();
            let m = g
                .isos
                .get_mut(id)
                .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
            m.password_hash = new_hash;
            m.clone()
        };
        // NFS-sourced ISOs have no on-disk meta.json — skip persistence
        // for them (the password lives in memory until the manager
        // re-scans the share, then it's gone). Document this in the API
        // handler so the operator knows.
        if matches!(updated.source, IsoSource::Local) {
            self.persist_meta(&updated).await?;
        }
        Ok(())
    }

    /// Flip an ISO's menu category. Persists to `meta.json` for local
    /// ISOs; NFS-sourced ISOs keep the change in memory only (the next
    /// re-scan would overwrite it anyway).
    pub async fn set_category(&self, id: &str, category: IsoCategory) -> Result<IsoMeta> {
        let updated = {
            let mut g = self.inner.write();
            let m = g
                .isos
                .get_mut(id)
                .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
            m.category = category;
            m.clone()
        };
        if matches!(updated.source, IsoSource::Local) {
            self.persist_meta(&updated).await?;
        }
        Ok(updated)
    }

    /// Absolute path to the directory holding local ISO uploads. Used
    /// by the HTTP layer for the disk-space endpoint — the volume that
    /// hosts this directory is what runs out of room first.
    #[must_use]
    pub fn iso_dir(&self) -> PathBuf {
        self.iso_dir.as_path().to_path_buf()
    }

    /// `(total_bytes, available_bytes)` for the filesystem hosting the
    /// ISO directory. Returns `None` if `statvfs` fails (read-only
    /// filesystem with no quota, mount disappeared, …) — callers
    /// should treat that as "unknown" rather than zero.
    ///
    /// Lives here rather than the HTTP crate because `http-api`'s
    /// `#![forbid(unsafe_code)]` rules out the libc FFI directly, and
    /// because this is naturally an `IsoStore` question — the volume
    /// of interest is whatever's hosting the iso dir.
    #[must_use]
    pub fn disk_usage(&self) -> Option<(u64, u64)> {
        disk_usage_for(self.iso_dir.as_path())
    }

    /// Verify a candidate password against the stored bcrypt hash.
    /// Returns:
    /// - `Ok(true)`   — match (or the ISO has no password set; boot is open)
    /// - `Ok(false)`  — mismatch
    /// - `Err(_)`     — id not found, or bcrypt error
    pub fn verify_password(&self, id: &str, candidate: &str) -> Result<bool> {
        let meta = self
            .get(id)
            .ok_or_else(|| Error::Invalid(format!("no such iso '{id}'")))?;
        let Some(hash) = meta.password_hash else {
            return Ok(true); // no password set — anyone can boot
        };
        bcrypt::verify(candidate, &hash).map_err(|e| Error::Other(e.into()))
    }
}

/// Resolve `(total, available)` bytes for the filesystem hosting `path`.
/// Returns `None` if `statvfs` fails.
#[allow(unsafe_code)]
fn disk_usage_for(path: &std::path::Path) -> Option<(u64, u64)> {
    use std::ffi::CString;
    use std::os::unix::ffi::OsStrExt;
    let c = CString::new(path.as_os_str().as_bytes()).ok()?;
    // SAFETY: `statvfs` is repr(C); a zeroed value is a valid initial
    // state per POSIX. The FFI call writes every field we then read.
    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
    // SAFETY: `c` is a NUL-terminated C string pointing into a stack
    // CString that outlives this call; `&mut stat` is a unique aligned
    // pointer to a stack-local `statvfs`. The kernel writes through
    // it but does not retain the pointer past return.
    let rc = unsafe { libc::statvfs(c.as_ptr(), &raw mut stat) };
    if rc != 0 {
        return None;
    }
    // Use f_frsize (fundamental block size). f_bsize is "preferred I/O
    // block" and doesn't always match the unit f_blocks is denominated
    // in — on some BSDs it would over-report by a factor of 8.
    let frsize = stat.f_frsize as u64;
    let total = stat.f_blocks as u64 * frsize;
    let avail = stat.f_bavail as u64 * frsize;
    Some((total, avail))
}

fn slugify(filename: &str) -> String {
    let stem = Path::new(filename)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("iso");
    slugify_str(stem)
}

/// Slugify an arbitrary string to lowercase ASCII alphanumerics, hyphens,
/// and underscores. Public so the NFS manager can mint ids that follow the
/// same rules as upload-time ISO ids.
#[must_use]
pub fn slugify_str(input: &str) -> String {
    input
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect::<String>()
        .trim_matches('-')
        .to_string()
}

/// Public wrapper around [`generate_boot_entries`] so the NFS manager can
/// build entries for shares it just scanned, using the same logic as the
/// upload pipeline. Re-exported via the crate root.
#[must_use]
pub fn generate_boot_entries_for(
    id: &str,
    filename: &str,
    r: &IntrospectionReport,
) -> Vec<BootEntry> {
    generate_boot_entries(id, filename, r)
}

/// Build `BootEntry`s from the introspection report. URLs are relative —
/// the HTTP layer rewrites them with the public base URL per request.
fn generate_boot_entries(id: &str, filename: &str, r: &IntrospectionReport) -> Vec<BootEntry> {
    let title = r
        .volume_label
        .clone()
        .unwrap_or_else(|| filename.to_string());
    match r.family {
        DistroFamily::WindowsPe => {
            // v0.5.8: boot Windows directly via iPXE HTTP sanboot. iPXE
            // exposes the raw ISO as an emulated CD backed by on-demand
            // HTTP range reads, and Windows Setup boots from it. This
            // replaces the old wimboot+SMB chain, which (a) needed an SMB
            // server the host often can't provide (port 445 collisions),
            // (b) served in-ISO files via an ISO9660 lookup that failed on
            // UDF-only Windows 11 ISOs, and (c) required an operator
            // toggle. sanboot needs none of that — just the HTTP port,
            // which works in any environment. The unmodified, stock ISO is
            // served at iso/<id>.iso; nothing is injected into Windows.
            vec![BootEntry {
                id: format!("{id}-windows"),
                title: format!("{title} (Windows)"),
                kind: BootKind::SanBootIso {
                    iso_url: format!("iso/{id}.iso"),
                },
            }]
        }
        fam if r.kernel_path.is_some() => {
            let base = format!("iso/{id}");
            let kernel_url = format!("{base}{}", r.kernel_path.as_deref().unwrap_or(""));
            let initrd_urls = r
                .initrd_paths
                .iter()
                .map(|p| format!("{base}{p}"))
                .collect();
            let args = KernelArgs {
                cmdline: linux_cmdline(fam, id),
            };
            vec![BootEntry {
                id: format!("{id}-linux"),
                title,
                kind: BootKind::LinuxKernel {
                    kernel_url,
                    initrd_urls,
                    args,
                },
            }]
        }
        _ => {
            // Last-resort SAN boot. Won't work for large modern ISOs, but
            // lets the ISO at least appear in the menu.
            vec![BootEntry {
                id: format!("{id}-sanboot"),
                title: format!("{title} (SAN boot — may fail for >1GiB ISOs)"),
                kind: BootKind::SanBootIso {
                    iso_url: format!("iso/{id}.iso"),
                },
            }]
        }
    }
}

fn linux_cmdline(family: DistroFamily, id: &str) -> String {
    // The HTTP layer resolves `${base-url}` at render time.
    let iso_url = format!("${{base-url}}/iso/{id}.iso");
    match family {
        // VMware-UEFI fix (v0.4.5, matching Bootimus v0.1.67's Casper
        // patch): drop `netboot=url url=… ---` in favour of the
        // canonical Casper option `iso-url=` and add `ds=nocloud` so
        // cloud-init / subiquity (live-server) doesn't stall waiting on
        // a metadata datasource that doesn't exist in PXE. Without
        // `ds=nocloud`, Ubuntu live-server / Mint / Pop!_OS / elementary
        // ISOs would boot fine on bare-metal UEFI but hang at "cloud-init
        // running" on VMware-UEFI guests because the vmxnet3 driver's
        // late-init upsets cloud-init's network probe.
        DistroFamily::DebianUbuntu => format!(
            "boot=casper initrd=initrd ds=nocloud ip=dhcp iso-url={iso_url}"
        ),
        DistroFamily::RhelFedora => format!(
            "inst.repo={iso_url} inst.stage2={iso_url} ip=dhcp"
        ),
        DistroFamily::OpenSuse => format!(
            "install={iso_url} netsetup=dhcp"
        ),
        DistroFamily::Arch => {
            "archiso_http_srv=${base-url}/iso/ archisobasedir=arch ip=dhcp copytoram".to_string()
        }
        DistroFamily::Alpine => format!(
            "alpine_repo=${{base-url}}/iso/{id}/ modloop=${{base-url}}/iso/{id}/boot/modloop-lts ip=dhcp"
        ),
        _ => "ip=dhcp".into(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::introspect::{DistroFamily, IntrospectionReport};
    use tempfile::tempdir;

    #[test]
    fn slugify_basic() {
        // Upload filenames come from multipart parts (no path components);
        // file_stem drops the extension, then non-alphanumerics become `-`.
        assert_eq!(slugify("Ubuntu 24.04 Desktop.iso"), "ubuntu-24-04-desktop");
        assert_eq!(slugify("Rocky-9.4-x86_64-dvd.iso"), "rocky-9-4-x86_64-dvd");
        assert_eq!(slugify("arch.iso"), "arch");
        // If a path sneaks in, file_stem strips the directory — OK, not a hazard.
        assert_eq!(slugify("/etc/passwd"), "passwd");
    }

    #[test]
    fn casper_cmdline_vmware_uefi_safe() {
        // v0.4.5 regression guard: the Debian/Ubuntu cmdline must use
        // the canonical Casper `iso-url=` option and include
        // `ds=nocloud` so VMware-UEFI guests don't hang at "cloud-init
        // running" waiting on a metadata datasource that PXE can't
        // provide. The legacy `netboot=url url=… ---` form is gone for
        // good.
        let s = linux_cmdline(DistroFamily::DebianUbuntu, "ubuntu-24-04");
        assert!(s.contains("boot=casper"), "{s}");
        assert!(
            s.contains("iso-url=${base-url}/iso/ubuntu-24-04.iso"),
            "{s}"
        );
        assert!(s.contains("ds=nocloud"), "{s}");
        assert!(s.contains("ip=dhcp"), "{s}");
        assert!(!s.contains("netboot=url"), "legacy option leaked: {s}");
        assert!(!s.contains(" --- "), "stray ---: {s}");
    }

    fn fake_meta(id: &str) -> IsoMeta {
        IsoMeta {
            id: id.into(),
            filename: format!("{id}.iso"),
            size_bytes: 0,
            sha256_hex: None,
            uploaded_at: OffsetDateTime::now_utc(),
            introspection: IntrospectionReport {
                family: DistroFamily::Unknown,
                volume_label: None,
                kernel_path: None,
                initrd_paths: vec![],
                has_boot_wim: false,
            },
            boot_entries: vec![],
            source: IsoSource::Local,
            password_hash: None,
            category: IsoCategory::default(),
        }
    }

    #[tokio::test]
    async fn password_round_trip_set_verify_clear() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().to_path_buf());
        store.ensure_dirs().await.unwrap();
        store
            .inner
            .write()
            .isos
            .insert("alpha".into(), fake_meta("alpha"));

        // No password set — verify_password returns Ok(true) for any input.
        assert!(store.verify_password("alpha", "anything").unwrap());
        assert!(!store.get("alpha").unwrap().is_password_protected());

        // Set a password.
        store.set_password("alpha", Some("hunter2")).await.unwrap();
        let m = store.get("alpha").unwrap();
        assert!(m.is_password_protected());
        assert!(m.password_hash.unwrap().starts_with("$2"));

        // Verify correct + wrong.
        assert!(store.verify_password("alpha", "hunter2").unwrap());
        assert!(!store.verify_password("alpha", "wrong").unwrap());
        assert!(!store.verify_password("alpha", "").unwrap());

        // Clear by passing None or an empty string.
        store.set_password("alpha", None).await.unwrap();
        assert!(!store.get("alpha").unwrap().is_password_protected());
        store.set_password("alpha", Some("again")).await.unwrap();
        store.set_password("alpha", Some("   ")).await.unwrap();
        assert!(!store.get("alpha").unwrap().is_password_protected());
    }

    #[tokio::test]
    async fn set_password_for_unknown_id_errors() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().to_path_buf());
        store.ensure_dirs().await.unwrap();
        let r = store.set_password("does-not-exist", Some("pw")).await;
        assert!(matches!(r, Err(Error::Invalid(_))));
    }

    #[tokio::test]
    async fn begin_upload_rejects_existing_partial_file() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().to_path_buf());
        store.ensure_dirs().await.unwrap();
        tokio::fs::write(dir.path().join("ubuntu.partial"), b"in-flight")
            .await
            .unwrap();

        let r = store.begin_upload("ubuntu.iso").await;
        assert!(matches!(r, Err(Error::Invalid(_))));
    }

    #[tokio::test]
    async fn password_persists_via_meta_json_for_local_isos() {
        // Hash makes it onto disk so it survives a restart.
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().to_path_buf());
        store.ensure_dirs().await.unwrap();
        let meta = fake_meta("alpha");
        store.persist_meta(&meta).await.unwrap();
        store.insert(meta);
        store.set_password("alpha", Some("s3cret")).await.unwrap();

        // Re-load from disk and confirm the hash came back.
        let store2 = IsoStore::new(dir.path().to_path_buf());
        store2.load_from_disk().await.unwrap();
        let reloaded = store2.get("alpha").expect("reloaded");
        assert!(reloaded.is_password_protected());
        assert!(store2.verify_password("alpha", "s3cret").unwrap());
        assert!(!store2.verify_password("alpha", "wrong").unwrap());
    }
}
