//! SFTP share consumer — in-process userspace SSH/SFTP client.
//!
//! v0.5.5 adds a third remote-library protocol alongside SMB (v0.4.65)
//! and NFS (v0.4.67). Like NFS it's a pure-Rust, in-process client —
//! no subprocess, no kernel mount, no `CAP_SYS_ADMIN` — so it works in
//! every container the other two work in (Unraid, restricted-SCC
//! OpenShift).
//!
//! ## Why SFTP and not SCP
//!
//! The obvious "SSH-based shares" ask is SCP, but SCP is the wrong
//! protocol for this job twice over: (1) it's a sequential whole-file
//! stream with no random access, so it could only ever behave like the
//! SMB path (whole-file, no HTTP Range); and (2) the mature Rust SCP
//! crates wrap libssh2 — a C library that needs OpenSSL — which would
//! break the static-musl, OpenSSL-free build. SFTP sidesteps both:
//! `SSH_FXP_READ` takes an explicit offset (so Range works, like NFS),
//! and `russh` + `russh-sftp` are pure Rust on the `ring` crypto
//! backend already in the binary (rustls + bergshamra), adding zero new
//! C dependencies.
//!
//! ## How it works
//!
//! 1. Operator submits `{ server, export, username, port, +secret }`
//!    via the Storage tab. The secret is either a password or an SSH
//!    private key (PEM, optionally passphrase-protected) — never both.
//! 2. We write the secret to a 0600 JSON file under
//!    `<work_dir>/sftp_creds/<id>.cred`. The share metadata
//!    (server/user/export/host-key fingerprint) lives in
//!    `<work_dir>/sftp_shares.json`; the secret never touches that file.
//! 3. We connect over SSH, authenticate, open the `sftp` subsystem, and
//!    `READDIR` the export directory for `*.iso` files (size comes back
//!    in the same listing — no extra round trip).
//! 4. Each ISO registers with the `IsoStore` as
//!    `IsoSource::Sftp { share_id, relative_path }`.
//! 5. On a PXE client request the HTTP handler asks for a byte stream
//!    at an offset; we open the file, `seek`, and pipe bounded chunks
//!    into the response body. HTTP Range → `206 Partial Content`, same
//!    as NFS.
//!
//! ## Host-key trust (TOFU)
//!
//! On the first successful connect we pin the server's SSH host-key
//! fingerprint (SHA256) into the share. Every later connect compares
//! the presented key against the pinned one and refuses the connection
//! on a mismatch — classic trust-on-first-use, like a fresh
//! `known_hosts`. A rebuilt server (new host key) surfaces a clear
//! "host key changed" error; the operator removes and re-adds to trust
//! the new key. This is strictly more authentication than NFS/SMB do
//! (neither authenticates the server at all), and it's cheap over SSH.
//!
//! ## Same public surface
//!
//! `SftpShareManager` mirrors `NfsShareManager` / `SmbShareManager`;
//! `SftpShare` mirrors `NfsShare` / `SmbShare`; the `{error, stderr,
//! hint}` error shape is shared so the storage tab renders all three
//! protocols through one code path.

use crate::introspect::{DistroFamily, IntrospectionReport};
use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore};
use bytes::Bytes;
use openpxe_core::{Error, Result};
use parking_lot::Mutex;
use russh::client;
use russh::keys::{decode_secret_key, HashAlg, PrivateKeyWithHashAlg, PublicKey};
use russh_sftp::client::SftpSession;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use time::OffsetDateTime;
use tokio::io::{AsyncReadExt, AsyncSeekExt};

/// Default TCP port for SSH (and thus the SFTP subsystem).
const DEFAULT_SFTP_PORT: u16 = 22;

/// How long we wait for the TCP connect + SSH handshake before giving
/// up. Matches the shape of the NFS/SMB pre-flight timeouts so the UI
/// banner reads consistently across protocols. The SSH key exchange is
/// a couple of round trips plus some asymmetric crypto, so we give it a
/// little more headroom than the bare TCP probe.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);

/// Bounded TCP pre-flight so a wrong IP / closed port surfaces a clean
/// error before we spin up the (heavier) SSH handshake.
const PROBE_TIMEOUT: Duration = Duration::from_secs(4);

/// Read chunk size for streaming. 64 KiB keeps in-flight memory bounded
/// and matches the NFS path; russh-sftp pipelines reads internally so
/// the chunk size is about response-body granularity, not throughput.
const READ_CHUNK_BYTES: usize = 64 * 1024;

/// Bound on the in-flight queue between the read-loop task and the HTTP
/// body stream. 16 * 64 KiB ≈ 1 MiB max buffer per stream.
const STREAM_BUFFER_DEPTH: usize = 16;

/// Which credential the share authenticates with. The secret itself
/// lives in the 0600 creds file, never here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SftpAuthKind {
    Password,
    Key,
}

/// One configured SFTP share. The id is derived from server+export so
/// re-adding the same coordinates is idempotent. No secret is stored on
/// this struct — it's safe to serialize into the API JSON.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SftpShare {
    pub id: String,
    pub server: String,
    /// Directory on the server holding the ISOs (e.g. "/srv/isos").
    pub export: String,
    /// SSH username.
    pub username: String,
    /// Which credential kind this share uses. The UI echoes it; the
    /// secret lives in the creds file.
    pub auth: SftpAuthKind,
    /// TCP port — 22 unless overridden.
    #[serde(default = "default_port")]
    pub port: u16,
    /// Pinned SSH host-key fingerprint (SHA256, e.g. "SHA256:abc…").
    /// `None` until the first successful connect; once set, a changed
    /// key is refused (TOFU). Public information — safe to surface.
    #[serde(default)]
    pub host_key_fingerprint: Option<String>,
    pub last_error: Option<String>,
    pub last_hint: Option<String>,
    #[serde(with = "time::serde::rfc3339::option")]
    pub last_scan: Option<OffsetDateTime>,
    pub iso_count: u32,
    pub reachable: bool,
}

/// Submission from the UI / API. Exactly one of `password` /
/// `private_key` should be set.
#[derive(Debug, Clone, Deserialize)]
pub struct SftpAddRequest {
    pub server: String,
    pub export: String,
    #[serde(default)]
    pub username: Option<String>,
    #[serde(default)]
    pub port: Option<u16>,
    #[serde(default)]
    pub password: Option<String>,
    /// PEM-encoded OpenSSH private key, pasted by the operator.
    #[serde(default)]
    pub private_key: Option<String>,
    /// Passphrase for an encrypted private key. Optional.
    #[serde(default)]
    pub passphrase: Option<String>,
}

fn default_port() -> u16 {
    DEFAULT_SFTP_PORT
}

/// The secret material for a share, persisted to a 0600 sidecar JSON
/// file. Never serialized into the public share JSON or API responses.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct SftpCreds {
    #[serde(default)]
    password: Option<String>,
    #[serde(default)]
    private_key: Option<String>,
    #[serde(default)]
    passphrase: Option<String>,
}

/// Structured error surfaced to the API and rendered in the UI. Same
/// shape as `NfsShareError` / `SmbShareError`.
#[derive(Debug, Clone, Serialize)]
pub struct SftpShareError {
    pub error: String,
    pub stderr: String,
    pub hint: Option<String>,
}

impl SftpShareError {
    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, SftpShare>,
}

/// Manages SFTP shares. Cheap to clone — internal state is
/// `Arc<Mutex<...>>`.
#[derive(Debug, Clone)]
pub struct SftpShareManager {
    creds_root: Arc<PathBuf>,
    state_path: Arc<PathBuf>,
    inner: Arc<Mutex<Inner>>,
    iso_store: IsoStore,
    /// Serializes scan operations on the same manager for predictable
    /// log output; each scan opens its own SSH connection.
    op_lock: Arc<tokio::sync::Mutex<()>>,
}

impl SftpShareManager {
    /// Construct a manager. Secrets live under `<work_dir>/sftp_creds/`
    /// (0600); state persists to `<work_dir>/sftp_shares.json`.
    #[must_use]
    pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self {
        Self {
            creds_root: Arc::new(work_dir.join("sftp_creds")),
            state_path: Arc::new(work_dir.join("sftp_shares.json")),
            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. Per-share failures
    /// are logged but never propagated — 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<SftpShare>>(&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::sftp",
                    id = %s.id, server = %s.server, export = %s.export,
                    "rescan on startup failed: {e}"
                );
            }
        }
        Ok(())
    }

    /// Register an SFTP share. Validates, writes the creds file, probes
    /// connectivity by performing a real SSH connect + SFTP READDIR,
    /// pins the host key (TOFU), and registers the resulting ISOs.
    pub async fn add(&self, req: SftpAddRequest) -> std::result::Result<SftpShare, SftpShareError> {
        let server = normalize_server(&req.server);
        let export = req.export.trim().to_string();
        if server.is_empty() {
            return Err(SftpShareError::from_raw("server is required", ""));
        }
        if !export.starts_with('/') {
            return Err(SftpShareError::from_raw(
                "export path must be absolute (start with '/', e.g. /srv/isos)",
                "",
            ));
        }
        if server.contains('\0') || export.contains('\0') {
            return Err(SftpShareError::from_raw("NUL bytes are not allowed", ""));
        }
        let username = req.username.unwrap_or_default().trim().to_string();
        if username.is_empty() {
            return Err(SftpShareError::from_raw("username is required", ""));
        }

        // Exactly one secret. Empty strings count as "not provided".
        let password = req.password.filter(|s| !s.is_empty());
        let private_key = req.private_key.filter(|s| !s.trim().is_empty());
        let (auth, creds) = match (password, private_key) {
            (Some(_), Some(_)) => {
                return Err(SftpShareError::from_raw(
                    "provide either a password or a private key, not both",
                    "",
                ));
            }
            (Some(pw), None) => (
                SftpAuthKind::Password,
                SftpCreds {
                    password: Some(pw),
                    ..Default::default()
                },
            ),
            (None, Some(key)) => (
                SftpAuthKind::Key,
                SftpCreds {
                    private_key: Some(key),
                    passphrase: req.passphrase.filter(|s| !s.is_empty()),
                    ..Default::default()
                },
            ),
            (None, None) => {
                return Err(SftpShareError::from_raw(
                    "a password or an SSH private key is required",
                    "",
                ));
            }
        };
        let port = req.port.filter(|p| *p != 0).unwrap_or(DEFAULT_SFTP_PORT);
        let id = share_id(&server, &export);

        // Pre-flight TCP probe so a wrong IP / firewall surfaces a clean
        // error instead of a slow SSH-handshake timeout.
        if let Err((err, hint)) = tcp_probe(&server, port).await {
            return Err(SftpShareError {
                error: err,
                stderr: String::new(),
                hint: Some(hint),
            });
        }

        // Persist the secret (0600) before we register the spec.
        if let Err(e) = self.write_creds(&id, &creds).await {
            return Err(SftpShareError::from_raw(
                format!("could not write credentials file: {e}"),
                "",
            ));
        }

        let spec = SftpShare {
            id: id.clone(),
            server,
            export,
            username,
            auth,
            port,
            host_key_fingerprint: None,
            last_error: None,
            last_hint: None,
            last_scan: None,
            iso_count: 0,
            reachable: false,
        };
        self.inner.lock().shares.insert(id.clone(), spec);
        self.persist_locked();

        if let Err(e) = self.rescan_inner(&id).await {
            // Clean up the creds file and the half-registered share so a
            // failed add doesn't leave a dead row behind.
            let _ = tokio::fs::remove_file(self.creds_path_for(&id)).await;
            let m = self.get(&id);
            self.inner.lock().shares.remove(&id);
            self.persist_locked();
            return Err(SftpShareError {
                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 and scrubs the
    /// creds file. Idempotent.
    pub async fn remove(&self, id: &str) -> Result<()> {
        let removed = self.inner.lock().shares.remove(id).is_some();
        if removed {
            self.iso_store.drop_external_source(id);
            let _ = tokio::fs::remove_file(self.creds_path_for(id)).await;
            self.persist_locked();
        }
        Ok(())
    }

    /// Re-walk a share for new / removed ISOs.
    pub async fn rescan(&self, id: &str) -> Result<u32> {
        self.rescan_inner(id).await
    }

    #[must_use]
    pub fn list(&self) -> Vec<SftpShare> {
        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
    }

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

    /// Open a byte stream reading `filename` out of share `share_id`,
    /// starting at `start_offset` and reading at most `max_len` bytes.
    /// Supports HTTP Range requests because SFTP opens a file handle we
    /// can `seek` into — the same protocol-level advantage NFS has over
    /// the SMB userspace path.
    pub async fn stream_iso(
        &self,
        share_id: &str,
        filename: &str,
        start_offset: u64,
        max_len: Option<u64>,
    ) -> Result<SftpStream> {
        let share = self
            .get(share_id)
            .ok_or_else(|| Error::Invalid(format!("no such SFTP share '{share_id}'")))?;
        // Flat path model (matching NFS/SMB): ISOs live at the export
        // root, no nested directories. Reject anything that looks like a
        // path so a crafted name can't escape the export.
        if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
            return Err(Error::Invalid(format!("invalid filename '{filename}'")));
        }
        let creds = self
            .read_creds(share_id)
            .await
            .map_err(|e| Error::Invalid(format!("could not read credentials: {e}")))?;

        let params = ConnParams::from_share(&share);
        let fname = filename.to_string();
        let (tx, rx) = tokio::sync::mpsc::channel::<std::io::Result<Bytes>>(STREAM_BUFFER_DEPTH);

        // Spawn a task that owns the SSH connection for the lifetime of
        // the stream — like the NFS path, each stream gets its own
        // connection so there's no sharing across scan/stream.
        let task = tokio::spawn(async move {
            let result =
                stream_loop(&params, &creds, &fname, start_offset, max_len, tx.clone()).await;
            if let Err(e) = result {
                let _ = tx.send(Err(std::io::Error::other(e.to_string()))).await;
            }
        });

        Ok(SftpStream { rx, _task: task })
    }

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

    fn creds_path_for(&self, id: &str) -> PathBuf {
        self.creds_root.join(format!("{id}.cred"))
    }

    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();

        let creds = match self.read_creds(id).await {
            Ok(c) => c,
            Err(e) => {
                let msg = format!("could not read credentials: {e}");
                self.update_status(id, 0, false, Some(msg.clone()), None, now);
                return Err(Error::Invalid(msg));
            }
        };

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

        let params = ConnParams::from_share(&share);
        let (listing, fingerprint) = match list_isos(&params, &creds).await {
            Ok(v) => v,
            Err(err) => {
                let stderr = err.to_string();
                let hint = hint_for(&stderr);
                self.update_status(id, 0, false, Some(stderr.clone()), hint, now);
                return Err(Error::Invalid(stderr));
            }
        };

        let mut count = 0u32;
        for entry in listing {
            let iso_id = format!("sftp-{}-{}", share.id, slugify_str(&entry.filename));
            // Same as NFS/SMB: no over-the-network introspection yet, so
            // register `Unknown` and let the boot-entry generator fall
            // back to filename-based detection. SFTP *could* do bounded
            // PVD reads (it has random access) — a follow-up can add it.
            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::Sftp {
                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;
        }

        // Pin the host key (TOFU) on first sight; later scans connect
        // with it as the expected key and would have failed above on a
        // mismatch, so this only ever sets or re-confirms.
        self.pin_fingerprint(id, fingerprint);
        self.update_status(id, count, true, None, None, now);
        tracing::info!(
            target: "openpxe::sftp",
            id = %id, server = %share.server, export = %share.export,
            iso_count = count,
            "SFTP share scanned"
        );
        Ok(count)
    }

    fn pin_fingerprint(&self, id: &str, fingerprint: String) {
        if fingerprint.is_empty() {
            return;
        }
        let mut g = self.inner.lock();
        if let Some(s) = g.shares.get_mut(id) {
            if s.host_key_fingerprint.is_none() {
                s.host_key_fingerprint = Some(fingerprint);
            }
        }
    }

    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();
    }

    async fn write_creds(&self, id: &str, creds: &SftpCreds) -> std::io::Result<()> {
        tokio::fs::create_dir_all(self.creds_root.as_path()).await?;
        let body = serde_json::to_vec(creds).map_err(std::io::Error::other)?;
        let path = self.creds_path_for(id);
        // Synchronous write to set 0600 atomically with create — std has
        // no async OpenOptions+mode on stable.
        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)?;
            Ok(())
        })
        .await
        .map_err(std::io::Error::other)??;
        Ok(())
    }

    async fn read_creds(&self, id: &str) -> std::io::Result<SftpCreds> {
        let body = tokio::fs::read(self.creds_path_for(id)).await?;
        serde_json::from_slice(&body).map_err(std::io::Error::other)
    }

    fn persist_locked(&self) {
        let shares: Vec<SftpShare> = 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::sftp", "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::sftp", "write tmp: {e}");
            return;
        }
        if let Err(e) = std::fs::rename(&tmp, path) {
            tracing::warn!(target: "openpxe::sftp", "rename: {e}");
        }
    }
}

/// HTTP body stream for an SFTP-sourced ISO read. Implements
/// `Stream<Item = io::Result<Bytes>>` so axum can build a response body
/// via `Body::from_stream`.
#[derive(Debug)]
pub struct SftpStream {
    rx: tokio::sync::mpsc::Receiver<std::io::Result<Bytes>>,
    /// Kept alive so the read-loop task (which owns the SSH connection)
    /// isn't cancelled while the client is still consuming. Dropping the
    /// stream cancels the task — the right behaviour on client disconnect.
    _task: tokio::task::JoinHandle<()>,
}

impl futures::Stream for SftpStream {
    type Item = std::io::Result<Bytes>;
    fn poll_next(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
    ) -> std::task::Poll<Option<Self::Item>> {
        self.rx.poll_recv(cx)
    }
}

/// Connection coordinates extracted from a share (no secret).
#[derive(Debug, Clone)]
struct ConnParams {
    server: String,
    port: u16,
    username: String,
    export: String,
    /// Pinned host key to enforce, or `None` for trust-on-first-use.
    expected_fingerprint: Option<String>,
}

impl ConnParams {
    fn from_share(s: &SftpShare) -> Self {
        Self {
            server: s.server.clone(),
            port: s.port,
            username: s.username.clone(),
            export: s.export.clone(),
            expected_fingerprint: s.host_key_fingerprint.clone(),
        }
    }
}

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

/// A live SSH session plus its SFTP subsystem. The session `Handle`
/// must outlive the `SftpSession` — dropping the last `Handle` closes
/// the SSH connection out from under the subsystem channel.
struct SftpConn {
    _session: client::Handle<TofuHandler>,
    sftp: SftpSession,
}

/// russh client handler implementing trust-on-first-use host-key
/// verification. We never construct an `Err` from `check_server_key`;
/// returning `Ok(false)` makes russh abort the handshake, and the
/// caller distinguishes "host key mismatch" from other connect failures
/// by comparing the captured `observed` fingerprint to the expected one.
struct TofuHandler {
    expected: Option<String>,
    observed: Arc<Mutex<Option<String>>>,
}

impl client::Handler for TofuHandler {
    type Error = russh::Error;

    async fn check_server_key(
        &mut self,
        server_public_key: &PublicKey,
    ) -> std::result::Result<bool, Self::Error> {
        let fp = server_public_key
            .fingerprint(HashAlg::default())
            .to_string();
        *self.observed.lock() = Some(fp.clone());
        match &self.expected {
            Some(exp) if exp != &fp => Ok(false), // mismatch → abort handshake
            _ => Ok(true),
        }
    }
}

/// Connect, authenticate, and open the SFTP subsystem. Returns the live
/// connection plus the observed host-key fingerprint (for TOFU pinning).
async fn connect(
    p: &ConnParams,
    creds: &SftpCreds,
) -> std::result::Result<(SftpConn, String), SftpClientError> {
    let observed = Arc::new(Mutex::new(None::<String>));
    let handler = TofuHandler {
        expected: p.expected_fingerprint.clone(),
        observed: observed.clone(),
    };
    let config = Arc::new(client::Config::default());

    let connect_fut = client::connect(config, (p.server.as_str(), p.port), handler);
    let mut session = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
        Ok(Ok(s)) => s,
        Ok(Err(e)) => {
            // Distinguish a host-key mismatch (handshake aborted by our
            // handler) from a generic connect failure.
            if let (Some(exp), Some(got)) = (&p.expected_fingerprint, observed.lock().clone()) {
                if exp != &got {
                    return Err(SftpClientError::HostKeyMismatch {
                        expected: exp.clone(),
                        got,
                    });
                }
            }
            return Err(SftpClientError::Connect(e.to_string()));
        }
        Err(_) => return Err(SftpClientError::Timeout(p.server.clone(), p.port)),
    };
    let observed_fp = observed.lock().clone().unwrap_or_default();

    let authed = if let Some(pw) = creds.password.as_deref() {
        session
            .authenticate_password(p.username.as_str(), pw)
            .await
            .map_err(|e| SftpClientError::Auth(e.to_string()))?
            .success()
    } else if let Some(pem) = creds.private_key.as_deref() {
        let key = decode_secret_key(pem, creds.passphrase.as_deref())
            .map_err(|e| SftpClientError::Auth(format!("private key: {e}")))?;
        // For RSA keys, request rsa-sha2-256 (modern servers reject the
        // legacy ssh-rsa/SHA-1); `new` ignores the hash for other key
        // types (ed25519, ecdsa), so this is safe to pass unconditionally.
        let pk = PrivateKeyWithHashAlg::new(Arc::new(key), Some(HashAlg::Sha256));
        session
            .authenticate_publickey(p.username.as_str(), pk)
            .await
            .map_err(|e| SftpClientError::Auth(e.to_string()))?
            .success()
    } else {
        return Err(SftpClientError::Auth("no credential configured".into()));
    };
    if !authed {
        return Err(SftpClientError::AuthRejected);
    }

    let channel = session
        .channel_open_session()
        .await
        .map_err(|e| SftpClientError::Connect(e.to_string()))?;
    channel
        .request_subsystem(true, "sftp")
        .await
        .map_err(|e| SftpClientError::Protocol(e.to_string()))?;
    let sftp = SftpSession::new(channel.into_stream())
        .await
        .map_err(|e| SftpClientError::Sftp(e.to_string()))?;

    Ok((
        SftpConn {
            _session: session,
            sftp,
        },
        observed_fp,
    ))
}

/// Connect and READDIR the export for `*.iso` files. Returns the listing
/// plus the observed host-key fingerprint.
async fn list_isos(
    p: &ConnParams,
    creds: &SftpCreds,
) -> std::result::Result<(Vec<SftpListEntry>, String), SftpClientError> {
    let (conn, fingerprint) = connect(p, creds).await?;
    let dir = conn
        .sftp
        .read_dir(p.export.as_str())
        .await
        .map_err(|e| SftpClientError::Sftp(e.to_string()))?;

    let mut entries = Vec::new();
    for entry in dir {
        let name = entry.file_name();
        if name == "." || name == ".." {
            continue;
        }
        let md = entry.metadata();
        if md.is_dir() {
            continue;
        }
        if !name.to_ascii_lowercase().ends_with(".iso") {
            continue;
        }
        entries.push(SftpListEntry {
            filename: name,
            size: md.len(),
        });
    }
    // `conn` drops here, closing the SSH session.
    Ok((entries, fingerprint))
}

async fn stream_loop(
    p: &ConnParams,
    creds: &SftpCreds,
    filename: &str,
    start_offset: u64,
    max_len: Option<u64>,
    tx: tokio::sync::mpsc::Sender<std::io::Result<Bytes>>,
) -> std::result::Result<(), SftpClientError> {
    let (conn, _fp) = connect(p, creds).await?;
    let full = format!("{}/{}", p.export.trim_end_matches('/'), filename);
    let mut file = conn
        .sftp
        .open(full)
        .await
        .map_err(|e| SftpClientError::Sftp(e.to_string()))?;
    if start_offset > 0 {
        file.seek(SeekFrom::Start(start_offset))
            .await
            .map_err(|e| SftpClientError::Io(e.to_string()))?;
    }

    let mut remaining = max_len;
    let mut buf = vec![0u8; READ_CHUNK_BYTES];
    loop {
        if remaining == Some(0) {
            break;
        }
        let want = match remaining {
            Some(r) if (r as usize) < READ_CHUNK_BYTES => r as usize,
            _ => READ_CHUNK_BYTES,
        };
        let n = file
            .read(&mut buf[..want])
            .await
            .map_err(|e| SftpClientError::Io(e.to_string()))?;
        if n == 0 {
            break; // EOF
        }
        let bytes = Bytes::copy_from_slice(&buf[..n]);
        if tx.send(Ok(bytes)).await.is_err() {
            break; // client disconnected
        }
        if let Some(r) = remaining.as_mut() {
            *r = r.saturating_sub(n as u64);
        }
    }
    // `conn` drops here.
    Ok(())
}

/// Pre-flight TCP probe to `server:port`. Mirrors the SMB 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 SSH port: {addr}: {e}"),
            format!("verify the SSH service is running on {server} and that port {port} is open"),
        )),
        Err(_) => Err((
            format!(
                "cannot reach SSH 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()
            ),
        )),
    }
}

#[derive(Debug)]
enum SftpClientError {
    Connect(String),
    Timeout(String, u16),
    Auth(String),
    AuthRejected,
    HostKeyMismatch { expected: String, got: String },
    Protocol(String),
    Sftp(String),
    Io(String),
}

impl std::fmt::Display for SftpClientError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Connect(msg) => write!(f, "connect failed: {msg}"),
            Self::Timeout(host, port) => write!(
                f,
                "connect timed out after {}s talking to {host}:{port}",
                CONNECT_TIMEOUT.as_secs()
            ),
            Self::Auth(msg) => write!(f, "authentication error: {msg}"),
            Self::AuthRejected => write!(f, "the server rejected the credentials"),
            Self::HostKeyMismatch { expected, got } => {
                write!(f, "SSH host key changed (expected {expected}, got {got})")
            }
            Self::Protocol(msg) => write!(f, "SSH channel error: {msg}"),
            Self::Sftp(msg) => write!(f, "SFTP error: {msg}"),
            Self::Io(msg) => write!(f, "read error: {msg}"),
        }
    }
}

impl std::error::Error for SftpClientError {}

/// Translate well-known SFTP/SSH failures into actionable hints.
fn hint_for(text: &str) -> Option<String> {
    let s = text.to_ascii_lowercase();
    if s.contains("host key changed") || s.contains("host key mismatch") {
        Some(
            "the server's SSH host key is different from the one pinned when \
             this share was added. Either the server was rebuilt / its host \
             key rotated, or this is a man-in-the-middle. If the change is \
             expected, remove and re-add the share to trust the new key."
                .into(),
        )
    } else if s.contains("rejected the credentials")
        || s.contains("authentication error")
        || s.contains("auth")
    {
        Some(
            "SSH authentication failed. Double-check the username and the \
             password / private key. For key auth, make sure you pasted the \
             PRIVATE key (the file without .pub), that its passphrase is \
             correct, and that the matching public key is in the server's \
             ~/.ssh/authorized_keys for this user."
                .into(),
        )
    } else if s.contains("no such file") || s.contains("does not exist") || s.contains("no such") {
        Some(
            "the export directory or ISO wasn't found on the server. Check \
             the export path (absolute, e.g. /srv/isos) and that the SSH user \
             can list it."
                .into(),
        )
    } else if s.contains("permission denied") {
        Some(
            "authenticated, but the SSH user can't read the export directory \
             or file. Check the directory's permissions on the server."
                .into(),
        )
    } else if s.contains("connection refused") {
        Some(
            "nothing is listening on this SSH port. Verify sshd is running on \
             the server and that the port (default 22) is correct."
                .into(),
        )
    } else if s.contains("timed out") || s.contains("timeout") {
        Some(
            "no answer from the server within the connect timeout. Verify the \
             IP, the port (default 22), and any firewall in between."
                .into(),
        )
    } else if s.contains("no route to host") || s.contains("network is unreachable") {
        Some("the server isn't reachable on this network. Check the IP and routes.".into())
    } else {
        None
    }
}

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

/// Normalize a server input: trim, strip schemes, drop trailing slashes.
/// Mirrors the NFS/SMB normalizers so paste-from-anywhere works.
fn normalize_server(raw: &str) -> String {
    let s = raw.trim();
    let s = s
        .strip_prefix("sftp://")
        .or_else(|| s.strip_prefix("ssh://"))
        .unwrap_or(s);
    s.trim_end_matches('/').to_string()
}

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

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

    #[test]
    fn normalize_server_strips_schemes() {
        assert_eq!(normalize_server("  10.0.0.5  "), "10.0.0.5");
        assert_eq!(normalize_server("sftp://nas.lan/"), "nas.lan");
        assert_eq!(normalize_server("ssh://192.168.1.51"), "192.168.1.51");
        assert_eq!(normalize_server("nas.lan"), "nas.lan");
    }

    #[test]
    fn hint_for_auth_failure_mentions_credentials() {
        let h = hint_for("authentication error: bad password").unwrap();
        let lc = h.to_lowercase();
        assert!(lc.contains("username") || lc.contains("password") || lc.contains("key"));
    }

    #[test]
    fn hint_for_host_key_mismatch_warns_about_mitm_and_readd() {
        let h = hint_for("SSH host key changed (expected SHA256:a, got SHA256:b)").unwrap();
        let lc = h.to_lowercase();
        assert!(lc.contains("host key"));
        assert!(lc.contains("re-add") || lc.contains("man-in-the-middle"));
    }

    #[test]
    fn hint_for_refused_points_at_sshd() {
        let h = hint_for("connect failed: Connection refused").unwrap();
        assert!(h.to_lowercase().contains("sshd") || h.to_lowercase().contains("listening"));
    }

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

    #[tokio::test]
    async fn add_requires_username() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().join("isos"));
        let mgr = SftpShareManager::new(dir.path(), store);
        let err = mgr
            .add(SftpAddRequest {
                server: "10.0.0.5".into(),
                export: "/srv/isos".into(),
                username: None,
                port: None,
                password: Some("pw".into()),
                private_key: None,
                passphrase: None,
            })
            .await
            .unwrap_err();
        assert!(
            err.error.to_lowercase().contains("username"),
            "{}",
            err.error
        );
    }

    #[tokio::test]
    async fn add_requires_a_secret() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().join("isos"));
        let mgr = SftpShareManager::new(dir.path(), store);
        let err = mgr
            .add(SftpAddRequest {
                server: "10.0.0.5".into(),
                export: "/srv/isos".into(),
                username: Some("root".into()),
                port: None,
                password: None,
                private_key: None,
                passphrase: None,
            })
            .await
            .unwrap_err();
        let lc = err.error.to_lowercase();
        assert!(
            lc.contains("password") || lc.contains("private key"),
            "{}",
            err.error
        );
    }

    #[tokio::test]
    async fn add_rejects_both_secrets() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().join("isos"));
        let mgr = SftpShareManager::new(dir.path(), store);
        let err = mgr
            .add(SftpAddRequest {
                server: "10.0.0.5".into(),
                export: "/srv/isos".into(),
                username: Some("root".into()),
                port: None,
                password: Some("pw".into()),
                private_key: Some("-----BEGIN OPENSSH PRIVATE KEY-----".into()),
                passphrase: None,
            })
            .await
            .unwrap_err();
        assert!(
            err.error.to_lowercase().contains("not both"),
            "{}",
            err.error
        );
    }

    #[tokio::test]
    async fn add_requires_absolute_export() {
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().join("isos"));
        let mgr = SftpShareManager::new(dir.path(), store);
        let err = mgr
            .add(SftpAddRequest {
                server: "10.0.0.5".into(),
                export: "relative/path".into(),
                username: Some("root".into()),
                port: None,
                password: Some("pw".into()),
                private_key: None,
                passphrase: None,
            })
            .await
            .unwrap_err();
        assert!(
            err.error.to_lowercase().contains("absolute"),
            "{}",
            err.error
        );
    }

    #[tokio::test]
    async fn creds_round_trip_0600() {
        use std::os::unix::fs::PermissionsExt;
        let dir = tempdir().unwrap();
        let store = IsoStore::new(dir.path().join("isos"));
        let mgr = SftpShareManager::new(dir.path(), store);
        let creds = SftpCreds {
            password: Some("hunter2".into()),
            private_key: None,
            passphrase: None,
        };
        mgr.write_creds("share1", &creds).await.unwrap();
        let read = mgr.read_creds("share1").await.unwrap();
        assert_eq!(read.password.as_deref(), Some("hunter2"));
        // The secret file must be 0600.
        let meta = std::fs::metadata(mgr.creds_path_for("share1")).unwrap();
        assert_eq!(meta.permissions().mode() & 0o777, 0o600);
    }
}
