//! The Project panel's file explorer — state and file/database operations.
//!
//! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/`
//! tree. This module holds everything that does **not** touch egui — the
//! navigation state, directory listing, and the create/rename/move/delete/
//! import operations — so the whole behavior layer is unit-testable and the
//! shell only renders it (`ShellTabViewer::assets_explorer`).
//!
//! Every operation goes through the [`AssetDatabase`] file ops
//! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is
//! registered, so an asset keeps its [`AssetUid`] — and every saved
//! `AssetRef` keeps resolving — across any reorganisation. Files the
//! database does not know (licenses, notes, …) fall back to plain
//! filesystem operations.

use std::path::{Path, PathBuf};

use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid};

/// The explorer's persistent UI state (lives on the `Shell`, survives frames).
#[derive(Default)]
pub struct ExplorerState {
    /// The folder being viewed, relative to `assets/` (`""` = the root).
    pub cwd: String,
    /// An in-progress rename, if any.
    pub rename: Option<RenameEdit>,
    /// The in-progress "New Folder" name, `Some` while the inline row shows.
    pub new_folder: Option<String>,
    /// One-shot: the next inline text field rendered requests focus (set when
    /// a rename / new-folder edit starts, taken by the first frame).
    pub focus_field: bool,
}

impl ExplorerState {
    /// Navigates to `cwd`, dropping any in-progress inline edits.
    pub fn navigate(&mut self, cwd: impl Into<String>) {
        self.cwd = cwd.into();
        self.rename = None;
        self.new_folder = None;
    }
}

/// An in-progress rename of one entry: what is being renamed + the buffer.
pub struct RenameEdit {
    /// The entry's current assets-relative path.
    pub rel: String,
    /// Whether it is a folder.
    pub is_dir: bool,
    /// The name being typed.
    pub buf: String,
}

/// One row of the explorer listing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    /// The leaf name shown in the panel.
    pub name: String,
    /// Assets-relative path (forward slashes).
    pub rel: String,
    /// Whether this is a folder.
    pub is_dir: bool,
    /// The database uid, when the file is registered.
    pub uid: Option<AssetUid>,
    /// The registered kind, when the file is registered.
    pub kind: Option<AssetKind>,
}

/// Lists the folder `cwd` (relative to `assets/`): folders first, then files,
/// each group sorted by name. Files are annotated with their database
/// uid/kind when registered. A missing folder yields an empty list (the
/// assets root may not exist yet in a fresh project).
pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec<Entry> {
    let dir = abs_of(db, cwd);
    let Ok(read) = std::fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut folders: Vec<Entry> = Vec::new();
    let mut files: Vec<Entry> = Vec::new();
    for item in read.flatten() {
        let name = item.file_name().to_string_lossy().into_owned();
        let rel = join_rel(cwd, &name);
        if item.path().is_dir() {
            folders.push(Entry {
                name,
                rel,
                is_dir: true,
                uid: None,
                kind: None,
            });
        } else {
            let uid = db.uid_of(&rel);
            let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind);
            files.push(Entry {
                name,
                rel,
                is_dir: false,
                uid,
                kind,
            });
        }
    }
    folders.sort_by(|a, b| a.name.cmp(&b.name));
    files.sort_by(|a, b| a.name.cmp(&b.name));
    folders.extend(files);
    folders
}

/// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs,
/// starting at the assets root. `"textures/env"` yields
/// `[("assets",""), ("textures","textures"), ("env","textures/env")]`.
pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> {
    let mut crumbs = vec![("assets".to_owned(), String::new())];
    let mut path = String::new();
    for seg in cwd.split('/').filter(|s| !s.is_empty()) {
        path = join_rel(&path, seg);
        crumbs.push((seg.to_owned(), path.clone()));
    }
    crumbs
}

/// Joins a folder path and a leaf name into an assets-relative path.
pub fn join_rel(dir: &str, name: &str) -> String {
    if dir.is_empty() {
        name.to_owned()
    } else {
        format!("{dir}/{name}")
    }
}

/// The parent folder of an assets-relative path (`""` at the top).
pub fn parent_of(rel: &str) -> String {
    rel.rsplit_once('/')
        .map(|(p, _)| p.to_owned())
        .unwrap_or_default()
}

/// Whether `name` is usable as a single new file/folder name: non-empty and
/// free of path separators / traversal.
pub fn valid_name(name: &str) -> bool {
    !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
}

/// A name that does not exist in `dir` yet, derived from `wanted` by
/// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`).
pub fn unique_name(dir: &Path, wanted: &str) -> String {
    if !dir.join(wanted).exists() {
        return wanted.to_owned();
    }
    let (stem, ext) = match wanted.rsplit_once('.') {
        // A leading dot (".gitignore") is a hidden name, not an extension.
        Some((s, e)) if !s.is_empty() => (s, Some(e)),
        _ => (wanted, None),
    };
    let mut n = 2;
    loop {
        let candidate = match ext {
            Some(ext) => format!("{stem}_{n}.{ext}"),
            None => format!("{stem}_{n}"),
        };
        if !dir.join(&candidate).exists() {
            return candidate;
        }
        n += 1;
    }
}

/// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its
/// assets-relative path.
pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result<String> {
    if !valid_name(wanted) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("invalid folder name: {wanted:?}"),
        ));
    }
    let dir = abs_of(db, cwd);
    std::fs::create_dir_all(&dir)?;
    let name = unique_name(&dir, wanted);
    std::fs::create_dir(dir.join(&name))?;
    Ok(join_rel(cwd, &name))
}

/// Renames the entry at `rel` to `new_name` (same folder), returning the new
/// relative path. Registered files keep their uid via
/// [`AssetDatabase::move_asset`]; folders move every registered entry under
/// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a
/// plain `fs::rename` (refusing to overwrite).
pub fn rename_entry(
    db: &mut AssetDatabase,
    rel: &str,
    is_dir: bool,
    new_name: &str,
) -> Result<String, AssetDbError> {
    if !valid_name(new_name) {
        return Err(AssetDbError::InvalidPath(new_name.to_owned()));
    }
    let new_rel = join_rel(&parent_of(rel), new_name);
    if new_rel == rel {
        return Ok(new_rel);
    }
    move_to(db, rel, is_dir, &new_rel)?;
    Ok(new_rel)
}

/// Moves the entry at `rel` into the folder `dest_dir`, returning the new
/// relative path. Same uid-preserving rules as [`rename_entry`].
pub fn move_entry(
    db: &mut AssetDatabase,
    rel: &str,
    is_dir: bool,
    dest_dir: &str,
) -> Result<String, AssetDbError> {
    let name = rel.rsplit('/').next().unwrap_or(rel);
    let new_rel = join_rel(dest_dir, name);
    if new_rel == rel {
        return Ok(new_rel);
    }
    move_to(db, rel, is_dir, &new_rel)?;
    Ok(new_rel)
}

/// Deletes the entry: registered files through the database (entry dropped,
/// uid retired), unregistered files from disk, and folders **only when
/// empty** — recursive delete of assets is deliberately not offered.
pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> {
    if entry.is_dir {
        let dir = abs_of(db, &entry.rel);
        if std::fs::read_dir(&dir)?.next().is_some() {
            // (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace
            // MSRV is older, so this stays a generic I/O error.)
            return Err(AssetDbError::Io(std::io::Error::other(format!(
                "folder not empty: {} (delete its contents first)",
                entry.rel
            ))));
        }
        std::fs::remove_dir(&dir)?;
        return Ok(());
    }
    match entry.uid {
        Some(uid) => delete_and_save(db, uid),
        None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?),
    }
}

/// Imports files dropped from the OS into `cwd`: each is copied in under a
/// collision-free name and registered (kind from the folder, else extension).
/// Directories and unreadable sources are skipped with a log line. Returns
/// how many files were imported.
pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize {
    let dir = abs_of(db, cwd);
    if std::fs::create_dir_all(&dir).is_err() {
        return 0;
    }
    let mut imported = 0;
    for src in sources {
        if src.is_dir() {
            log::warn!("skipping folder drop {} (import files)", src.display());
            continue;
        }
        let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else {
            continue;
        };
        let name = unique_name(&dir, &file_name);
        match std::fs::copy(src, dir.join(&name)) {
            Ok(_) => {
                let rel = join_rel(cwd, &name);
                db.register(&rel);
                log::info!("imported {rel}");
                imported += 1;
            }
            Err(err) => log::warn!("could not import {}: {err}", src.display()),
        }
    }
    if imported > 0 {
        if let Err(err) = db.save() {
            log::warn!("could not write asset manifest: {err}");
        }
    }
    imported
}

// --- internals -----------------------------------------------------------

/// The absolute path of an assets-relative path under the database's root.
fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf {
    let assets = db.assets_dir();
    if rel.is_empty() {
        assets
    } else {
        assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
    }
}

/// Routes a rename/move to the right primitive: `move_folder` for folders,
/// `move_asset` for registered files, `fs::rename` (no overwrite) for
/// unregistered ones. Saves the manifest after a database change.
fn move_to(
    db: &mut AssetDatabase,
    rel: &str,
    is_dir: bool,
    new_rel: &str,
) -> Result<(), AssetDbError> {
    if is_dir {
        db.move_folder(rel, new_rel)?;
        save_manifest(db);
        return Ok(());
    }
    match db.uid_of(rel) {
        Some(uid) => {
            db.move_asset(uid, new_rel)?;
            save_manifest(db);
            Ok(())
        }
        None => {
            let to = abs_of(db, new_rel);
            if to.exists() {
                return Err(AssetDbError::DestinationExists(new_rel.to_owned()));
            }
            if let Some(parent) = to.parent() {
                std::fs::create_dir_all(parent)?;
            }
            Ok(std::fs::rename(abs_of(db, rel), to)?)
        }
    }
}

/// Deletes a registered asset and persists the manifest.
fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> {
    db.delete_asset(uid)?;
    save_manifest(db);
    Ok(())
}

/// Best-effort manifest save after a mutation (failure → Console, not fatal).
fn save_manifest(db: &AssetDatabase) {
    if let Err(err) = db.save() {
        log::warn!("could not write asset manifest: {err}");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU32, Ordering};

    static COUNTER: AtomicU32 = AtomicU32::new(0);

    /// A fresh project root with an `assets/` tree and an open database.
    fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) {
        let root = std::env::temp_dir().join(format!(
            "oxide_explorer_test_{}_{}",
            std::process::id(),
            COUNTER.fetch_add(1, Ordering::SeqCst),
        ));
        let assets = root.join("assets");
        std::fs::create_dir_all(&assets).unwrap();
        for rel in files {
            let full = assets.join(rel);
            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
            std::fs::write(full, b"x").unwrap();
        }
        let mut db = AssetDatabase::new(&root);
        db.scan();
        (root, db)
    }

    #[test]
    fn breadcrumbs_and_path_helpers() {
        assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]);
        assert_eq!(
            breadcrumbs("textures/env"),
            vec![
                ("assets".to_owned(), String::new()),
                ("textures".to_owned(), "textures".to_owned()),
                ("env".to_owned(), "textures/env".to_owned()),
            ]
        );
        assert_eq!(join_rel("", "a"), "a");
        assert_eq!(join_rel("a/b", "c"), "a/b/c");
        assert_eq!(parent_of("a/b/c"), "a/b");
        assert_eq!(parent_of("a"), "");
        assert!(valid_name("wall.png"));
        assert!(!valid_name(""));
        assert!(!valid_name("a/b"));
        assert!(!valid_name(".."));
    }

    #[test]
    fn list_dir_sorts_folders_first_and_annotates_registered_files() {
        let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]);

        let top = list_dir(&db, "");
        let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, ["textures", "notes.md"]);
        assert!(top[0].is_dir && top[0].uid.is_none());
        assert_eq!(top[1].kind, Some(AssetKind::Other));

        let textures = list_dir(&db, "textures");
        let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect();
        assert_eq!(names, ["env", "wall.png"]);
        assert_eq!(textures[1].kind, Some(AssetKind::Texture));
        assert_eq!(textures[1].uid, db.uid_of("textures/wall.png"));

        // A folder that does not exist lists as empty, not an error.
        assert!(list_dir(&db, "nope").is_empty());

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn unique_name_suffixes_before_the_extension() {
        let (root, db) = scratch_db(&["textures/wall.png"]);
        let dir = db.assets_dir().join("textures");
        assert_eq!(unique_name(&dir, "new.png"), "new.png");
        assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png");
        std::fs::write(dir.join("wall_2.png"), b"x").unwrap();
        assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png");
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn create_folder_is_unique_and_validated() {
        let (root, db) = scratch_db(&[]);
        assert_eq!(create_folder(&db, "", "props").unwrap(), "props");
        assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2");
        assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env");
        assert!(create_folder(&db, "", "a/b").is_err());
        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn rename_and_move_preserve_uids() {
        let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]);
        let wall = db.uid_of("textures/wall.png").unwrap();
        let sky = db.uid_of("textures/env/sky.png").unwrap();

        // Rename a file in place.
        let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap();
        assert_eq!(new_rel, "textures/brick.png");
        assert_eq!(db.relative_path(wall), Some("textures/brick.png"));

        // Move it into a sibling folder.
        let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap();
        assert_eq!(new_rel, "textures/env/brick.png");
        assert_eq!(db.relative_path(wall), Some("textures/env/brick.png"));

        // Rename the folder: both entries follow, uids intact.
        let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap();
        assert_eq!(new_rel, "textures/world");
        assert_eq!(db.relative_path(sky), Some("textures/world/sky.png"));
        assert_eq!(db.relative_path(wall), Some("textures/world/brick.png"));

        // Invalid target name is refused.
        assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err());

        // The manifest was persisted along the way.
        let reloaded = AssetDatabase::open(&root);
        assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png"));

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn unregistered_files_rename_through_the_filesystem() {
        let (root, mut db) = scratch_db(&[]);
        // A file the database does not track (e.g. a license dropped next to
        // a font). Note scratch_db scans, so create it *after*.
        let assets = db.assets_dir();
        std::fs::write(assets.join("OFL.txt"), b"x").unwrap();
        assert!(db.uid_of("OFL.txt").is_none());

        let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap();
        assert_eq!(new_rel, "LICENSE.txt");
        assert!(assets.join("LICENSE.txt").is_file());
        assert!(!assets.join("OFL.txt").exists());

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn delete_rules_files_yes_folders_only_when_empty() {
        let (root, mut db) = scratch_db(&["textures/wall.png"]);
        let wall_entry = list_dir(&db, "textures")
            .into_iter()
            .find(|e| e.name == "wall.png")
            .unwrap();
        let folder_entry = list_dir(&db, "")
            .into_iter()
            .find(|e| e.name == "textures")
            .unwrap();

        // Non-empty folder refused; file deletes (entry + disk); empty folder ok.
        assert!(delete_entry(&mut db, &folder_entry).is_err());
        delete_entry(&mut db, &wall_entry).unwrap();
        assert!(db.uid_of("textures/wall.png").is_none());
        delete_entry(&mut db, &folder_entry).unwrap();
        assert!(list_dir(&db, "").is_empty());

        std::fs::remove_dir_all(root).ok();
    }

    #[test]
    fn import_copies_registers_and_dodges_collisions() {
        let (root, mut db) = scratch_db(&["textures/wall.png"]);
        // Two outside files, one colliding with an existing asset name.
        let outside = root.join("outside");
        std::fs::create_dir_all(&outside).unwrap();
        std::fs::write(outside.join("wall.png"), b"new").unwrap();
        std::fs::write(outside.join("tree.glb"), b"tree").unwrap();

        let n = import_files(
            &mut db,
            "textures",
            &[outside.join("wall.png"), outside.join("tree.glb")],
        );
        assert_eq!(n, 2);
        assert!(db.uid_of("textures/wall_2.png").is_some());
        // Kind follows the *folder* it was dropped into.
        let tree = db.uid_of("textures/tree.glb").unwrap();
        assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture);

        std::fs::remove_dir_all(root).ok();
    }
}
