//! SMB share consumer — replaces the kernel-mount NFS path that v0.4.64
//! shipped.
//!
//! ## Why SMB and not NFS
//!
//! v0.4.64 tried to make `mount -t nfs` work inside the OpenPXE
//! container. With `CAP_SYS_ADMIN` + `--privileged` we still hit the
//! same `mount.nfs: failed to apply fstab options` on Unraid because
//! Unraid's base kernel ships without the `nfs` / `nfsv4` client
//! modules loaded. No amount of container-side configuration can
//! load a kernel module on the host.
//!
//! SMB has the same kernel-side problem (`mount -t cifs` needs the
//! `cifs` kernel module) but unlike NFS it has a usable **userspace**
//! client: Samba's `smbclient` CLI. It speaks the SMB protocol over a
//! plain TCP socket, no kernel modules required. Bootimus uses the
//! same approach.
//!
//! ## How it works
//!
//! 1. Operator submits a share spec via the Storage tab:
//!    `{ server: "192.168.1.51", share: "isos",
//!       username, password, guest }`.
//! 2. We write credentials to a 0600-permission tempfile under
//!    `<work_dir>/smb_creds/`. Passing them on the command line would
//!    leak them through `ps` and the container's audit log.
//! 3. We test the connection by listing the share's root with
//!    `smbclient //server/share -A creds_file -c 'ls *.iso'`. If the
//!    server is unreachable, the share doesn't exist, or auth fails,
//!    we get a clean error before persisting anything.
//! 4. We parse the `ls` output for `*.iso` filenames and sizes, and
//!    register each one with the `IsoStore` as an
//!    `IsoSource::Smb { share_id, relative_path }`.
//! 5. When a PXE client requests the bytes, the HTTP handler asks this
//!    manager for an async reader. We spawn
//!    `smbclient //server/share -A creds_file -c 'get file -'` and
//!    pipe its stdout straight into the response body. No double
//!    storage, no temp files.
//!
//! ## Why subprocess and not a Rust library
//!
//! The Debian runtime image already ships the `samba` package
//! (Dockerfile line 84) — `smbclient` is right there. Library options
//! like `pavao` wrap `libsmbclient` so they still pull in the same C
//! library at runtime. Subprocess is simpler, the API surface is
//! whatever the operator can verify with `smbclient` at a shell, and
//! debugging "what does smbclient see?" is trivial.
//!
//! ## Range request limitations (v0.4.65)
//!
//! `smbclient -c 'get file -'` is a sequential whole-file stream;
//! there's no native seek in the CLI. We honor full GETs and reject
//! HTTP Range requests with `416 Range Not Satisfiable` for
//! SMB-sourced ISOs. PXE clients in practice request the whole file:
//! iPXE chain loading, casper sanboot, wimboot all do sequential
//! streaming. A follow-up release can add libsmbclient-based seek if
//! a real workload needs it.

use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::process::Command;

/// Default TCP port for SMB / CIFS. The wire protocol moved to 445
/// years ago; 139 (NetBIOS) is legacy and we don't expose it as an
/// option.
const DEFAULT_SMB_PORT: u16 = 445;

/// Maximum time we wait for a TCP connection to the SMB server during
/// the pre-flight probe. Same shape as the v0.4.64 NFS probe — short
/// enough that a wrong IP doesn't make the UI hang for 30s, long
/// enough that a slow appliance can still answer.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);

/// One configured SMB share. The id is derived from server+share so an
/// operator pasting the same coordinates twice gets idempotent
/// behaviour rather than a duplicate row.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmbShare {
    pub id: String,
    pub server: String,
    pub share: String,
    /// Username used for the SMB connection. Empty when `guest` is
    /// true. Stored so the UI can echo it back; the password lives in
    /// the separate credentials file (see `creds_path`).
    pub username: String,
    /// True when we're connecting with `-N` (anonymous / guest mode).
    /// Most NAS appliances that expose ISO libraries do so as
    /// guest-readable; this is the common case.
    pub guest: bool,
    /// TCP port — 445 unless the operator overrode it. Persisted so
    /// the UI can echo it back.
    #[serde(default = "default_port")]
    pub port: u16,
    /// Most recent error encountered talking to the share, or `None`
    /// on success. Cleared every successful operation.
    pub last_error: Option<String>,
    /// Operator-friendly translation of `last_error`. None when we
    /// don't have a friendlier rendition.
    pub last_hint: Option<String>,
    #[serde(with = "time::serde::rfc3339::option")]
    pub last_scan: Option<OffsetDateTime>,
    /// Number of `*.iso` files we know about on the share as of the
    /// most recent scan.
    pub iso_count: u32,
    /// Whether the connection's currently working. `true` after a
    /// successful scan, `false` after a failure. Drives the UI dot.
    pub reachable: bool,
    /// Path to the credentials file on disk. Internal — not surfaced
    /// in the API JSON; we serialize it for restart-survival but the
    /// UI doesn't render it.
    #[serde(default)]
    #[serde(skip_serializing)]
    pub(crate) creds_path: Option<PathBuf>,
}

/// Submission from the UI / API.
#[derive(Debug, Clone, Deserialize)]
pub struct SmbAddRequest {
    pub server: String,
    pub share: String,
    #[serde(default)]
    pub username: Option<String>,
    #[serde(default)]
    pub password: Option<String>,
    #[serde(default)]
    pub guest: bool,
    #[serde(default)]
    pub port: Option<u16>,
}

fn default_port() -> u16 {
    DEFAULT_SMB_PORT
}

/// Structured error surfaced to the API and rendered in the UI as two
/// lines: the raw `error` from smbclient + an actionable `hint`.
/// Mirrors the v0.4.64 NFS error shape so the storage tab can use a
/// single rendering path.
#[derive(Debug, Clone, Serialize)]
pub struct SmbShareError {
    pub error: String,
    pub stderr: String,
    pub hint: Option<String>,
}

impl SmbShareError {
    fn from_raw(error: impl Into<String>, stderr: impl Into<String>) -> Self {
        let stderr = stderr.into();
        let error = error.into();
        let hint = hint_for(&stderr).or_else(|| hint_for(&error));
        Self {
            error,
            stderr,
            hint,
        }
    }
}

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

/// Manages SMB shares and surfaces their ISOs through the IsoStore.
///
/// Cheap to clone — internal state is `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SmbShareManager {
    creds_root: Arc<PathBuf>,
    state_path: Arc<PathBuf>,
    inner: Arc<Mutex<Inner>>,
    iso_store: IsoStore,
    /// Serializes scan/list/get against the same share. smbclient
    /// itself is fine concurrent across processes, but bundling
    /// operations through a single lock makes test ordering and log
    /// output predictable.
    op_lock: Arc<tokio::sync::Mutex<()>>,
}

impl SmbShareManager {
    /// Construct a manager rooted at `work_dir`. Credentials files
    /// live under `<work_dir>/smb_creds/` with 0600 permissions; state
    /// persists to `<work_dir>/smb_shares.json`.
    #[must_use]
    pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
        let creds_root = work_dir.join("smb_creds");
        let state_path = work_dir.join("smb_shares.json");
        Self {
            creds_root: Arc::new(creds_root),
            state_path: Arc::new(state_path),
            inner: Arc::new(Mutex::new(Inner::default())),
            iso_store,
            op_lock: Arc::new(tokio::sync::Mutex::new(())),
        }
    }

    /// Load persisted state and re-scan every share. Errors per share
    /// are logged and surfaced on the spec; the call itself never
    /// fails — startup must not block on a single offline server.
    pub async fn load_and_rescan(&self) -> Result<()> {
        tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
        let shares = match tokio::fs::read_to_string(self.state_path.as_path()).await {
            Ok(text) => serde_json::from_str::<Vec<SmbShare>>(&text).unwrap_or_default(),
            Err(_) => Vec::new(),
        };
        for mut s in shares {
            s.last_error = None;
            s.last_hint = None;
            s.reachable = false;
            self.inner.lock().shares.insert(s.id.clone(), s.clone());
            if let Err(e) = self.rescan_inner(&s.id).await {
                tracing::warn!(
                    target: "openpxe::smb",
                    id = %s.id, server = %s.server, share = %s.share,
                    "rescan on startup failed: {e}"
                );
            }
        }
        Ok(())
    }

    /// Add or refresh a share. Validates the input, writes a creds
    /// file, probes connectivity, and scans for ISOs.
    pub async fn add(
        &self,
        req: SmbAddRequest,
    ) -> std::result::Result<SmbShare, SmbShareError> {
        let server = normalize_server(&req.server);
        let share = req.share.trim().trim_start_matches('/').to_string();
        if server.is_empty() {
            return Err(SmbShareError::from_raw("server is required", ""));
        }
        if share.is_empty() {
            return Err(SmbShareError::from_raw("share name is required", ""));
        }
        if share.contains('/') {
            return Err(SmbShareError::from_raw(
                "share name should be the top-level share (e.g. 'isos'), not a path",
                "",
            ));
        }
        if server.contains('\0') || share.contains('\0') {
            return Err(SmbShareError::from_raw("NUL bytes are not allowed", ""));
        }

        let guest = req.guest;
        let username = req.username.unwrap_or_default().trim().to_string();
        let password = req.password.unwrap_or_default();
        if !guest && username.is_empty() {
            return Err(SmbShareError::from_raw(
                "username is required when 'guest' is unchecked",
                "",
            ));
        }
        let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SMB_PORT);

        let id = share_id(&server, &share);

        // Pre-flight TCP probe so a wrong IP / firewall surfaces a
        // clean error instead of one of smbclient's notoriously
        // cryptic NT_STATUS codes.
        if let Err((err, hint)) = tcp_probe(&server, port).await {
            // No share is persisted yet; just return the error.
            return Err(SmbShareError {
                error: err,
                stderr: String::new(),
                hint: Some(hint),
            });
        }

        // Write the creds file. Even guest mode gets a file (empty
        // username/password) so the code path is uniform.
        let creds_path = self.creds_root.join(format!("{id}.cred"));
        if let Err(e) = self.write_creds(&creds_path, &username, &password).await {
            return Err(SmbShareError::from_raw(
                format!("could not write credentials file: {e}"),
                "",
            ));
        }

        let spec = SmbShare {
            id: id.clone(),
            server,
            share,
            username,
            guest,
            port,
            last_error: None,
            last_hint: None,
            last_scan: None,
            iso_count: 0,
            reachable: false,
            creds_path: Some(creds_path),
        };
        self.inner.lock().shares.insert(id.clone(), spec);
        self.persist_locked();

        // Now actually talk to the server.
        if let Err(e) = self.rescan_inner(&id).await {
            let m = self.get(&id);
            return Err(SmbShareError {
                error: m.as_ref().and_then(|m| m.last_error.clone())
                    .unwrap_or_else(|| e.to_string()),
                stderr: String::new(),
                hint: m.and_then(|m| m.last_hint),
            });
        }
        Ok(self.get(&id).expect("just inserted"))
    }

    /// Remove a share: drops every ISO sourced from it, scrubs the
    /// creds file, and forgets the spec. Idempotent.
    pub async fn remove(&self, id: &str) -> Result<()> {
        let creds_path = {
            let mut g = self.inner.lock();
            g.shares.remove(id).and_then(|s| s.creds_path)
        };
        self.iso_store.drop_external_source(id);
        if let Some(p) = creds_path {
            // Overwrite-then-unlink would be more thorough but the
            // file is 0600 in a non-root-owned dir; rm is sufficient.
            let _ = tokio::fs::remove_file(&p).await;
        }
        self.persist_locked();
        Ok(())
    }

    /// Re-list the share and refresh the IsoStore entries.
    pub async fn rescan(&self, id: &str) -> Result<u32> {
        self.rescan_inner(id).await
    }

    /// Snapshot of every configured share, sorted by id for stable UI
    /// rendering.
    #[must_use]
    pub fn list(&self) -> Vec<SmbShare> {
        let g = self.inner.lock();
        let mut v: Vec<_> = g.shares.values().cloned().collect();
        v.sort_by(|a, b| a.id.cmp(&b.id));
        v
    }

    /// Look up a share by id.
    #[must_use]
    pub fn get(&self, id: &str) -> Option<SmbShare> {
        self.inner.lock().shares.get(id).cloned()
    }

    /// Open an async reader streaming an ISO out of the share. Used
    /// by the HTTP ISO download handler.
    ///
    /// Kept `async` for symmetry with the other I/O entrypoints —
    /// spawning the child is sync today (no `.await` inside) but a
    /// future addition (e.g. probing the share before spawn or
    /// throttling concurrent smbclients) would need to await without
    /// changing the call sites.
    #[allow(clippy::unused_async)]
    pub async fn stream_iso(
        &self,
        share_id: &str,
        filename: &str,
    ) -> Result<SmbStream> {
        let share = self
            .get(share_id)
            .ok_or_else(|| Error::Invalid(format!("no such SMB share '{share_id}'")))?;
        // Defensive: reject any filename that tries to escape the
        // share root. smbclient itself accepts only filenames at the
        // share root in our `get` form, but belt-and-suspenders.
        if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
            return Err(Error::Invalid(format!(
                "invalid filename '{filename}'"
            )));
        }
        let creds = share
            .creds_path
            .as_deref()
            .ok_or_else(|| Error::Invalid("share has no credentials file".into()))?;
        let target = format!("//{}/{}", share.server, share.share);
        let mut cmd = Command::new("smbclient");
        cmd.arg(&target)
            .arg("-A")
            .arg(creds)
            .arg("-p")
            .arg(share.port.to_string())
            .arg("-c")
            .arg(format!("get \"{filename}\" -"))
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .stdin(Stdio::null());
        if share.guest {
            cmd.arg("-N");
        }
        // v0.4.66: surface a useful error if smbclient isn't on the
        // PATH. Shouldn't happen on the stock image but custom
        // builds may strip it.
        let mut child = cmd.spawn().map_err(|e| {
            if e.kind() == std::io::ErrorKind::NotFound {
                Error::Invalid(
                    "smbclient binary not found on $PATH — install the \
                     Debian `smbclient` package or pull OpenPXE v0.4.66+"
                        .into(),
                )
            } else {
                Error::Other(e.into())
            }
        })?;
        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| Error::Invalid("smbclient stdout missing".into()))?;
        Ok(SmbStream { child, stdout })
    }

    // ── internals ─────────────────────────────────────────────────────

    async fn rescan_inner(&self, id: &str) -> Result<u32> {
        let _g = self.op_lock.lock().await;

        let share = self
            .get(id)
            .ok_or_else(|| Error::Invalid(format!("no such share '{id}'")))?;
        let now = OffsetDateTime::now_utc();

        // Drop prior entries so a deleted file disappears from the
        // store on the next scan.
        self.iso_store.drop_external_source(id);

        let listing = match self.list_isos(&share).await {
            Ok(l) => l,
            Err((err, stderr)) => {
                let combined = if stderr.is_empty() {
                    err.clone()
                } else {
                    format!("{err}: {stderr}")
                };
                let hint = hint_for(&stderr).or_else(|| hint_for(&err));
                self.update_status(id, 0, false, Some(combined.clone()), hint, now);
                return Err(Error::Invalid(combined));
            }
        };

        // For each ISO we found, we still need its size + a quick
        // introspection pass. The introspection pass needs random
        // access into the ISO9660 PVD which lives at offset 0x8000.
        // For SMB sources we can't seek without downloading the file
        // first, so we use a degenerate "unknown family" introspection
        // report for the listing pass. Operators can rescan after the
        // first PXE boot has touched the file if they want a real
        // family detection. (Better: a follow-up release adds a tiny
        // `smbclient -c 'get file -'` bounded read to do introspection
        // without storing the whole ISO.)
        let mut count = 0u32;
        for entry in listing {
            let iso_id = format!("smb-{}-{}", share.id, slugify_str(&entry.filename));
            // SMB sources don't get a real introspection pass — that
            // would require seeking into the ISO9660 PVD over the
            // network, and smbclient CLI doesn't seek. We register an
            // `Unknown` family so the boot-entry generator falls back
            // to generic sanboot/wimboot detection from the filename
            // and the operator gets *something* bootable. A follow-up
            // release can do a bounded `smbclient get` of the first
            // 64 KiB for real detection.
            let report = IntrospectionReport {
                family: DistroFamily::Unknown,
                volume_label: None,
                kernel_path: None,
                initrd_paths: Vec::new(),
                has_boot_wim: false,
            };
            let boot_entries = generate_boot_entries_for(&iso_id, &entry.filename, &report);
            let source = IsoSource::Smb {
                share_id: share.id.clone(),
                relative_path: entry.filename.clone(),
            };
            self.iso_store.register_external(
                iso_id,
                entry.filename,
                entry.size,
                report,
                boot_entries,
                source,
            );
            count += 1;
        }

        self.update_status(id, count, true, None, None, now);
        tracing::info!(
            target: "openpxe::smb",
            id = %id, server = %share.server, share = %share.share,
            iso_count = count,
            "SMB share scanned"
        );
        Ok(count)
    }

    /// Spawn `smbclient //server/share -A creds -c "ls *.iso"` and
    /// parse the output. Returns `(error_text, stderr_text)` on
    /// failure so the caller can surface both.
    async fn list_isos(
        &self,
        share: &SmbShare,
    ) -> std::result::Result<Vec<SmbListEntry>, (String, String)> {
        let target = format!("//{}/{}", share.server, share.share);
        let mut cmd = Command::new("smbclient");
        cmd.arg(&target)
            .arg("-A")
            .arg(
                share
                    .creds_path
                    .as_deref()
                    .ok_or_else(|| ("no credentials file".to_string(), String::new()))?,
            )
            .arg("-p")
            .arg(share.port.to_string())
            .arg("-c")
            .arg("ls *.iso");
        if share.guest {
            cmd.arg("-N");
        }
        let output = match cmd.output().await {
            Ok(o) => o,
            Err(e) => {
                // v0.4.66: distinguish ENOENT (missing binary) from
                // other exec failures and pre-fill the hint so the
                // UI shows a clear remediation instead of the bare
                // "No such file or directory (os error 2)". This
                // shouldn't fire on the stock image — the Dockerfile
                // installs the `smbclient` package — but is a useful
                // breadcrumb for anyone running OpenPXE in a stripped
                // base image.
                let stderr = if e.kind() == std::io::ErrorKind::NotFound {
                    "smbclient binary not found on $PATH".to_string()
                } else {
                    String::new()
                };
                return Err((
                    format!("could not exec smbclient: {e}"),
                    stderr,
                ));
            }
        };
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            // smbclient writes most diagnostics to stdout too; merge
            // them so we don't lose context.
            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
            let combined = if stderr.is_empty() { stdout } else { stderr };
            return Err((
                format!("smbclient exit {}", output.status.code().unwrap_or(-1)),
                combined,
            ));
        }
        let stdout = String::from_utf8_lossy(&output.stdout);
        Ok(parse_ls_iso(&stdout))
    }

    async fn write_creds(
        &self,
        path: &Path,
        username: &str,
        password: &str,
    ) -> std::io::Result<()> {
        tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
        // Write the file with 0600 perms. `smbclient -A` accepts the
        // standard pam_mount-style:
        //   username = foo
        //   password = bar
        let body = format!(
            "username = {}\npassword = {}\n",
            username.replace('\n', ""),
            password.replace('\n', ""),
        );
        // Synchronous file write to set perms atomically with the
        // create — there's no async equivalent of OpenOptions+mode
        // shared with the tokio API in std stable.
        let path = path.to_path_buf();
        tokio::task::spawn_blocking(move || -> std::io::Result<()> {
            use std::os::unix::fs::OpenOptionsExt;
            let mut f = std::fs::OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(true)
                .mode(0o600)
                .open(&path)?;
            f.write_all(body.as_bytes())?;
            Ok(())
        })
        .await
        .map_err(std::io::Error::other)??;
        Ok(())
    }

    fn update_status(
        &self,
        id: &str,
        iso_count: u32,
        reachable: bool,
        err: Option<String>,
        hint: Option<String>,
        ts: OffsetDateTime,
    ) {
        if let Some(s) = self.inner.lock().shares.get_mut(id) {
            s.iso_count = iso_count;
            s.reachable = reachable;
            s.last_error = err;
            s.last_hint = hint;
            s.last_scan = Some(ts);
        }
        self.persist_locked();
    }

    /// Atomically replace the on-disk JSON. Persistence errors are
    /// logged, never propagated.
    fn persist_locked(&self) {
        let shares: Vec<SmbShare> = self.inner.lock().shares.values().cloned().collect();
        let path = self.state_path.as_path();
        let tmp = path.with_extension("json.tmp");
        let body = match serde_json::to_vec_pretty(&shares) {
            Ok(b) => b,
            Err(e) => {
                tracing::warn!(target: "openpxe::smb", "serialize: {e}");
                return;
            }
        };
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Err(e) = std::fs::write(&tmp, body) {
            tracing::warn!(target: "openpxe::smb", "write tmp: {e}");
            return;
        }
        if let Err(e) = std::fs::rename(&tmp, path) {
            tracing::warn!(target: "openpxe::smb", "rename: {e}");
        }
    }
}

/// Async-reader handle for an in-flight `smbclient get file -` stream.
/// Wraps the child process + its piped stdout; dropping it kills the
/// child.
#[derive(Debug)]
pub struct SmbStream {
    /// Kept alive so the child isn't reaped while we're reading. The
    /// `Drop` impl on `tokio::process::Child` sends SIGKILL on drop
    /// when `kill_on_drop` is set; we leave that to the default
    /// (no-kill) so a slow client doesn't tear down the pipe before
    /// the OS finishes the read. The child exits naturally when its
    /// stdout closes.
    #[allow(dead_code)]
    child: tokio::process::Child,
    pub stdout: tokio::process::ChildStdout,
}

#[derive(Debug, Clone)]
struct SmbListEntry {
    filename: String,
    size: u64,
}

/// Parse `smbclient ls *.iso` output. The format is:
///
/// ```text
///   .                                   D        0  Mon May 26 10:00:00 2026
///   ..                                  D        0  Mon May 26 10:00:00 2026
///   ubuntu-22.04-desktop.iso            A 3650912256  Mon May 26 11:00:00 2026
///
///         4096 blocks of size 1048576. 1234 blocks available
/// ```
///
/// Each file line:
///   - starts with whitespace
///   - has the filename, then attribute flags (D=dir, A=archive, R=read-only,
///     H=hidden, S=system, N=normal), then size, then date.
///
/// We accept any line where the attributes column doesn't contain `D`
/// (i.e. not a directory) and the filename ends in `.iso` (case
/// insensitive).
fn parse_ls_iso(out: &str) -> Vec<SmbListEntry> {
    let mut entries = Vec::new();
    for raw in out.lines() {
        let line = raw.trim();
        // Skip blank lines, the connection-info banner, and the
        // trailing "N blocks of size" summary. The actual filter for
        // "is this a file listing?" is the attribute+size pattern
        // detection below, which only matches real file rows.
        if line.is_empty() || line.contains("blocks of size") {
            continue;
        }
        // Find the attribute column: a short token of one or more of
        // [DAHSRN] that follows a long-enough filename block.
        // smbclient pads the filename to ~36 columns, so we can split
        // on multiple consecutive spaces and then look for the
        // attribute token.
        let tokens: Vec<&str> = line.split_whitespace().collect();
        if tokens.len() < 3 {
            continue;
        }
        // The last 5 tokens are typically: ATTR SIZE Day Mon DD HH:MM:SS YYYY
        // (sometimes Day is missing depending on locale). Walk
        // backwards to find ATTR + SIZE: ATTR is 1-6 chars of [DAHSRN],
        // SIZE is digits.
        let attr_idx = tokens.iter().enumerate().rev().find_map(|(i, t)| {
            if i == 0 {
                return None;
            }
            let next = tokens.get(i + 1)?;
            let is_attr = !t.is_empty() && t.chars().all(|c| "DAHSRN".contains(c));
            let is_size = next.chars().all(|c| c.is_ascii_digit()) && !next.is_empty();
            if is_attr && is_size {
                Some(i)
            } else {
                None
            }
        });
        let Some(attr_idx) = attr_idx else { continue };
        let attr = tokens[attr_idx];
        // Directories aren't ISO files.
        if attr.contains('D') {
            continue;
        }
        let size_tok = tokens[attr_idx + 1];
        let Ok(size) = size_tok.parse::<u64>() else {
            continue;
        };
        // The filename is everything before the attribute token in
        // the original (un-tokenized) line — we need the original
        // because filenames can contain spaces.
        // Locate the attribute token's start column by counting
        // characters in the prior tokens + separators. Simpler: find
        // the index of the attribute in the trimmed line by joining
        // and trimming again.
        let joined_before: String = tokens[..attr_idx].join(" ");
        let name = joined_before.trim().to_string();
        if name.is_empty() || name == "." || name == ".." {
            continue;
        }
        if !name.to_ascii_lowercase().ends_with(".iso") {
            continue;
        }
        entries.push(SmbListEntry {
            filename: name,
            size,
        });
    }
    entries
}

/// Pre-flight TCP probe to `server:port`. Format matches v0.4.64 NFS
/// probe so the UI banner reads consistently.
async fn tcp_probe(
    server: &str,
    port: u16,
) -> std::result::Result<(), (String, String)> {
    use tokio::net::TcpStream;
    let addr = format!("{server}:{port}");
    match tokio::time::timeout(PROBE_TIMEOUT, TcpStream::connect(&addr)).await {
        Ok(Ok(_)) => Ok(()),
        Ok(Err(e)) => Err((
            format!("cannot reach SMB port: {addr}: {e}"),
            format!(
                "verify the SMB service is running on {server} and that port {port} is open"
            ),
        )),
        Err(_) => Err((
            format!(
                "cannot reach SMB port: {addr}: timed out after {}s",
                PROBE_TIMEOUT.as_secs()
            ),
            format!(
                "no TCP answer from {server}:{port} within {}s — check the IP and any firewall in between",
                PROBE_TIMEOUT.as_secs()
            ),
        )),
    }
}

/// Translate well-known smbclient stderr patterns into actionable
/// hints. Returns `None` when we don't have a translation.
fn hint_for(text: &str) -> Option<String> {
    let s = text.to_ascii_lowercase();
    if s.contains("smbclient binary not found")
        || s.contains("smbclient: no such file")
        || (s.contains("could not exec smbclient") && s.contains("os error 2"))
    {
        // v0.4.66: this only fires on a stripped / custom runtime
        // image — the stock OpenPXE container ships `smbclient` from
        // the Debian `smbclient` package. The error surfaced on
        // v0.4.65 specifically because that release's Dockerfile
        // installed `samba` (the server) but not `smbclient` (the
        // client CLI). Operators on the stock image should never see
        // this; if they do, the fix is to upgrade.
        Some(
            "smbclient isn't installed in this container. Pull the \
             official OpenPXE image v0.4.66 or newer — the stock image \
             ships smbclient. If you're running a custom build, add the \
             Debian `smbclient` package to your runtime stage."
                .into(),
        )
    } else if s.contains("nt_status_logon_failure") || s.contains("logon_failure") {
        Some(
            "the server rejected the credentials. Double-check the username \
             and password — many NAS appliances use a separate SMB account \
             rather than the system login."
                .into(),
        )
    } else if s.contains("nt_status_access_denied") || s.contains("access_denied") {
        Some(
            "the credentials worked but the account doesn't have read \
             access to this share. Check the share's permissions on the \
             server."
                .into(),
        )
    } else if s.contains("nt_status_bad_network_name")
        || s.contains("nt_status_bad_network_path")
        || s.contains("bad_network_name")
    {
        Some(
            "the share name doesn't exist on this server. Enter just the \
             share name (e.g. 'isos'), not a path. Use `smbclient -L \
             //server` to list shares manually."
                .into(),
        )
    } else if s.contains("connection refused") {
        Some(
            "the SMB service isn't accepting connections on this port. \
             Verify smbd / Samba is running on the server."
                .into(),
        )
    } else if s.contains("connection timed out") || s.contains("no route to host") {
        Some(
            "the server isn't reachable on this network. Check the IP and \
             any firewall in between."
                .into(),
        )
    } else if s.contains("nt_status_network_unreachable") {
        Some(
            "the server's network is unreachable from this container — \
             check the host networking setup."
                .into(),
        )
    } else if s.contains("does not exist") || s.contains("not a directory") {
        Some(
            "the listed path doesn't exist on the share. Make sure the \
             share name is the top-level share, not a sub-path."
                .into(),
        )
    } else if s.contains("session setup failed") {
        Some(
            "session setup failed — usually a protocol / dialect mismatch. \
             Most modern servers speak SMB2/3; very old shares (XP) may \
             need legacy support enabled on the server."
                .into(),
        )
    } else {
        None
    }
}

fn share_id(server: &str, share: &str) -> String {
    slugify_str(&format!("{server}-{share}"))
}

/// Normalize a server input: trim, strip scheme prefix the operator
/// may have pasted, and drop trailing slashes. UNC-style `\\server`
/// and `//server` prefixes are also accepted.
fn normalize_server(raw: &str) -> String {
    let s = raw.trim();
    let s = s
        .strip_prefix("smb://")
        .or_else(|| s.strip_prefix("cifs://"))
        .or_else(|| s.strip_prefix("\\\\"))
        .or_else(|| s.strip_prefix("//"))
        .unwrap_or(s);
    s.trim_end_matches('/').trim_end_matches('\\').to_string()
}

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

    #[test]
    fn share_id_is_stable_and_safe() {
        let a = share_id("10.0.0.5", "isos");
        let b = share_id("10.0.0.5", "isos");
        assert_eq!(a, b);
        assert!(!a.contains('/'));
        assert!(!a.contains('.'));
    }

    #[test]
    fn normalize_server_strips_url_and_unc_prefixes() {
        assert_eq!(normalize_server("  10.0.0.5  "), "10.0.0.5");
        assert_eq!(normalize_server("smb://nas.lan/"), "nas.lan");
        assert_eq!(normalize_server("cifs://192.168.1.51"), "192.168.1.51");
        assert_eq!(normalize_server("\\\\192.168.1.51\\"), "192.168.1.51");
        assert_eq!(normalize_server("//nas.lan//"), "nas.lan");
        assert_eq!(normalize_server("nas.lan"), "nas.lan");
    }

    #[test]
    fn hint_for_logon_failure_calls_out_credentials() {
        let h = hint_for("session setup failed: NT_STATUS_LOGON_FAILURE").unwrap();
        assert!(h.to_lowercase().contains("credentials"));
    }

    #[test]
    fn hint_for_bad_share_name_calls_out_share_lookup() {
        let h = hint_for("tree connect failed: NT_STATUS_BAD_NETWORK_NAME").unwrap();
        assert!(h.to_lowercase().contains("share"));
    }

    #[test]
    fn hint_for_unknown_is_none() {
        assert!(hint_for("some unrelated error text").is_none());
    }

    #[test]
    fn hint_for_missing_smbclient_calls_out_upgrade() {
        // The exec-side path sets `stderr` to "smbclient binary not
        // found on $PATH" when ENOENT lands.
        let h = hint_for("smbclient binary not found on $PATH").unwrap();
        assert!(
            h.contains("smbclient") && h.to_lowercase().contains("install"),
            "expected upgrade/install guidance, got: {h}"
        );
        // The raw error path on the API side passes the verbatim
        // exec error in `error` plus an empty `stderr`. The
        // SmbShareError constructor's hint_for fallback checks error
        // too, so this pattern needs to translate as well.
        let h2 = hint_for("could not exec smbclient: No such file or directory (os error 2)")
            .unwrap();
        assert!(h2.contains("smbclient"));
    }

    #[test]
    fn parse_ls_iso_finds_one_iso_and_skips_directories() {
        let out = "\
\tDomain=[WORKGROUP] OS=[Windows] Server=[Samba]\n\
  .                                   D        0  Mon May 26 10:00:00 2026\n\
  ..                                  D        0  Mon May 26 10:00:00 2026\n\
  ubuntu-22.04-desktop.iso            A 3650912256  Mon May 26 11:00:00 2026\n\
\n\
\t\t4096 blocks of size 1048576. 1234 blocks available\n\
";
        let entries = parse_ls_iso(out);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].filename, "ubuntu-22.04-desktop.iso");
        assert_eq!(entries[0].size, 3_650_912_256);
    }

    #[test]
    fn parse_ls_iso_handles_filenames_with_spaces() {
        let out = "\
  Windows Server 2025.iso             A 5000000000  Tue May 27 09:00:00 2026\n\
";
        let entries = parse_ls_iso(out);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].filename, "Windows Server 2025.iso");
        assert_eq!(entries[0].size, 5_000_000_000);
    }

    #[test]
    fn parse_ls_iso_skips_non_iso_files() {
        let out = "\
  readme.txt                          A      100  Tue May 27 09:00:00 2026\n\
  archive.zip                         A     5000  Tue May 27 09:00:00 2026\n\
";
        let entries = parse_ls_iso(out);
        assert!(entries.is_empty());
    }

    #[test]
    fn add_request_requires_username_when_not_guest() {
        // We can't easily test the add() path against a real SMB
        // server in unit tests, but we can confirm the validation
        // logic at least serializes the request shape we expect. The
        // actual auth check happens in add() itself which we cover in
        // integration tests against a stub server.
        let req = SmbAddRequest {
            server: "10.0.0.5".into(),
            share: "isos".into(),
            username: None,
            password: None,
            guest: false,
            port: None,
        };
        // No SmbShareManager here — we just check the field shape
        // matches what UI submits.
        assert!(!req.guest);
        assert!(req.username.is_none());
    }
}
