//! Jailed wipe engine for Rust (and compatible) game server instances.
//!
//! Three wipe types are supported, each a strict superset of the previous:
//!
//! | Type        | What is deleted                                                  |
//! |-------------|------------------------------------------------------------------|
//! | `map`       | `*.map`, `*.sav` under `<root>/server/<identity>/`               |
//! | `blueprint` | map wipe + `*.blueprints.*.db` / `.blueprints.*` under save dir  |
//! | `full`      | blueprint wipe + `oxide/data/` contents + player state DB files  |
//!
//! Identity discovery: rather than require the identity in the payload, we walk
//! `<root>/server/*/` looking for files that match each wipe type's patterns.
//! This handles any identity name without configuration churn.
//!
//! **Safety**: every path operated on is validated inside the canonicalized
//! instance root with the same two-stage (lexical + canonicalize) jail used by
//! `filemanager.rs`.  We use `symlink_metadata` (lstat) everywhere we walk
//! directories — symlinks are never followed across the boundary (Lesson 26).

use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};

use crate::filemanager::jail;

// ---------------------------------------------------------------------------
// Public API types
// ---------------------------------------------------------------------------

/// The scope of data to erase.
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WipeType {
    /// Delete procedural map + save files only.
    Map,
    /// Map wipe + player blueprint databases.
    Blueprint,
    /// Blueprint wipe + oxide/data + all player state DBs.
    Full,
}

/// Parameters parsed from the NATS command payload.
#[derive(Debug, serde::Deserialize)]
pub struct WipeRequest {
    /// Scope of the wipe.
    pub wipe_type: WipeType,
    /// Copy files to `.corrosion-backups/<backup_label>/` before deleting.
    #[serde(default)]
    pub backup: bool,
    /// Label used as the backup subdirectory name.  Defaults to `"wipe-backup"`.
    #[serde(default = "default_backup_label")]
    pub backup_label: String,
}

fn default_backup_label() -> String {
    "wipe-backup".to_string()
}

/// Result of a successful wipe operation.
#[derive(Debug)]
pub struct WipeResult {
    pub deleted_count: usize,
    pub wipe_type: WipeType,
}

// ---------------------------------------------------------------------------
// Core wipe logic  (sync — suitable for `spawn_blocking`)
// ---------------------------------------------------------------------------

/// Execute a wipe of `wipe_type` inside `root`, optionally backing up first.
///
/// Does NOT touch the supervisor lifecycle — the caller (instancecmd dispatch)
/// must stop the server before calling this and restart it afterwards.
///
/// Returns a `WipeResult` describing what was deleted.  Missing directories are
/// treated as zero-deleted, not as errors, so a fresh server never returns Err
/// just because `server/*/` doesn't exist yet.
pub fn execute(root: &Path, req: &WipeRequest) -> Result<WipeResult> {
    // Canonicalize root once; every subsequent path check goes through `jail()`.
    let canon_root = fs::canonicalize(root)
        .with_context(|| format!("canonicalize instance root '{}'", root.display()))?;

    // Collect every path to delete based on wipe type.
    let targets = collect_targets(&canon_root, &req.wipe_type)?;

    // Backup before any deletion when requested.
    if req.backup && !targets.is_empty() {
        let backup_dir = jail(root, &format!(".corrosion-backups/{}", req.backup_label))?;
        fs::create_dir_all(&backup_dir)
            .with_context(|| format!("create backup dir '{}'", backup_dir.display()))?;
        for path in &targets {
            backup_one(&canon_root, path, &backup_dir)?;
        }
    }

    // Delete.
    let mut deleted_count = 0usize;
    for path in &targets {
        // Final safety check: confirm inside root before deletion.
        if path != &canon_root && !path.starts_with(&canon_root) {
            anyhow::bail!(
                "wipe safety: path '{}' is outside instance root '{}' — aborting",
                path.display(),
                canon_root.display()
            );
        }
        match delete_path(path) {
            Ok(n) => deleted_count += n,
            Err(e) => tracing::warn!("wipe: skipping '{}': {e:#}", path.display()),
        }
    }

    tracing::info!(
        "wipe complete: type={:?} deleted={} root={}",
        req.wipe_type,
        deleted_count,
        root.display()
    );

    Ok(WipeResult {
        deleted_count,
        wipe_type: req.wipe_type.clone(),
    })
}

// ---------------------------------------------------------------------------
// Target collection
// ---------------------------------------------------------------------------

/// Walk the Rust server tree under `canon_root` and return every path (file or
/// dir) that should be deleted for the given wipe type.
///
/// Layout assumed:
/// ```text
/// <root>/
///   server/
///     <identity>/         -- any name; we walk all subdirs
///       *.map
///       *.sav
///       player.blueprints.*.db  (and *.blueprints.* variants)
///       player.deaths.*.db
///       player.identities.*.db
///       player.states.*.db
///       *.db                    (full wipe)
///   oxide/
///     data/               -- cleared for full wipe (dir contents, not dir itself)
/// ```
fn collect_targets(canon_root: &Path, wipe_type: &WipeType) -> Result<Vec<PathBuf>> {
    let mut targets: Vec<PathBuf> = Vec::new();

    // --- server/<identity>/ ---
    let server_dir = canon_root.join("server");
    if is_real_dir(&server_dir) {
        for identity_entry in read_dir_safe(&server_dir)? {
            let identity_meta = fs::symlink_metadata(&identity_entry)
                .with_context(|| format!("stat '{}'", identity_entry.display()))?;

            // Never follow symlinks across the boundary.
            if identity_meta.file_type().is_symlink() {
                tracing::debug!("wipe: skipping symlink '{}'", identity_entry.display());
                continue;
            }

            if !identity_meta.is_dir() {
                continue;
            }

            collect_save_targets(canon_root, &identity_entry, wipe_type, &mut targets)?;
        }
    }

    // --- oxide/data/ (full wipe only) ---
    if *wipe_type == WipeType::Full {
        let oxide_data = canon_root.join("oxide").join("data");
        if is_real_dir(&oxide_data) {
            // Delete directory *contents*, not the directory itself.
            for entry in read_dir_safe(&oxide_data)? {
                let meta = fs::symlink_metadata(&entry)
                    .with_context(|| format!("stat '{}'", entry.display()))?;
                if meta.file_type().is_symlink() {
                    tracing::debug!("wipe: skipping symlink '{}'", entry.display());
                    continue;
                }
                // Jail-check every entry before adding.
                ensure_inside(canon_root, &entry)?;
                targets.push(entry);
            }
        }
    }

    Ok(targets)
}

/// Collect files from one `<root>/server/<identity>/` directory.
fn collect_save_targets(
    canon_root: &Path,
    identity_dir: &Path,
    wipe_type: &WipeType,
    out: &mut Vec<PathBuf>,
) -> Result<()> {
    for entry in read_dir_safe(identity_dir)? {
        let meta = fs::symlink_metadata(&entry)
            .with_context(|| format!("stat '{}'", entry.display()))?;

        // Never follow symlinks.
        if meta.file_type().is_symlink() {
            tracing::debug!("wipe: skipping symlink '{}'", entry.display());
            continue;
        }

        ensure_inside(canon_root, &entry)?;

        let file_name = entry
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();

        let keep = match wipe_type {
            WipeType::Map => !is_map_file(&file_name) && !is_sav_file(&file_name),
            WipeType::Blueprint => {
                !is_map_file(&file_name)
                    && !is_sav_file(&file_name)
                    && !is_blueprint_file(&file_name)
            }
            WipeType::Full => {
                !is_map_file(&file_name)
                    && !is_sav_file(&file_name)
                    && !is_blueprint_file(&file_name)
                    && !is_player_state_file(&file_name)
                    && !is_generic_db_file(&file_name)
            }
        };

        if !keep {
            out.push(entry);
        }
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Pattern matchers
// ---------------------------------------------------------------------------

fn is_map_file(name: &str) -> bool {
    name.ends_with(".map")
}

fn is_sav_file(name: &str) -> bool {
    name.ends_with(".sav")
}

fn is_blueprint_file(name: &str) -> bool {
    // Matches both `player.blueprints.*.db` and `.blueprints.*` variants.
    name.contains(".blueprints.")
}

fn is_player_state_file(name: &str) -> bool {
    name.contains("player.deaths.")
        || name.contains("player.identities.")
        || name.contains("player.states.")
}

fn is_generic_db_file(name: &str) -> bool {
    name.ends_with(".db")
}

// ---------------------------------------------------------------------------
// Deletion
// ---------------------------------------------------------------------------

/// Delete a single path (file or directory tree). Returns count of top-level
/// items removed (1 for a file, 1 for a directory tree).  Missing paths return
/// 0 — the server may be fresh.
fn delete_path(path: &Path) -> Result<usize> {
    let meta = match fs::symlink_metadata(path) {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
        Err(e) => return Err(e).with_context(|| format!("stat '{}'", path.display())),
    };

    if meta.file_type().is_symlink() {
        // Delete the symlink itself — never follow it.
        fs::remove_file(path).with_context(|| format!("remove symlink '{}'", path.display()))?;
        return Ok(1);
    }

    if meta.is_dir() {
        fs::remove_dir_all(path)
            .with_context(|| format!("remove_dir_all '{}'", path.display()))?;
    } else {
        fs::remove_file(path)
            .with_context(|| format!("remove_file '{}'", path.display()))?;
    }
    Ok(1)
}

// ---------------------------------------------------------------------------
// Backup
// ---------------------------------------------------------------------------

/// Copy one path (file or directory) into `backup_dir`, preserving the last
/// component of the path name.  Symlinks are skipped — we never follow them.
fn backup_one(canon_root: &Path, src: &Path, backup_dir: &Path) -> Result<()> {
    let meta = match fs::symlink_metadata(src) {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e).with_context(|| format!("stat backup src '{}'", src.display())),
    };

    if meta.file_type().is_symlink() {
        tracing::debug!("wipe backup: skipping symlink '{}'", src.display());
        return Ok(());
    }

    let name = match src.file_name() {
        Some(n) => n,
        None => return Ok(()),
    };

    // Preserve relative path from root inside the backup directory to avoid
    // name collisions when multiple identity dirs have a `proc.map`.
    let rel = src
        .strip_prefix(canon_root)
        .unwrap_or_else(|_| src)
        .parent()
        .unwrap_or_else(|| Path::new(""));
    let dest = backup_dir.join(rel).join(name);

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

    copy_recursive_safe(src, &dest)?;
    Ok(())
}

/// Recursive copy that uses `symlink_metadata` (lstat) and refuses to follow
/// any symlink — mirrors the same guard in `filemanager::copy_recursive`.
fn copy_recursive_safe(src: &Path, dest: &Path) -> Result<()> {
    let meta = fs::symlink_metadata(src)
        .with_context(|| format!("stat source '{}'", src.display()))?;

    if meta.file_type().is_symlink() {
        anyhow::bail!(
            "refusing to copy symlink '{}' during backup — symlinks are not followed",
            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_safe(&entry.path(), &dest.join(entry.file_name()))?;
        }
    } else {
        fs::copy(src, dest)
            .with_context(|| format!("copy '{}' -> '{}'", src.display(), dest.display()))?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Returns `true` if `path` exists, is a directory, and is not a symlink.
fn is_real_dir(path: &Path) -> bool {
    match fs::symlink_metadata(path) {
        Ok(m) => m.is_dir() && !m.file_type().is_symlink(),
        Err(_) => false,
    }
}

/// Read a directory and return the absolute paths of its entries.
/// Uses lstat internally via `read_dir` (entry paths; metadata is lstat'd
/// separately by callers).
fn read_dir_safe(dir: &Path) -> Result<Vec<PathBuf>> {
    let mut entries = Vec::new();
    let rd = match fs::read_dir(dir) {
        Ok(rd) => rd,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
        Err(e) => return Err(e).with_context(|| format!("read_dir '{}'", dir.display())),
    };
    for item in rd {
        let item = item.with_context(|| format!("read dir entry in '{}'", dir.display()))?;
        entries.push(item.path());
    }
    Ok(entries)
}

/// Assert that `path` is strictly inside (or equal to) `canon_root`.
/// This is the final safety fence before any destructive or backup operation.
fn ensure_inside(canon_root: &Path, path: &Path) -> Result<()> {
    // Canonicalize the path if it exists; otherwise use it as-is (it's
    // derived from read_dir, which already returns absolute paths rooted
    // under canon_root in normal operation).
    let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    if resolved != canon_root && !resolved.starts_with(canon_root) {
        anyhow::bail!(
            "wipe safety: path '{}' is outside instance root '{}' — aborting",
            path.display(),
            canon_root.display()
        );
    }
    Ok(())
}
