//! Operator terminal — typed commands over HTTP.
//!
//! The Terminal tab posts a single command line per request. We split it
//! into argv, dispatch to a whitelisted handler, and return plain-text
//! output. The handler also pushes the input line and any output onto
//! the LogBus so commands and their results show up inline in the live
//! tail (Minecraft-server-style).
//!
//! ## Why a whitelist
//!
//! Exposing a real shell would be a remote-code-execution endpoint. We
//! keep the surface tiny and read-mostly; mutations are limited to the
//! same operations the rest of the UI already exposes.

use crate::state::AppState;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use serde_json::json;
use std::fmt::Write as _;

#[derive(Debug, Deserialize)]
pub struct CommandRequest {
    /// Raw input as typed by the operator. Empty / all-whitespace is OK
    /// (returns the help banner).
    pub command: String,
}

pub async fn run_command(
    State(state): State<AppState>,
    Json(req): Json<CommandRequest>,
) -> impl IntoResponse {
    let line = req.command.trim();
    if line.is_empty() {
        return (
            StatusCode::OK,
            Json(json!({ "output": HELP_TEXT, "ok": true })),
        );
    }

    // Echo the typed command into the live log so the Terminal tab shows
    // operator activity in-band with server-emitted log lines.
    state
        .log_bus
        .push("info", "openpxe::terminal", format!("> {line}"));

    let argv = shell_split(line);
    if argv.is_empty() {
        return (
            StatusCode::OK,
            Json(json!({ "output": HELP_TEXT, "ok": true })),
        );
    }

    let result = dispatch(&state, &argv).await;
    let (ok, output) = match result {
        Ok(s) => (true, s),
        Err(s) => (false, s),
    };
    // Mirror command output to the log bus (truncated for noisy commands)
    // so reading the live tail tells the same story as scrolling the
    // terminal pane.
    let mirror = if output.len() > 1024 {
        format!(
            "{}\n... ({} bytes truncated)",
            &output[..1024],
            output.len() - 1024
        )
    } else {
        output.clone()
    };
    if ok {
        state.log_bus.push("info", "openpxe::terminal", mirror);
    } else {
        state.log_bus.push("warn", "openpxe::terminal", mirror);
    }

    (StatusCode::OK, Json(json!({ "output": output, "ok": ok })))
}

async fn dispatch(state: &AppState, argv: &[String]) -> Result<String, String> {
    let head = argv[0].as_str();
    let tail = &argv[1..];
    match head {
        "help" | "?" => Ok(HELP_TEXT.to_string()),
        "version" => Ok(format!("openpxe {}", env!("CARGO_PKG_VERSION"))),
        "uptime" => Ok(uptime_string(state)),
        "status" => Ok(status_text(state)),
        "isos" | "images" => Ok(isos_text(state)),
        "clients" => Ok(clients_text(state)),
        "queue" => queue_command(state, tail).await,
        // `smb` controls the outbound Samba server for Windows
        // install media. `share` lists/manages remote SMB shares
        // OpenPXE pulls ISOs from (v0.4.65). `nfs` is the parallel
        // command for remote NFSv3 shares (v0.4.67, in-process via
        // nfs3_client — not the v0.4.64 kernel-mount path).
        "share" | "smb-share" => smb_share_command(state, tail).await,
        "smb" => smb_command(state, tail).await,
        "nfs" => nfs_share_command(state, tail).await,
        // v0.5.5: SFTP-over-SSH remote shares (in-process russh client).
        "sftp" => sftp_share_command(state, tail).await,
        "log" => log_command(state, tail),
        "whoami" => Ok("operator".to_string()),
        "echo" => Ok(tail.join(" ")),
        "clear" => Ok("\x0c".to_string()), // form feed — frontend clears panel
        other => Err(format!(
            "unknown command: {other}\ntype 'help' for the list"
        )),
    }
}

// ── status / lists ─────────────────────────────────────────────────────

fn status_text(s: &AppState) -> String {
    let isos = s.iso_store.list();
    let clients = s.clients.list();
    let queue_entries = s.queue.list();
    let smb = s.smb.as_ref().map(|m| m.snapshot());
    let smb_shares = s.smb_shares.list();
    let smb_reachable = smb_shares.iter().filter(|m| m.reachable).count();
    // v0.4.67: NFSv3 sources too.
    let nfs_shares = s.nfs_shares.list();
    let nfs_reachable = nfs_shares.iter().filter(|m| m.reachable).count();
    // v0.5.5: SFTP-over-SSH sources too.
    let sftp_shares = s.sftp_shares.list();
    let sftp_reachable = sftp_shares.iter().filter(|m| m.reachable).count();
    format!(
        "OpenPXE {ver}\n\
         base url:     {base}\n\
         interface:    {nic}\n\
         uptime:       {up}\n\
         isos:         {n_isos}  (local: {n_local}, smb: {n_smb}, nfs: {n_nfs}, sftp: {n_sftp})\n\
         clients:      {n_clients}\n\
         queue:        {n_entries}\n\
         smb server:   {smb}\n\
         smb shares:   {n_smb_total} configured ({n_smb_active} reachable)\n\
         nfs shares:   {n_nfs_total} configured ({n_nfs_active} reachable)\n\
         sftp shares:  {n_sftp_total} configured ({n_sftp_active} reachable)\n",
        ver = env!("CARGO_PKG_VERSION"),
        base = s.public_base_url,
        nic = if s.nic_name.is_empty() {
            "?"
        } else {
            s.nic_name.as_str()
        },
        up = uptime_string(s),
        n_isos = isos.len(),
        n_local = isos
            .iter()
            .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Local))
            .count(),
        n_smb = isos
            .iter()
            .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Smb { .. }))
            .count(),
        n_nfs = isos
            .iter()
            .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Nfs { .. }))
            .count(),
        n_sftp = isos
            .iter()
            .filter(|i| matches!(i.source, openpxe_iso_store::IsoSource::Sftp { .. }))
            .count(),
        n_clients = clients.len(),
        n_entries = queue_entries.len(),
        smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
        n_smb_total = smb_shares.len(),
        n_smb_active = smb_reachable,
        n_nfs_total = nfs_shares.len(),
        n_nfs_active = nfs_reachable,
        n_sftp_total = sftp_shares.len(),
        n_sftp_active = sftp_reachable,
    )
}

fn isos_text(s: &AppState) -> String {
    let isos = s.iso_store.list();
    if isos.is_empty() {
        return "(no isos)".into();
    }
    let mut out = String::new();
    let _ = writeln!(
        out,
        "{:<32} {:<10} {:<10} {:<8}",
        "ID", "FAMILY", "SIZE", "SOURCE"
    );
    for i in isos {
        let src = match i.source {
            openpxe_iso_store::IsoSource::Local => "local".to_string(),
            openpxe_iso_store::IsoSource::Smb { share_id, .. } => format!("smb:{share_id}"),
            // v0.4.67: NFSv3 via in-process nfs3_client.
            openpxe_iso_store::IsoSource::Nfs { share_id, .. } => format!("nfs:{share_id}"),
            // v0.5.5: SFTP-over-SSH via in-process russh.
            openpxe_iso_store::IsoSource::Sftp { share_id, .. } => format!("sftp:{share_id}"),
        };
        let _ = writeln!(
            out,
            "{:<32} {:<10} {:<10} {:<8}",
            truncate(&i.id, 32),
            format!("{:?}", i.introspection.family),
            human_bytes(i.size_bytes),
            src,
        );
    }
    out
}

fn clients_text(s: &AppState) -> String {
    let clients = s.clients.list();
    if clients.is_empty() {
        return "(no clients yet)".into();
    }
    let mut out = String::new();
    let _ = writeln!(out, "{:<19} {:<16} {:<8} LAST SEEN", "MAC", "IP", "EVENTS");
    for c in clients {
        let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
        let _ = writeln!(
            out,
            "{:<19} {:<16} {:<8} {}",
            c.mac,
            ip,
            c.events.len(),
            c.last_seen
                .format(&time::format_description::well_known::Rfc3339)
                .unwrap_or_default()
        );
    }
    out
}

// ── queue ──────────────────────────────────────────────────────────────

// `async` for symmetry with the other dispatch helpers — queue operations
// are sync today but might grow to await on a database in a future phase.
#[allow(clippy::unused_async)]
async fn queue_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        None | Some("list") => {
            let entries = s.queue.list();
            if entries.is_empty() {
                return Ok("(queue empty)".into());
            }
            let mut out = String::new();
            for entry in entries {
                let _ = writeln!(
                    out,
                    "#{:<3} {:<19} {:<16} target={}",
                    entry.position,
                    entry.mac,
                    entry.id,
                    entry.assigned_target.unwrap_or_else(|| "-".into())
                );
            }
            Ok(out)
        }
        Some("assign-all") => {
            let target = args
                .get(1)
                .ok_or_else(|| "usage: queue assign-all <iso_boot_entry_id>".to_string())?;
            let found = s
                .iso_store
                .list()
                .into_iter()
                .any(|i| i.boot_entries.iter().any(|e| &e.id == target));
            if !found {
                return Err(format!("no such boot entry: {target}"));
            }
            let ids: Vec<_> = s.queue.list().into_iter().map(|g| g.id).collect();
            let n = s.queue.assign(&ids, target);
            Ok(format!("assigned {n} queue entries -> {target}"))
        }
        Some("assign") => {
            let entry_id = args
                .get(1)
                .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
            let target = args
                .get(2)
                .ok_or_else(|| "usage: queue assign <entry_id> <iso_boot_entry_id>".to_string())?;
            let n = s.queue.assign(std::slice::from_ref(entry_id), target);
            if n == 0 {
                return Err(format!("no such queue entry: {entry_id}"));
            }
            Ok(format!("assigned 1 queue entry -> {target}"))
        }
        Some("release") => {
            let entry_id = args
                .get(1)
                .ok_or_else(|| "usage: queue release <entry_id>".to_string())?;
            match s.queue.release(entry_id) {
                Some(_) => Ok(format!("released {entry_id}")),
                None => Err(format!("no such queue entry: {entry_id}")),
            }
        }
        Some(other) => Err(format!(
            "unknown queue subcommand: {other}\ntry: queue [list|assign-all|assign|release]"
        )),
    }
}

// ── share (v0.4.65: SMB shares) ─────────────────────────────────────────

async fn smb_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        None | Some("list") => {
            let shares = s.smb_shares.list();
            if shares.is_empty() {
                return Ok("(no SMB shares configured)".into());
            }
            let mut out = String::new();
            let _ = writeln!(
                out,
                "{:<24} {:<7} {:<6} {:<6} TARGET",
                "ID", "STATUS", "AUTH", "ISOS"
            );
            for m in shares {
                let status = if m.reachable { "ok" } else { "down" };
                let auth = if m.guest { "guest" } else { "user" };
                let _ = writeln!(
                    out,
                    "{:<24} {:<7} {:<6} {:<6} //{}/{}",
                    truncate(&m.id, 24),
                    status,
                    auth,
                    m.iso_count,
                    m.server,
                    m.share,
                );
                if let Some(e) = m.last_error {
                    let _ = writeln!(out, "    error: {e}");
                }
                if let Some(h) = m.last_hint {
                    let _ = writeln!(out, "    hint:  {h}");
                }
            }
            Ok(out)
        }
        Some("add") => {
            // share add //server/share [guest|user:password]
            let target = args.get(1).ok_or_else(|| {
                "usage: share add //server/share [guest|user:password]".to_string()
            })?;
            // Accept either `//server/share` (UNC-style) or
            // `server:share` (shorter to type).
            let stripped = target.trim_start_matches('/').trim_start_matches('\\');
            let (server, share) = if let Some((s, p)) = stripped.split_once('/') {
                (s, p)
            } else if let Some((s, p)) = stripped.split_once(':') {
                (s, p)
            } else {
                return Err("target must be '//server/share' or 'server:share'".into());
            };

            // Auth spec: "guest" or "user:password". Default: guest.
            let auth = args.get(2).cloned().unwrap_or_else(|| "guest".into());
            let (guest, username, password) = if auth == "guest" {
                (true, None, None)
            } else if let Some((u, p)) = auth.split_once(':') {
                (false, Some(u.to_string()), Some(p.to_string()))
            } else {
                return Err("auth must be 'guest' or 'user:password'".into());
            };

            let req = openpxe_iso_store::SmbAddRequest {
                server: server.to_string(),
                share: share.to_string(),
                username,
                password,
                guest,
                port: None,
            };
            match s.smb_shares.add(req).await {
                Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
                Err(e) => {
                    let mut out = format!("add failed: {}", e.error);
                    if let Some(h) = e.hint {
                        out.push_str("\nhint: ");
                        out.push_str(&h);
                    }
                    Err(out)
                }
            }
        }
        Some("remove") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: share remove <id>".to_string())?;
            match s.smb_shares.remove(id).await {
                Ok(()) => Ok(format!("removed {id}")),
                Err(e) => Err(format!("remove failed: {e}")),
            }
        }
        Some("scan") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: share scan <id>".to_string())?;
            match s.smb_shares.rescan(id).await {
                Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
                Err(e) => Err(format!("scan failed: {e}")),
            }
        }
        Some(other) => Err(format!(
            "unknown share subcommand: {other}\ntry: share [list|add|remove|scan]"
        )),
    }
}

// ── nfs (v0.4.67: in-process NFSv3 via nfs3_client) ────────────────────

async fn nfs_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        None | Some("list") => {
            let shares = s.nfs_shares.list();
            if shares.is_empty() {
                return Ok("(no NFS shares configured)".into());
            }
            let mut out = String::new();
            let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
            for m in shares {
                let status = if m.reachable { "ok" } else { "down" };
                let _ = writeln!(
                    out,
                    "{:<24} {:<7} {:<6} {}:{}",
                    truncate(&m.id, 24),
                    status,
                    m.iso_count,
                    m.server,
                    m.export,
                );
                if let Some(e) = m.last_error {
                    let _ = writeln!(out, "    error: {e}");
                }
                if let Some(h) = m.last_hint {
                    let _ = writeln!(out, "    hint:  {h}");
                }
            }
            Ok(out)
        }
        Some("add") => {
            // nfs add <server>:<export> [port]
            let target = args
                .get(1)
                .ok_or_else(|| "usage: nfs add <server>:<export> [port]".to_string())?;
            let (server, export) = target
                .split_once(':')
                .ok_or_else(|| "target must be 'server:/export'".to_string())?;
            let port = args.get(2).and_then(|s| s.parse::<u16>().ok());
            let req = openpxe_iso_store::NfsAddRequest {
                server: server.to_string(),
                export: export.to_string(),
                port,
            };
            match s.nfs_shares.add(req).await {
                Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
                Err(e) => {
                    let mut out = format!("add failed: {}", e.error);
                    if let Some(h) = e.hint {
                        out.push_str("\nhint: ");
                        out.push_str(&h);
                    }
                    Err(out)
                }
            }
        }
        Some("remove") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: nfs remove <id>".to_string())?;
            match s.nfs_shares.remove(id).await {
                Ok(()) => Ok(format!("removed {id}")),
                Err(e) => Err(format!("remove failed: {e}")),
            }
        }
        Some("scan") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: nfs scan <id>".to_string())?;
            match s.nfs_shares.rescan(id).await {
                Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
                Err(e) => Err(format!("scan failed: {e}")),
            }
        }
        Some(other) => Err(format!(
            "unknown nfs subcommand: {other}\ntry: nfs [list|add|remove|scan]"
        )),
    }
}

// ── sftp (v0.5.5) ────────────────────────────────────────────────────────
//
// Parallel to nfs_share_command. The terminal `add` only supports
// password auth — pasting a multiline PEM private key through the
// terminal is impractical, so key-based shares are added via the WebUI.

async fn sftp_share_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        None | Some("list") => {
            let shares = s.sftp_shares.list();
            if shares.is_empty() {
                return Ok("(no SFTP shares configured)".into());
            }
            let mut out = String::new();
            let _ = writeln!(out, "{:<24} {:<7} {:<6} TARGET", "ID", "STATUS", "ISOS");
            for m in shares {
                let status = if m.reachable { "ok" } else { "down" };
                let _ = writeln!(
                    out,
                    "{:<24} {:<7} {:<6} {}@{}:{}",
                    truncate(&m.id, 24),
                    status,
                    m.iso_count,
                    m.username,
                    m.server,
                    m.export,
                );
                if let Some(e) = m.last_error {
                    let _ = writeln!(out, "    error: {e}");
                }
                if let Some(h) = m.last_hint {
                    let _ = writeln!(out, "    hint:  {h}");
                }
            }
            Ok(out)
        }
        Some("add") => {
            // sftp add <user>@<server>:<export> <password> [port]
            let target = args.get(1).ok_or_else(|| {
                "usage: sftp add <user>@<server>:<export> <password> [port] \
                 (key auth: use the WebUI)"
                    .to_string()
            })?;
            let password = args
                .get(2)
                .ok_or_else(|| "a password is required (key auth: use the WebUI)".to_string())?;
            let (user, rest) = target
                .split_once('@')
                .ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
            let (server, export) = rest
                .split_once(':')
                .ok_or_else(|| "target must be 'user@server:/export'".to_string())?;
            let port = args.get(3).and_then(|s| s.parse::<u16>().ok());
            let req = openpxe_iso_store::SftpAddRequest {
                server: server.to_string(),
                export: export.to_string(),
                username: Some(user.to_string()),
                port,
                password: Some(password.clone()),
                private_key: None,
                passphrase: None,
            };
            match s.sftp_shares.add(req).await {
                Ok(m) => Ok(format!("added {} ({} isos)", m.id, m.iso_count)),
                Err(e) => {
                    let mut out = format!("add failed: {}", e.error);
                    if let Some(h) = e.hint {
                        out.push_str("\nhint: ");
                        out.push_str(&h);
                    }
                    Err(out)
                }
            }
        }
        Some("remove") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: sftp remove <id>".to_string())?;
            match s.sftp_shares.remove(id).await {
                Ok(()) => Ok(format!("removed {id}")),
                Err(e) => Err(format!("remove failed: {e}")),
            }
        }
        Some("scan") => {
            let id = args
                .get(1)
                .ok_or_else(|| "usage: sftp scan <id>".to_string())?;
            match s.sftp_shares.rescan(id).await {
                Ok(n) => Ok(format!("re-scanned {id}: {n} isos")),
                Err(e) => Err(format!("scan failed: {e}")),
            }
        }
        Some(other) => Err(format!(
            "unknown sftp subcommand: {other}\ntry: sftp [list|add|remove|scan]"
        )),
    }
}

// ── smb ────────────────────────────────────────────────────────────────

#[allow(clippy::unused_async)]
async fn smb_command(s: &AppState, args: &[String]) -> Result<String, String> {
    let smb = s
        .smb
        .as_ref()
        .ok_or_else(|| "SMB manager not configured (Windows support disabled)".to_string())?;
    match args.first().map(String::as_str) {
        None | Some("status") => Ok(format!("{:#?}", smb.snapshot())),
        Some("start") => {
            // start/reconcile return the new SmbState — there's no
            // separate Result type. The state itself indicates success
            // or failure via its variant.
            let st = smb.start();
            Ok(format!("smbd start requested -> {st:?}"))
        }
        Some("stop") => {
            smb.stop();
            Ok("smbd stop requested".into())
        }
        Some("reload") => {
            let st = smb.reconcile();
            Ok(format!("smbd reload (SIGHUP) sent -> {st:?}"))
        }
        Some(other) => Err(format!(
            "unknown smb subcommand: {other}\ntry: smb [status|start|stop|reload]"
        )),
    }
}

// ── log ────────────────────────────────────────────────────────────────

fn log_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        Some("clear") => {
            s.log_bus.clear();
            Ok("log buffer cleared".into())
        }
        Some("tail") => {
            let n: usize = args.get(1).and_then(|v| v.parse().ok()).unwrap_or(20);
            let lines = s.log_bus.recent();
            let start = lines.len().saturating_sub(n);
            let mut out = String::new();
            for l in &lines[start..] {
                let _ = writeln!(out, "{}", l.render());
            }
            if out.is_empty() {
                Ok("(empty)".into())
            } else {
                Ok(out)
            }
        }
        _ => Err("usage: log [clear|tail [n]]".into()),
    }
}

// ── helpers ────────────────────────────────────────────────────────────

fn uptime_string(s: &AppState) -> String {
    let now = time::OffsetDateTime::now_utc();
    let secs = (now - s.started_at).whole_seconds().max(0);
    let h = secs / 3600;
    let m = (secs % 3600) / 60;
    let s = secs % 60;
    format!("{h}h {m}m {s}s")
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}…", &s[..max.saturating_sub(1)])
    }
}

fn human_bytes(n: u64) -> String {
    const U: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    // Loss-of-precision past 2^52 is academic for ISO file sizes — even
    // a 4 PiB file still rounds to the right unit.
    #[allow(clippy::cast_precision_loss)]
    let mut x = n as f64;
    let mut i = 0;
    while x >= 1024.0 && i < U.len() - 1 {
        x /= 1024.0;
        i += 1;
    }
    if i == 0 || x >= 10.0 {
        format!("{:.0} {}", x, U[i])
    } else {
        format!("{:.1} {}", x, U[i])
    }
}

/// Tiny shell-like splitter — splits on whitespace, honoring `'…'` and
/// `"…"` quoted segments. We deliberately don't expand `$VAR` or any
/// other shell metacharacters; this is a parser for our own command
/// vocabulary, not a real shell.
pub fn shell_split(input: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut current = String::new();
    let mut in_single = false;
    let mut in_double = false;
    for ch in input.chars() {
        match ch {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            c if c.is_whitespace() && !in_single && !in_double => {
                if !current.is_empty() {
                    out.push(std::mem::take(&mut current));
                }
            }
            c => current.push(c),
        }
    }
    if !current.is_empty() {
        out.push(current);
    }
    out
}

const HELP_TEXT: &str = "\
OpenPXE terminal — available commands:

  help                              show this help
  version                           print server version
  status                            high-level server status
  uptime                            time since startup

  isos                              list registered ISOs
  clients                           list PXE clients seen this session
  queue list                        list queued clients
  queue assign <entry_id> <target>   assign one queued client to a boot entry
  queue assign-all <target>         assign every waiting client
  queue release <entry_id>           release one queued client

  share list                        list configured SMB shares
  share add //srv/share [auth]      add an SMB share; auth = 'guest' or 'user:pass'
  share remove <id>                 forget an SMB share
  share scan <id>                   re-list a share for new ISOs

  nfs list                          list configured NFSv3 shares
  nfs add <srv>:<export> [port]     add an NFSv3 share
  nfs remove <id>                   forget an NFS share
  nfs scan <id>                     re-list an NFS share for new ISOs

  sftp list                         list configured SFTP-over-SSH shares
  sftp add <user>@<srv>:<export> <pass> [port]   add an SFTP share (key auth: WebUI)
  sftp remove <id>                  forget an SFTP share
  sftp scan <id>                    re-list an SFTP share for new ISOs

  smb status                        outbound Samba state (Windows install media)
  smb start | stop | reload         control the outbound smbd

  log clear                         drop the in-memory log ring buffer
  log tail [n]                      show the last n buffered lines (default 20)
  clear                             clear the terminal pane

Tab to autocomplete is not implemented (sorry).\n";

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

    #[test]
    fn shell_split_basic() {
        assert_eq!(shell_split(""), Vec::<String>::new());
        assert_eq!(shell_split("share list"), vec!["share", "list"]);
        assert_eq!(
            shell_split("share add //nas/isos guest"),
            vec!["share", "add", "//nas/isos", "guest"]
        );
    }

    #[test]
    fn shell_split_quoted() {
        assert_eq!(
            shell_split("echo 'hello world' done"),
            vec!["echo", "hello world", "done"]
        );
        assert_eq!(
            shell_split(r#"echo "double quotes" 'and singles'"#),
            vec!["echo", "double quotes", "and singles"]
        );
    }

    #[test]
    fn human_bytes_units() {
        assert_eq!(human_bytes(0), "0 B");
        assert_eq!(human_bytes(1023), "1023 B");
        assert_eq!(human_bytes(1024), "1.0 KB");
        assert_eq!(human_bytes(2 * 1024 * 1024), "2.0 MB");
        assert_eq!(human_bytes(5 * 1024u64.pow(3)), "5.0 GB");
    }

    #[test]
    fn truncate_keeps_short() {
        assert_eq!(truncate("hi", 10), "hi");
        assert_eq!(truncate("longerthanfive", 5), "long…");
    }

    #[test]
    fn help_uses_queue_language() {
        assert!(HELP_TEXT.contains("queue list"));
        assert!(HELP_TEXT.contains("queued clients"));
    }
}
