//! SMB share manager. Spawns and supervises `smbd` for serving extracted
//! Windows install trees on port 445. This is the server side of the
//! Bootimus-pattern Windows boot: WinPE does `net use Z: \\server\<share>`
//! and runs Setup from there.
//!
//! Lifecycle:
//!
//!   1. Web UI toggles `windows_enabled = true` → `SmbManager::start()`.
//!      We write an `smb.conf` that declares one share per extracted
//!      Windows ISO, then `smbd --foreground --no-process-group`.
//!   2. When a Windows ISO is uploaded, `extract_windows_iso()` unpacks
//!      it under `smb_dir/<iso_id>/` and `SmbManager::reconcile_shares()`
//!      rewrites `smb.conf` and signals smbd to reload (SIGHUP).
//!   3. When the toggle flips off, `stop()` sends SIGTERM to smbd and
//!      leaves the extracted trees in place (in case the toggle comes
//!      back on).
//!
//! Safety posture:
//!   - Guest-mode SMB, read-only (`writable = no`, `guest ok = yes`).
//!   - SMB2 minimum (no SMB1 legacy, not needed for WinPE).
//!   - Bound to 0.0.0.0:445; operator MUST put this on a trusted install
//!     VLAN — guest SMB is not for the general internet.
//!   - smbd runs as the same non-root uid as openpxe (10001).
//!   - If `smbd` isn't on PATH (e.g. lightweight container build without
//!     Samba), we return `SmbState::SmbdMissing` and the UI surfaces the
//!     gap. No panics, no retries, no silent failure.

use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "state")]
pub enum SmbState {
    /// Windows support is off — smbd not running.
    Disabled,
    /// `smbd` is missing from the image. Operator enabled Windows but the
    /// runtime container didn't include Samba.
    SmbdMissing,
    /// Started and healthy.
    Running { pid: u32, shares: Vec<String> },
    /// Tried to start but smbd exited. Reason is captured for the UI.
    Failed { reason: String },
}

pub struct SmbManager {
    smb_dir: PathBuf,
    conf_path: PathBuf,
    child: Arc<Mutex<Option<Child>>>,
    state: Arc<Mutex<SmbState>>,
}

impl SmbManager {
    pub fn new(smb_dir: PathBuf) -> Self {
        let conf_path = smb_dir.join("smb.conf");
        Self {
            smb_dir,
            conf_path,
            child: Arc::new(Mutex::new(None)),
            state: Arc::new(Mutex::new(SmbState::Disabled)),
        }
    }

    #[must_use]
    pub fn snapshot(&self) -> SmbState {
        self.state.lock().clone()
    }

    /// Enumerate `<smb_dir>/*/` sub-dirs as shares. An extracted Windows
    /// ISO under `smb_dir/<slug>/` becomes a share named `<slug>`. Returns
    /// the sorted list.
    pub fn discover_shares(&self) -> Vec<String> {
        let Ok(rd) = std::fs::read_dir(&self.smb_dir) else {
            return vec![];
        };
        let mut out: Vec<String> = rd
            .flatten()
            .filter(|e| e.path().is_dir())
            .filter_map(|e| e.file_name().to_str().map(str::to_owned))
            // Ignore hidden / internal dirs.
            .filter(|n| !n.starts_with('.') && n != "tmp")
            .collect();
        out.sort();
        out
    }

    /// Write out `smb.conf` for the currently-discovered shares. Safe to
    /// call while smbd is running — smbd reloads on SIGHUP.
    pub fn write_conf(&self) -> std::io::Result<Vec<String>> {
        use std::fmt::Write as _;
        std::fs::create_dir_all(&self.smb_dir)?;
        let shares = self.discover_shares();
        let mut conf = String::new();
        conf.push_str(SMB_CONF_GLOBAL);
        for name in &shares {
            let path = self.smb_dir.join(name);
            // Per-share block. `write!` to String never fails — the unwrap
            // is provably unreachable, but expect() makes that explicit.
            write!(
                conf,
                "\n[{name}]\n\
                 path = {}\n\
                 comment = OpenPXE Windows install media ({name})\n\
                 read only = yes\n\
                 guest ok = yes\n\
                 guest only = yes\n\
                 browseable = yes\n\
                 available = yes\n",
                path.display(),
            )
            .expect("writing to a String is infallible");
        }
        let tmp = self.conf_path.with_extension("conf.tmp");
        std::fs::write(&tmp, conf)?;
        std::fs::rename(tmp, &self.conf_path)?;
        Ok(shares)
    }

    /// Start smbd. No-op if already running.
    pub fn start(&self) -> SmbState {
        let mut g = self.child.lock();
        if g.as_ref().is_some_and(|c| c.id() > 0) {
            return self.state.lock().clone();
        }
        if !smbd_present() {
            let s = SmbState::SmbdMissing;
            *self.state.lock() = s.clone();
            return s;
        }
        let shares = match self.write_conf() {
            Ok(v) => v,
            Err(e) => {
                let s = SmbState::Failed {
                    reason: format!("write smb.conf: {e}"),
                };
                *self.state.lock() = s.clone();
                return s;
            }
        };
        let child = Command::new("smbd")
            .args([
                "--foreground",
                "--no-process-group",
                "--configfile",
                self.conf_path.to_str().unwrap_or(""),
                "--log-stdout",
            ])
            .stdin(Stdio::null())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit())
            .spawn();
        match child {
            Ok(c) => {
                let pid = c.id();
                *g = Some(c);
                let s = SmbState::Running { pid, shares };
                *self.state.lock() = s.clone();
                tracing::info!(target: "openpxe::smb", pid, shares=?self.state.lock(), "smbd started");
                s
            }
            Err(e) => {
                let s = SmbState::Failed {
                    reason: format!("spawn smbd: {e}"),
                };
                *self.state.lock() = s.clone();
                s
            }
        }
    }

    /// Rewrite smb.conf and SIGHUP smbd so it picks up new/removed shares.
    /// No-op if smbd isn't running.
    #[allow(unsafe_code)]
    pub fn reconcile(&self) -> SmbState {
        let mut g = self.child.lock();
        if g.is_none() {
            return self.state.lock().clone();
        }
        let shares = match self.write_conf() {
            Ok(v) => v,
            Err(e) => {
                let s = SmbState::Failed {
                    reason: format!("write smb.conf: {e}"),
                };
                *self.state.lock() = s.clone();
                return s;
            }
        };
        if let Some(c) = g.as_mut() {
            // u32 -> i32 for libc::kill. We never spawn enough children
            // for the pid to overflow i32; cast_signed makes the intent
            // explicit and silences the lint.
            let pid = c.id().cast_signed();
            // SAFETY: libc::kill is FFI-safe; we pass a pid we own (returned
            // from `Child::id` above, the child is alive because we hold the
            // Mutex guard `g`) and a well-defined signal constant. Return
            // value ignored because there's no meaningful recovery if SIGHUP
            // fails — the next reconcile will retry.
            // Rationale for not using a safe wrapper: the only crate that
            // covers this is `nix`, which pulls ~40 transitive deps for a
            // single signal send. One documented unsafe call is the better
            // tradeoff for a container-first project.
            unsafe {
                libc::kill(pid, libc::SIGHUP);
            }
            let s = SmbState::Running {
                pid: pid as u32,
                shares,
            };
            *self.state.lock() = s.clone();
            s
        } else {
            self.state.lock().clone()
        }
    }

    /// Stop smbd. Safe to call repeatedly.
    pub fn stop(&self) {
        let mut g = self.child.lock();
        if let Some(mut c) = g.take() {
            let _ = c.kill();
            let _ = c.wait();
        }
        *self.state.lock() = SmbState::Disabled;
    }
}

fn smbd_present() -> bool {
    let Ok(paths) = std::env::var("PATH") else {
        return false;
    };
    for dir in std::env::split_paths(&paths) {
        if dir.join("smbd").is_file() {
            return true;
        }
    }
    false
}

const SMB_CONF_GLOBAL: &str = r"[global]
workgroup = OPENPXE
server min protocol = SMB2
smb ports = 445
log level = 1
max log size = 1024
disable netbios = yes
server role = standalone
map to guest = Bad User
guest account = nobody
# Anchor to container-friendly paths; tdb + log files under the data dir
# so a read-only rootfs in OpenShift doesn't block Samba.
lock directory = /tmp
state directory = /tmp
cache directory = /tmp
pid directory = /tmp
# WinPE reconnect hardening. Windows Setup can reboot mid-install and
# reconnect from the same IP; stale sessions/oplocks otherwise cause
# intermittent `net use` failures on the second stage.
reset on zero vc = yes
oplocks = no
kernel oplocks = no
level2 oplocks = no
strict locking = no
deadtime = 1
";

/// Extract a Windows ISO at `iso_path` into `smb_dir/<slug>/`. Uses
/// `7z` when available (most reliable for UDF + ISO9660 hybrid images);
/// falls back to `bsdtar -xf` which also handles UDF on many distros.
/// Returns the share name (i.e. the slug) on success.
///
/// Idempotent: if the target dir already contains `sources/boot.wim`, we
/// skip extraction. Callers who want a forced re-extract should remove the
/// dir first.
pub fn extract_windows_iso(
    iso_path: &Path,
    smb_dir: &Path,
    slug: &str,
) -> std::io::Result<PathBuf> {
    let target = smb_dir.join(slug);
    if target.join("sources").join("boot.wim").is_file() {
        tracing::debug!(target: "openpxe::smb", slug, "ISO already extracted, skipping");
        return Ok(target);
    }
    std::fs::create_dir_all(&target)?;

    // Try 7z first.
    if which("7z").is_some() {
        let out = Command::new("7z")
            .args(["x", "-y", "-bd", "-bb0"])
            .arg(format!("-o{}", target.display()))
            .arg(iso_path)
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .output()?;
        if out.status.success() {
            return Ok(target);
        }
        tracing::warn!(
            target: "openpxe::smb",
            stderr=%String::from_utf8_lossy(&out.stderr),
            "7z extract failed, trying bsdtar"
        );
    }
    // bsdtar fallback.
    if which("bsdtar").is_some() {
        let out = Command::new("bsdtar")
            .args(["-xf"])
            .arg(iso_path)
            .args(["-C"])
            .arg(&target)
            .output()?;
        if out.status.success() {
            return Ok(target);
        }
        return Err(std::io::Error::other(format!(
            "bsdtar failed: {}",
            String::from_utf8_lossy(&out.stderr)
        )));
    }
    Err(std::io::Error::new(
        std::io::ErrorKind::NotFound,
        "neither 7z nor bsdtar available for ISO extraction",
    ))
}

fn which(cmd: &str) -> Option<PathBuf> {
    let paths = std::env::var_os("PATH")?;
    for dir in std::env::split_paths(&paths) {
        let p = dir.join(cmd);
        if p.is_file() {
            return Some(p);
        }
    }
    None
}

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

    #[test]
    fn disabled_by_default() {
        let dir = tempdir().unwrap();
        let m = SmbManager::new(dir.path().into());
        assert_eq!(m.snapshot(), SmbState::Disabled);
    }

    #[test]
    fn start_without_smbd_reports_missing() {
        // Drop smbd from PATH for this test.
        let saved = std::env::var_os("PATH");
        std::env::set_var("PATH", "/usr/nowhere-openpxe-test");
        let dir = tempdir().unwrap();
        let m = SmbManager::new(dir.path().into());
        let st = m.start();
        // Restore PATH before asserting so any subsequent failure is legible.
        if let Some(p) = saved {
            std::env::set_var("PATH", p);
        }
        assert_eq!(st, SmbState::SmbdMissing);
    }

    #[test]
    fn discover_shares_lists_iso_subdirs() {
        let dir = tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("win10-pro")).unwrap();
        std::fs::create_dir_all(dir.path().join("win11")).unwrap();
        std::fs::create_dir_all(dir.path().join(".hidden")).unwrap();
        let m = SmbManager::new(dir.path().into());
        assert_eq!(m.discover_shares(), vec!["win10-pro", "win11"]);
    }

    #[test]
    fn write_conf_emits_share_blocks() {
        let dir = tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join("win10")).unwrap();
        let m = SmbManager::new(dir.path().into());
        let shares = m.write_conf().unwrap();
        assert_eq!(shares, vec!["win10"]);
        let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
        assert!(conf.contains("[global]"));
        assert!(conf.contains("[win10]"));
        assert!(conf.contains("guest ok = yes"));
        assert!(conf.contains("read only = yes"));
        assert!(conf.contains("server min protocol = SMB2"));
        assert!(conf.contains("workgroup = OPENPXE"));
    }

    #[test]
    fn write_conf_includes_winpe_reconnect_tuning() {
        let dir = tempdir().unwrap();
        let m = SmbManager::new(dir.path().into());
        m.write_conf().unwrap();
        let conf = std::fs::read_to_string(dir.path().join("smb.conf")).unwrap();
        for expected in [
            "reset on zero vc = yes",
            "oplocks = no",
            "kernel oplocks = no",
            "level2 oplocks = no",
            "strict locking = no",
            "deadtime = 1",
        ] {
            assert!(
                conf.contains(expected),
                "missing Windows reconnect Samba option {expected} in:\n{conf}"
            );
        }
    }
}
