//! 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", "pxeforge::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", "pxeforge::terminal", mirror);
    } else {
        state.log_bus.push("warn", "pxeforge::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!("pxeforge {}", 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)),
        "gate" => gate_command(state, tail).await,
        "nfs" => nfs_command(state, tail).await,
        "smb" => smb_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 gates = s.gates.list();
    let smb = s.smb.as_ref().map(|m| m.snapshot());
    let nfs = s.nfs.list();
    let nfs_active = nfs.iter().filter(|m| m.mounted).count();
    format!(
        "PXEForge {ver}\n\
         base url:     {base}\n\
         interface:    {nic}\n\
         uptime:       {up}\n\
         isos:         {n_isos}  (local: {n_local}, nfs: {n_nfs})\n\
         clients:      {n_clients}\n\
         gates:        {n_gates}\n\
         smb:          {smb}\n\
         nfs mounts:   {n_total} configured ({n_active} active)\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, pxeforge_iso_store::IsoSource::Local)).count(),
        n_nfs = isos.iter().filter(|i| !matches!(i.source, pxeforge_iso_store::IsoSource::Local)).count(),
        n_clients = clients.len(),
        n_gates = gates.len(),
        smb = smb.map_or_else(|| "(disabled)".into(), |s| format!("{s:?}")),
        n_total = nfs.len(),
        n_active = nfs_active,
    )
}

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 {
            pxeforge_iso_store::IsoSource::Local => "local".to_string(),
            pxeforge_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_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
}

// ── gate ───────────────────────────────────────────────────────────────

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

// ── nfs ────────────────────────────────────────────────────────────────

async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
    match args.first().map(String::as_str) {
        None | Some("list") => {
            let mounts = s.nfs.list();
            if mounts.is_empty() {
                return Ok("(no NFS mounts configured)".into());
            }
            let mut out = String::new();
            let _ = writeln!(
                out,
                "{:<24} {:<6} {:<7} {:<6} TARGET",
                "ID", "VER", "STATUS", "ISOS"
            );
            for m in mounts {
                let status = if m.mounted { "ok" } else { "down" };
                let _ = writeln!(
                    out,
                    "{:<24} {:<6} {:<7} {:<6} {}:{}",
                    truncate(&m.id, 24),
                    match m.version {
                        pxeforge_iso_store::NfsVersion::V3 => "v3",
                        pxeforge_iso_store::NfsVersion::V41 => "v4.1",
                    },
                    status,
                    m.iso_count,
                    m.server,
                    m.export,
                );
                if let Some(e) = m.last_error {
                    let _ = writeln!(out, "    error: {e}");
                }
            }
            Ok(out)
        }
        Some("mount") => {
            // nfs mount <server>:<export> [v3|v41] [ro|rw]
            let target = args
                .get(1)
                .ok_or_else(|| "usage: nfs mount <server>:<export> [v3|v41] [ro|rw]".to_string())?;
            let (server, export) = target
                .split_once(':')
                .ok_or_else(|| "target must be 'server:/export'".to_string())?;
            let version = match args.get(2).map(String::as_str) {
                Some("v3") => pxeforge_iso_store::NfsVersion::V3,
                Some("v41") | None => pxeforge_iso_store::NfsVersion::V41,
                Some(other) => return Err(format!("unknown nfs version: {other} (expect v3 or v41)")),
            };
            let read_only = !matches!(args.get(3).map(String::as_str), Some("rw"));
            let req = pxeforge_iso_store::NfsAddRequest {
                server: server.to_string(),
                export: export.to_string(),
                version,
                read_only,
            };
            match s.nfs.add(req).await {
                Ok(m) => Ok(format!("mounted {} ({} isos)", m.id, m.iso_count)),
                Err(e) => Err(format!("mount failed: {e}")),
            }
        }
        Some("unmount") => {
            let id = args.get(1).ok_or_else(|| "usage: nfs unmount <id>".to_string())?;
            match s.nfs.remove(id).await {
                Ok(()) => Ok(format!("unmounted {id}")),
                Err(e) => Err(format!("unmount failed: {e}")),
            }
        }
        Some("scan") => {
            let id = args.get(1).ok_or_else(|| "usage: nfs scan <id>".to_string())?;
            match s.nfs.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|mount|unmount|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 = "\
PXEForge 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
  gate list                         list gated-deployment queue
  gate assign <gate_id> <target>    assign one gate to a boot entry
  gate assign-all <target>          assign every waiting gate
  gate release <gate_id>            release one gate

  nfs list                          list NFS mounts
  nfs mount <s>:<e> [v3|v41] [ro|rw]  add and mount an NFS share
  nfs unmount <id>                  unmount and forget a share
  nfs scan <id>                     re-scan a share for new ISOs

  smb status                        SMB (Samba) state
  smb start | stop | reload         control 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("nfs list"), vec!["nfs", "list"]);
        assert_eq!(
            shell_split("nfs mount 10.0.0.5:/srv v41 ro"),
            vec!["nfs", "mount", "10.0.0.5:/srv", "v41", "ro"]
        );
    }

    #[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…");
    }
}
