//! Jailed file manager for game-server install directories.
//!
//! Every path operation is confined to the instance `root` — the directory
//! declared as `root` in `[[instance]]` config.  A two-stage check (lexical
//! Clean + `std::fs::canonicalize`) prevents both `../..` traversals and
//! symlink-based escapes: even if an attacker plants a symlink inside the root
//! that points outside it, `canonicalize` resolves the target and the prefix
//! check catches the escape.
//!
//! The NATS request/reply contract mirrors the Go companion agent's jailed file
//! manager (see `companion-agent/internal/filemanager/`) but uses a simpler
//! flat JSON envelope rather than the VueFinder storage-path protocol — the
//! Rust agent is the replacement, and the panel's backend talks to whichever
//! agent is present.
//!
//! Subject: `corrosion.{license}.{instance}.files.cmd`
//! Request:  `{"op":"list"|"read"|"write"|"delete"|"rename"|"mkdir"|"mkfile"|"move"|"copy",
//!             "path":"rel/path", "dest"?:"...", "content"?:"...", "name"?:"..."}`
//! Response: `{"status":"success","data":...}` or `{"status":"error","message":"..."}`

use anyhow::{bail, Context};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

/// Maximum size for a `read` operation (5 MiB).  Larger files must be
/// transferred through a dedicated download endpoint, not the file manager.
const MAX_READ_SIZE: u64 = 5 * 1024 * 1024;

// ---------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct FileRequest {
    pub op: String,
    /// Relative path within the instance root (the "subject" of the operation).
    #[serde(default)]
    pub path: String,
    /// Destination for `rename`, `move`, `copy` — relative to instance root.
    #[serde(default)]
    pub dest: Option<String>,
    /// Text content for `write`.
    #[serde(default)]
    pub content: Option<String>,
    /// Bare filename for `mkdir` and `mkfile`.
    #[serde(default)]
    pub name: Option<String>,
}

/// A single directory entry returned by `list`.
#[derive(Debug, Serialize)]
pub struct FileEntry {
    pub name: String,
    /// Path relative to the instance root, using forward slashes.
    pub path: String,
    pub is_dir: bool,
    /// File size in bytes.  Zero for directories.
    pub size: u64,
    /// RFC 3339 modification timestamp.
    pub modified: String,
}

// ---------------------------------------------------------------------------
// Jail helper — the security core of this module
// ---------------------------------------------------------------------------

/// Resolve `rel` against `root`, then canonicalize to reject any form of
/// escape including `../..` traversals and symlinks that point outside root.
///
/// For paths that do not yet exist (e.g. write targets), we canonicalize the
/// nearest existing ancestor and then re-join the remaining components, which
/// are lexically-clean because they went through `std::path::Path` building.
///
/// Returns the absolute, canonicalized path if it is within `root`.
pub fn jail(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
    // Canonicalize root once to get a stable prefix for comparison.
    // We do this on every call rather than caching so the function stays
    // pure and testable without Agent state.
    let canon_root = fs::canonicalize(root)
        .with_context(|| format!("canonicalize instance root '{}'", root.display()))?;

    // Build the candidate absolute path.  We use Path joining so that an
    // absolute `rel` (e.g. "/etc/passwd") replaces the root entirely — we
    // detect and reject that case immediately.
    let candidate = if rel.is_empty() || rel == "." {
        root.to_path_buf()
    } else {
        let rel_path = Path::new(rel);
        if rel_path.is_absolute() {
            bail!(
                "absolute path '{}' is not allowed; supply a path relative to the instance root",
                rel
            );
        }
        root.join(rel_path)
    };

    // Normalize lexically first (removes `..` / `.` without filesystem access).
    // This is a defence-in-depth step; the authoritative check is below.
    let lexical = normalize_lexical(&candidate);

    // Canonicalize: resolve symlinks and `..` via the kernel.
    // For a not-yet-existing path we walk up to the nearest existing ancestor.
    let canon = canonicalize_lenient(&lexical)?;

    // Authoritative prefix check: the resolved path must be equal to or a
    // child of the canonicalized root.
    if canon != canon_root && !canon.starts_with(&canon_root) {
        bail!(
            "path '{}' resolves to '{}' which is outside the instance root '{}'",
            rel,
            canon.display(),
            canon_root.display()
        );
    }

    Ok(canon)
}

/// Canonicalize a path that may not fully exist yet by walking up to the
/// nearest existing ancestor, canonicalizing it, then re-joining the remaining
/// (lexically-clean) suffix.
fn canonicalize_lenient(path: &Path) -> anyhow::Result<PathBuf> {
    // Fast path: path already exists.
    if let Ok(c) = fs::canonicalize(path) {
        return Ok(c);
    }

    // Walk up until we find an ancestor that exists.
    let mut existing = path.to_path_buf();
    let mut suffix: Vec<std::ffi::OsString> = Vec::new();

    loop {
        match fs::canonicalize(&existing) {
            Ok(canon) => {
                // Re-attach the non-existing suffix.
                let mut result = canon;
                for component in suffix.iter().rev() {
                    result = result.join(component);
                }
                return Ok(result);
            }
            Err(_) => {
                let file_name = match existing.file_name() {
                    Some(n) => n.to_os_string(),
                    None => bail!("cannot resolve path '{}'", path.display()),
                };
                suffix.push(file_name);
                existing = match existing.parent() {
                    Some(p) => p.to_path_buf(),
                    None => bail!("cannot resolve path '{}'", path.display()),
                };
            }
        }
    }
}

/// Lexically normalize a path (remove `.` and `..` components) without
/// touching the filesystem.  This mirrors `filepath.Clean` in Go.
fn normalize_lexical(path: &Path) -> PathBuf {
    let mut components: Vec<std::path::Component> = Vec::new();
    for component in path.components() {
        match component {
            std::path::Component::CurDir => {}
            std::path::Component::ParentDir => {
                // Only pop a normal component — we cannot pop a root prefix.
                if matches!(components.last(), Some(std::path::Component::Normal(_))) {
                    components.pop();
                } else {
                    components.push(component);
                }
            }
            other => components.push(other),
        }
    }
    components.iter().collect()
}

// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------

/// List the contents of a directory.  Returns an entry per item, sorted
/// (directories first, then files, both alphabetical).
pub fn list(root: &Path, rel: &str) -> anyhow::Result<Vec<FileEntry>> {
    let abs = jail(root, rel)?;
    // Use the canonicalized root as the prefix for relative path computation so
    // that symlinked root paths (e.g. macOS /var → /private/var) don't cause
    // strip_prefix to fail and fall back to leaking the absolute path.
    let canon_root = fs::canonicalize(root)
        .with_context(|| format!("canonicalize root '{}'", root.display()))?;

    let rd = fs::read_dir(&abs)
        .with_context(|| format!("read_dir '{}'", abs.display()))?;

    let mut entries: Vec<FileEntry> = Vec::new();
    for item in rd {
        let item = item.with_context(|| format!("reading directory entry in '{}'", abs.display()))?;
        // symlink_metadata (lstat): report the link itself, never the target —
        // following it would leak the size/type/existence of files outside the
        // jail. A symlink lists as a zero-ish-size non-dir entry.
        let meta = fs::symlink_metadata(item.path())
            .with_context(|| format!("stat '{}'", item.path().display()))?;

        let name = item.file_name().to_string_lossy().into_owned();
        let is_dir = meta.is_dir();
        let size = if is_dir { 0 } else { meta.len() };

        // Build the relative path from the canonicalized root.
        let entry_abs = item.path();
        let entry_rel = entry_abs
            .strip_prefix(&canon_root)
            .unwrap_or(&entry_abs)
            .to_string_lossy()
            .replace('\\', "/");

        let modified = meta
            .modified()
            .ok()
            .map(|t| {
                let dt: DateTime<Utc> = t.into();
                dt.to_rfc3339_opts(SecondsFormat::Secs, true)
            })
            .unwrap_or_default();

        entries.push(FileEntry { name, path: entry_rel, is_dir, size, modified });
    }

    // Stable sort: dirs first, then alphabetical within each group.
    entries.sort_by(|a, b| {
        b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))
    });

    Ok(entries)
}

/// Read a text file.  Capped at `MAX_READ_SIZE` bytes.
pub fn read(root: &Path, rel: &str) -> anyhow::Result<String> {
    let abs = jail(root, rel)?;

    let meta = fs::metadata(&abs)
        .with_context(|| format!("stat '{}'", abs.display()))?;

    if meta.is_dir() {
        bail!("'{}' is a directory, not a file", rel);
    }
    if meta.len() > MAX_READ_SIZE {
        bail!(
            "file '{}' is {} bytes which exceeds the {} byte read limit",
            rel,
            meta.len(),
            MAX_READ_SIZE
        );
    }

    fs::read_to_string(&abs).with_context(|| format!("read '{}'", abs.display()))
}

/// Write (create or overwrite) a file.  Parent directories are created as
/// needed.
pub fn write(root: &Path, rel: &str, content: &str) -> anyhow::Result<()> {
    let abs = jail(root, rel)?;

    if let Some(parent) = abs.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create_dir_all '{}'", parent.display()))?;
    }

    fs::write(&abs, content.as_bytes())
        .with_context(|| format!("write '{}'", abs.display()))
}

/// Delete a file or directory tree.
pub fn delete(root: &Path, rel: &str) -> anyhow::Result<()> {
    let abs = jail(root, rel)?;

    let meta = fs::metadata(&abs)
        .with_context(|| format!("stat '{}'", abs.display()))?;

    if meta.is_dir() {
        fs::remove_dir_all(&abs).with_context(|| format!("remove_dir_all '{}'", abs.display()))
    } else {
        fs::remove_file(&abs).with_context(|| format!("remove_file '{}'", abs.display()))
    }
}

/// Rename/move `rel` to a new bare name (`new_name`) within the same parent.
/// `new_name` must not contain path separators.
pub fn rename(root: &Path, rel: &str, new_name: &str) -> anyhow::Result<()> {
    if new_name.is_empty() || new_name == "." || new_name == ".." {
        bail!("new_name '{}' is not a valid filename", new_name);
    }
    if new_name.contains('/') || new_name.contains('\\') {
        bail!("new_name '{}' must not contain path separators", new_name);
    }

    let src_abs = jail(root, rel)?;

    // Construct the destination relative path by replacing the filename part
    // of `rel` with `new_name`.  This keeps everything in relative-path space
    // so we never hand an absolute path to `jail`.
    let src_rel = Path::new(rel);
    let dest_rel = match src_rel.parent() {
        Some(parent) if parent != Path::new("") => {
            parent.join(new_name).to_string_lossy().replace('\\', "/")
        }
        _ => new_name.to_string(),
    };

    let dest_abs = jail(root, &dest_rel)?;

    fs::rename(&src_abs, &dest_abs)
        .with_context(|| format!("rename '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}

/// Create a directory (and any missing parents) at `rel`.
pub fn mkdir(root: &Path, rel: &str) -> anyhow::Result<()> {
    let abs = jail(root, rel)?;
    fs::create_dir_all(&abs).with_context(|| format!("mkdir '{}'", abs.display()))
}

/// Create an empty file at `rel`.  Fails if it already exists.
pub fn mkfile(root: &Path, rel: &str) -> anyhow::Result<()> {
    let abs = jail(root, rel)?;

    if let Some(parent) = abs.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create_dir_all '{}'", parent.display()))?;
    }

    let _ = std::fs::OpenOptions::new()
        .create_new(true)
        .write(true)
        .open(&abs)
        .with_context(|| format!("mkfile '{}'", abs.display()))?;

    Ok(())
}

/// Move `src` to `dest` (both relative to root).
pub fn move_path(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
    let src_abs = jail(root, src)?;
    let dest_abs = jail(root, dest)?;

    if let Some(parent) = dest_abs.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create_dir_all '{}'", parent.display()))?;
    }

    fs::rename(&src_abs, &dest_abs).or_else(|_| {
        // Cross-device move: copy then delete.
        copy_recursive(&src_abs, &dest_abs)?;
        fs::remove_dir_all(&src_abs)
            .with_context(|| format!("remove source '{}' after cross-device move", src_abs.display()))
    }).with_context(|| format!("move '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}

/// Copy `src` to `dest` (both relative to root).
pub fn copy(root: &Path, src: &str, dest: &str) -> anyhow::Result<()> {
    let src_abs = jail(root, src)?;
    let dest_abs = jail(root, dest)?;

    if let Some(parent) = dest_abs.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create_dir_all '{}'", parent.display()))?;
    }

    copy_recursive(&src_abs, &dest_abs)
        .with_context(|| format!("copy '{}' -> '{}'", src_abs.display(), dest_abs.display()))
}

/// Recursive copy helper.
///
/// SECURITY: uses `symlink_metadata` (does NOT follow symlinks) and refuses to
/// copy any symlink. `jail()` only validates the top-level src/dest; a symlink
/// *inside* a copied directory that points outside the jail would, if followed,
/// pull external content (e.g. `/etc`) into the jail where it could then be
/// read — a jail-escape exfiltration. Refusing symlinks closes that path.
fn copy_recursive(src: &Path, dest: &Path) -> anyhow::Result<()> {
    let meta = fs::symlink_metadata(src)
        .with_context(|| format!("stat source '{}'", src.display()))?;

    if meta.file_type().is_symlink() {
        bail!(
            "refusing to copy symlink '{}' — symlinks are not followed across the jail boundary",
            src.display()
        );
    }

    if meta.is_dir() {
        fs::create_dir_all(dest)
            .with_context(|| format!("create_dir_all '{}'", dest.display()))?;

        for entry in fs::read_dir(src)
            .with_context(|| format!("read_dir '{}'", src.display()))?
        {
            let entry = entry?;
            copy_recursive(&entry.path(), &dest.join(entry.file_name()))?;
        }
    } else {
        fs::copy(src, dest)
            .with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// NATS request dispatch
// ---------------------------------------------------------------------------

/// Dispatch a `FileRequest` against `root` and return a JSON `serde_json::Value`
/// ready for the NATS reply.
pub fn dispatch(root: &Path, req: &FileRequest) -> serde_json::Value {
    use serde_json::json;

    let result = match req.op.as_str() {
        "list" => {
            list(root, &req.path).map(|entries| json!({ "entries": entries }))
        }
        "read" => {
            read(root, &req.path).map(|content| json!({ "content": content }))
        }
        "write" => {
            let content = req.content.as_deref().unwrap_or("");
            write(root, &req.path, content).map(|_| json!(null))
        }
        "delete" => {
            delete(root, &req.path).map(|_| json!(null))
        }
        "rename" => {
            let new_name = req.name.as_deref().unwrap_or("");
            rename(root, &req.path, new_name).map(|_| json!(null))
        }
        "mkdir" => {
            mkdir(root, &req.path).map(|_| json!(null))
        }
        "mkfile" => {
            mkfile(root, &req.path).map(|_| json!(null))
        }
        "move" => {
            let dest = req.dest.as_deref().unwrap_or("");
            move_path(root, &req.path, dest).map(|_| json!(null))
        }
        "copy" => {
            let dest = req.dest.as_deref().unwrap_or("");
            copy(root, &req.path, dest).map(|_| json!(null))
        }
        other => Err(anyhow::anyhow!(
            "unknown op '{}' (supported: list, read, write, delete, rename, mkdir, mkfile, move, copy)",
            other
        )),
    };

    match result {
        Ok(data) => json!({ "status": "success", "data": data }),
        Err(e) => {
            tracing::warn!("filemanager op='{}' path='{}': {e:#}", req.op, req.path);
            json!({ "status": "error", "message": format!("{e:#}") })
        }
    }
}

/// Subscribe to `corrosion.{license}.{instance}.files.cmd` and serve file
/// manager requests for `instance_id` jailed to `root`.
///
/// This function runs until the agent's cancellation token fires or the NATS
/// subscription ends.  It is spawned once per instance in `main.rs`.
pub async fn run(
    agent: std::sync::Arc<crate::agent::Agent>,
    instance_id: String,
    root: PathBuf,
) -> anyhow::Result<()> {
    use futures::StreamExt;

    let subject = crate::subjects::instance_files_cmd(&agent.cfg.license_id, &instance_id);
    let mut sub = agent.nats.subscribe(subject.clone()).await?;
    tracing::info!("file manager handler listening on {subject}");

    let cancel = agent.shutdown.clone();
    loop {
        tokio::select! {
            msg = sub.next() => {
                match msg {
                    Some(msg) => {
                        let agent = agent.clone();
                        let root = root.clone();
                        let instance_id = instance_id.clone();
                        tokio::spawn(async move { handle(agent, &instance_id, &root, msg).await });
                    }
                    None => {
                        tracing::warn!("file manager subscription ended for '{instance_id}'");
                        break;
                    }
                }
            }
            _ = cancel.cancelled() => {
                tracing::info!("file manager handler stopping for '{instance_id}'");
                break;
            }
        }
    }
    Ok(())
}

async fn handle(
    agent: std::sync::Arc<crate::agent::Agent>,
    instance_id: &str,
    root: &Path,
    msg: async_nats::Message,
) {
    let Some(reply) = msg.reply.clone() else {
        tracing::warn!("file manager message without reply subject ignored (instance '{instance_id}')");
        return;
    };

    let response = match serde_json::from_slice::<FileRequest>(&msg.payload) {
        Ok(req) => {
            // Blocking fs calls — offload from the async executor.
            let root = root.to_path_buf();
            tokio::task::spawn_blocking(move || dispatch(&root, &req))
                .await
                .unwrap_or_else(|e| {
                    serde_json::json!({ "status": "error", "message": format!("internal error: {e}") })
                })
        }
        Err(e) => {
            serde_json::json!({ "status": "error", "message": format!("invalid request payload: {e}") })
        }
    };

    let bytes = match serde_json::to_vec(&response) {
        Ok(b) => b,
        Err(e) => {
            tracing::error!("file manager response serialize failed: {e}");
            return;
        }
    };
    if let Err(e) = agent.nats.publish(reply, bytes.into()).await {
        tracing::warn!("file manager response publish failed: {e}");
    }
}
