//! [`AssetDatabase`]: stable, project-relative asset references.
//!
//! The [`AssetServer`](super::AssetServer) loads assets by *path*, but a scene
//! or UI document must not bake **absolute** system paths into its saved data —
//! that would break the moment the project is moved to another machine or
//! directory, and it is the chief obstacle to a clean game export (Stage 16).
//!
//! The asset database is the bridge. It assigns every imported asset a stable
//! [`AssetUid`] and records, per project, the mapping
//! **`AssetUid` ↔ assets-relative path** (e.g. `"fonts/Inter-Regular.ttf"`).
//! Saved documents reference assets by `AssetUid`; resolving a uid yields the
//! relative path, which combined with the (possibly new) project root gives an
//! absolute path the [`AssetServer`](super::AssetServer) loads and deduplicates.
//! Because the stored mapping is purely relative, a reference resolves to the
//! same [`Handle`] across save/load **and** after the whole project directory
//! moves.
//!
//! The uid layer (rather than referencing by relative path directly) means an
//! asset can later be *renamed or moved within* the project without breaking
//! references — the uid travels with the file in the manifest.
//!
//! Assets live under typed subfolders of the project's `assets/` directory
//! ([`AssetKind`] → folder), so the database (and the editor's browser) can
//! present and filter them by type without inspecting file contents.

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

use serde::{Deserialize, Serialize};

use super::{AssetServer, Handle};
use crate::project::ASSETS_DIR;

/// The manifest file (RON) at the project root recording the uid ↔ path map.
///
/// It sits at the root rather than inside `assets/` so a scan of the typed
/// asset folders never treats the manifest itself as an asset.
pub const ASSET_MANIFEST_FILE: &str = "assets.manifest";

/// The typed category of a project asset.
///
/// A kind fixes the asset's subfolder under `assets/` and the file extensions
/// that belong to it, letting the database classify files by where they live
/// (with extension as a fallback for files dropped directly in `assets/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AssetKind {
    /// Text rendering fonts (`fonts/`): `.ttf`, `.otf`.
    Font,
    /// Images and textures (`textures/`): `.png`, `.jpg`, …
    Texture,
    /// 3D models (`models/`): `.gltf`, `.glb`, `.obj`.
    Model,
    /// Sound and music (`audio/`): `.wav`, `.ogg`, …
    Audio,
    /// Serialized UI documents (`ui/`).
    Ui,
    /// Game-logic scripts (`scripts/`): `.rhai` (Stage 10).
    Script,
    /// Anything that does not fall into a known typed folder or extension.
    Other,
}

impl AssetKind {
    /// The typed kinds in their canonical order (excludes [`Other`](Self::Other),
    /// which has no folder of its own).
    pub const TYPED: [AssetKind; 6] = [
        AssetKind::Font,
        AssetKind::Texture,
        AssetKind::Model,
        AssetKind::Audio,
        AssetKind::Ui,
        AssetKind::Script,
    ];

    /// The subfolder name under `assets/` for this kind (empty for
    /// [`Other`](Self::Other), which has no dedicated folder).
    pub fn folder(self) -> &'static str {
        match self {
            AssetKind::Font => "fonts",
            AssetKind::Texture => "textures",
            AssetKind::Model => "models",
            AssetKind::Audio => "audio",
            AssetKind::Ui => "ui",
            AssetKind::Script => "scripts",
            AssetKind::Other => "",
        }
    }

    /// The lower-case file extensions (without the dot) that belong to this
    /// kind. [`Other`](Self::Other) claims none.
    pub fn extensions(self) -> &'static [&'static str] {
        match self {
            AssetKind::Font => &["ttf", "otf"],
            AssetKind::Texture => &["png", "jpg", "jpeg", "tga", "bmp", "dds", "ktx2"],
            AssetKind::Model => &["gltf", "glb", "obj"],
            AssetKind::Audio => &["wav", "ogg", "mp3", "flac"],
            // UI documents share the `.ron` extension with scenes, so a UI asset
            // is recognised by its `ui/` folder rather than its extension.
            AssetKind::Ui => &[],
            AssetKind::Script => &["rhai"],
            AssetKind::Other => &[],
        }
    }

    /// The kind owning the typed `folder` name, if any.
    pub fn from_folder(folder: &str) -> Option<AssetKind> {
        AssetKind::TYPED.into_iter().find(|k| k.folder() == folder)
    }

    /// The kind that claims `extension` (without the dot, any case), if any.
    pub fn from_extension(extension: &str) -> Option<AssetKind> {
        let ext = extension.to_lowercase();
        AssetKind::TYPED
            .into_iter()
            .find(|k| k.extensions().contains(&ext.as_str()))
    }

    /// Classifies an assets-relative path. The leading folder wins (so a file in
    /// `ui/` is [`Ui`](Self::Ui) regardless of extension); files outside a typed
    /// folder fall back to their extension, else [`Other`](Self::Other).
    pub fn classify(relative_path: &str) -> AssetKind {
        if let Some((head, _)) = relative_path.split_once('/') {
            if let Some(kind) = AssetKind::from_folder(head) {
                return kind;
            }
        }
        Path::new(relative_path)
            .extension()
            .and_then(|e| e.to_str())
            .and_then(AssetKind::from_extension)
            .unwrap_or(AssetKind::Other)
    }

    /// The kind an asset reference of target type `target` refers to, used to
    /// filter an asset picker. `target` is the inner type of an `AssetRef<T>`
    /// (or `Handle<T>`) field (see [`asset_ref_target`]); unknown targets yield
    /// `None` so the picker can offer every kind.
    pub fn for_handle_target(target: &str) -> Option<AssetKind> {
        match target {
            "Font" | "UiFont" => Some(AssetKind::Font),
            "GltfModel" | "Model" | "Mesh" => Some(AssetKind::Model),
            "Texture" | "Image" => Some(AssetKind::Texture),
            "AudioClip" | "Sound" | "Audio" => Some(AssetKind::Audio),
            "UiPanel" | "UiDocument" => Some(AssetKind::Ui),
            "ScriptAsset" | "Script" => Some(AssetKind::Script),
            _ => None,
        }
    }
}

/// If `type_name` is an asset-reference field spelling — [`AssetRef<T>`] (the
/// serializable reference components store) or a bare [`Handle<T>`] — returns
/// the inner target type's short name; otherwise `None`.
///
/// Reflection records a field's *syntactic* type name (see
/// [`FieldInfo::type_name`](crate::reflect::FieldInfo::type_name)), which for an
/// asset-reference field is something like `"AssetRef < Font >"` or
/// `"Handle<crate::ui::Font>"`. This normalizes whitespace, unwraps the single
/// generic argument, and strips any module path, yielding e.g. `"Font"`. The
/// editor uses it to recognise such fields and pick the right asset filter via
/// [`AssetKind::for_handle_target`].
pub fn asset_ref_target(type_name: &str) -> Option<&str> {
    // Peel the wrapper structurally, trimming whitespace at each step, so both
    // `"AssetRef < Font >"` and `"AssetRef<Font>"` parse and we return a borrow
    // of the original string.
    let t = type_name.trim();
    let inner = t
        .strip_prefix("AssetRef")
        .or_else(|| t.strip_prefix("Handle"))?
        .trim_start();
    let inner = inner.strip_prefix('<')?.trim();
    let inner = inner.strip_suffix('>')?.trim();
    // Reject nested generics / multiple args we don't understand.
    if inner.contains('<') || inner.contains(',') {
        return None;
    }
    // Strip any module path (`crate::ui::Font` -> `Font`).
    Some(inner.rsplit("::").next().unwrap_or(inner).trim())
}

/// A stable, per-project identifier for one asset.
///
/// Unlike [`AssetId`](super::AssetId) — which is process-unique and changes
/// every run — an `AssetUid` is persisted in the project's manifest and stays
/// attached to its asset across sessions, so saved references keep resolving.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AssetUid(pub u64);

impl AssetUid {
    /// The raw numeric value.
    pub fn value(self) -> u64 {
        self.0
    }
}

/// A typed, serializable reference to a project asset.
///
/// This is what a **component** stores when it points at an asset (a UI label's
/// font, a renderer's mesh, …). A live [`Handle<T>`] is not serializable and is
/// tied to one process run, so persisting it would be wrong; an `AssetRef<T>`
/// instead holds the stable [`AssetUid`] and resolves to a handle on demand via
/// [`resolve`](Self::resolve) (database → relative path → server → handle).
///
/// Being a thin wrapper over `Option<AssetUid>`, it serializes compactly and
/// round-trips through reflection's RON path, so an asset-reference field is
/// editable in the inspector with no per-type code. The phantom `T` records the
/// target asset type, which the editor reads from the field's spelling
/// (`"AssetRef < Font >"`) via [`asset_ref_target`] to filter the picker.
pub struct AssetRef<T> {
    uid: Option<AssetUid>,
    _marker: std::marker::PhantomData<fn() -> T>,
}

impl<T> AssetRef<T> {
    /// An empty reference, pointing at no asset.
    pub const fn none() -> Self {
        Self {
            uid: None,
            _marker: std::marker::PhantomData,
        }
    }

    /// A reference to the asset with stable id `uid`.
    pub const fn new(uid: AssetUid) -> Self {
        Self {
            uid: Some(uid),
            _marker: std::marker::PhantomData,
        }
    }

    /// The referenced asset's stable id, or `None` if empty.
    pub fn uid(self) -> Option<AssetUid> {
        self.uid
    }

    /// Whether this reference points at an asset.
    pub fn is_some(self) -> bool {
        self.uid.is_some()
    }

    /// Points the reference at `uid` (or clears it with `None`).
    pub fn set(&mut self, uid: Option<AssetUid>) {
        self.uid = uid;
    }

    /// Resolves to a loaded [`Handle<T>`] via `db` + `server`, or `None` if the
    /// reference is empty or its uid is unknown to the database.
    pub fn resolve(self, db: &AssetDatabase, server: &AssetServer) -> Option<Handle<T>>
    where
        T: Send + Sync + 'static,
    {
        db.load::<T>(server, self.uid?)
    }
}

// Hand-written trait impls: deriving would wrongly require `T: Clone`/`Default`
// etc., but an `AssetRef<T>` carries no `T` value — only a uid + phantom marker.
impl<T> Clone for AssetRef<T> {
    fn clone(&self) -> Self {
        *self
    }
}
impl<T> Copy for AssetRef<T> {}
impl<T> Default for AssetRef<T> {
    fn default() -> Self {
        Self::none()
    }
}
impl<T> PartialEq for AssetRef<T> {
    fn eq(&self, other: &Self) -> bool {
        self.uid == other.uid
    }
}
impl<T> Eq for AssetRef<T> {}
impl<T> std::fmt::Debug for AssetRef<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_tuple("AssetRef").field(&self.uid).finish()
    }
}
// Serialize transparently as the inner `Option<AssetUid>` so saved data is just
// the uid (or unit `None`) and stays independent of `T`.
impl<T> Serialize for AssetRef<T> {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        self.uid.serialize(serializer)
    }
}
impl<'de, T> Deserialize<'de> for AssetRef<T> {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        Ok(Self {
            uid: Option::<AssetUid>::deserialize(deserializer)?,
            _marker: std::marker::PhantomData,
        })
    }
}

/// One asset's record in the database: its stable id, kind, and the path it
/// lives at *relative to the project's `assets/` directory* (forward slashes).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetEntry {
    /// The stable identifier saved references use.
    pub uid: AssetUid,
    /// The asset's typed category.
    pub kind: AssetKind,
    /// Path relative to `assets/`, e.g. `"fonts/Inter-Regular.ttf"`.
    pub path: String,
}

/// The on-disk manifest: the uid allocator plus every known entry.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Manifest {
    /// The next uid to hand out; persisted so a deleted asset's uid is never
    /// reused by a freshly imported one.
    next_uid: u64,
    /// Every recorded asset (sorted by uid when written, for stable diffs).
    entries: Vec<AssetEntry>,
}

/// Maps stable asset ids to project-relative paths and back, and resolves them
/// to [`Handle`]s through an [`AssetServer`](super::AssetServer).
///
/// Construct it for a project root with [`new`](Self::new) (empty) or
/// [`open`](Self::open) (reading any existing manifest), then [`scan`](Self::scan)
/// the asset folders or [`register`](Self::register) individual imports. The
/// root may be changed with [`set_root`](Self::set_root) — e.g. after opening
/// the same project from a new location — without disturbing the uid mapping.
#[derive(Debug, Clone)]
pub struct AssetDatabase {
    root: PathBuf,
    by_uid: HashMap<AssetUid, AssetEntry>,
    by_path: HashMap<String, AssetUid>,
    next_uid: u64,
}

impl AssetDatabase {
    /// An empty database for the project rooted at `root`.
    pub fn new(root: impl AsRef<Path>) -> Self {
        Self {
            root: root.as_ref().to_path_buf(),
            by_uid: HashMap::new(),
            by_path: HashMap::new(),
            next_uid: 1,
        }
    }

    /// Opens the database for the project at `root`, reading its manifest if
    /// present. A missing or unreadable manifest yields an empty database (a
    /// later [`scan`](Self::scan) repopulates it from disk).
    pub fn open(root: impl AsRef<Path>) -> Self {
        let root = root.as_ref().to_path_buf();
        let mut db = Self::new(&root);
        let manifest_path = root.join(ASSET_MANIFEST_FILE);
        if let Ok(text) = std::fs::read_to_string(&manifest_path) {
            if let Ok(manifest) = ron::from_str::<Manifest>(&text) {
                for entry in manifest.entries {
                    db.by_path.insert(entry.path.clone(), entry.uid);
                    db.by_uid.insert(entry.uid, entry);
                }
                db.next_uid = manifest.next_uid.max(db.highest_uid() + 1);
            }
        }
        db
    }

    /// Writes the manifest to `<root>/assets.manifest`.
    pub fn save(&self) -> std::io::Result<()> {
        let mut entries: Vec<AssetEntry> = self.by_uid.values().cloned().collect();
        entries.sort_by_key(|e| e.uid);
        let manifest = Manifest {
            next_uid: self.next_uid,
            entries,
        };
        let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(self.manifest_path(), text)
    }

    /// The project root the database resolves paths against.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Points the database at a new project root (e.g. after the project
    /// directory moved). The uid ↔ relative-path mapping is unaffected, so all
    /// existing references keep resolving — now against the new location.
    pub fn set_root(&mut self, root: impl AsRef<Path>) {
        self.root = root.as_ref().to_path_buf();
    }

    /// The `assets/` directory under the project root.
    pub fn assets_dir(&self) -> PathBuf {
        self.root.join(ASSETS_DIR)
    }

    /// The manifest file path.
    pub fn manifest_path(&self) -> PathBuf {
        self.root.join(ASSET_MANIFEST_FILE)
    }

    /// Records the asset at `relative_path` (relative to `assets/`), returning
    /// its uid — the existing one if already known, else a freshly allocated
    /// one. The kind is inferred from the path. Idempotent for a given path.
    pub fn register(&mut self, relative_path: impl AsRef<str>) -> AssetUid {
        let path = normalize_relative(relative_path.as_ref());
        if let Some(&uid) = self.by_path.get(&path) {
            return uid;
        }
        let uid = AssetUid(self.next_uid);
        self.next_uid += 1;
        let entry = AssetEntry {
            uid,
            kind: AssetKind::classify(&path),
            path: path.clone(),
        };
        self.by_path.insert(path, uid);
        self.by_uid.insert(uid, entry);
        uid
    }

    /// Scans the typed asset folders under `assets/` and reconciles the
    /// database with what is on disk: existing files keep their uid, new files
    /// are [registered](Self::register), and entries whose files no longer exist
    /// are dropped. Returns the number of newly registered assets.
    ///
    /// Call [`save`](Self::save) afterwards to persist any new uids.
    pub fn scan(&mut self) -> usize {
        let assets_dir = self.assets_dir();
        let mut found: Vec<String> = Vec::new();
        for kind in AssetKind::TYPED {
            collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
        }

        // Drop entries whose backing file disappeared.
        let present: std::collections::HashSet<&String> = found.iter().collect();
        let removed: Vec<(AssetUid, String)> = self
            .by_uid
            .values()
            .filter(|e| !present.contains(&e.path))
            .map(|e| (e.uid, e.path.clone()))
            .collect();
        for (uid, path) in removed {
            self.by_uid.remove(&uid);
            self.by_path.remove(&path);
        }

        // Register anything new.
        let before = self.by_uid.len();
        for path in found {
            self.register(path);
        }
        self.by_uid.len().saturating_sub(before)
    }

    /// The entry for `uid`, if known.
    pub fn entry(&self, uid: AssetUid) -> Option<&AssetEntry> {
        self.by_uid.get(&uid)
    }

    /// The uid recorded for an assets-relative path, if any.
    pub fn uid_of(&self, relative_path: impl AsRef<str>) -> Option<AssetUid> {
        self.by_path
            .get(&normalize_relative(relative_path.as_ref()))
            .copied()
    }

    /// The assets-relative path for `uid`, if known.
    pub fn relative_path(&self, uid: AssetUid) -> Option<&str> {
        self.by_uid.get(&uid).map(|e| e.path.as_str())
    }

    /// The absolute filesystem path for `uid` under the current root, if known.
    pub fn absolute_path(&self, uid: AssetUid) -> Option<PathBuf> {
        self.by_uid.get(&uid).map(|e| {
            self.assets_dir()
                .join(e.path.replace('/', std::path::MAIN_SEPARATOR_STR))
        })
    }

    /// Every entry, in unspecified order.
    pub fn entries(&self) -> impl Iterator<Item = &AssetEntry> {
        self.by_uid.values()
    }

    /// Entries of a given kind, in unspecified order.
    pub fn entries_of_kind(&self, kind: AssetKind) -> impl Iterator<Item = &AssetEntry> {
        self.by_uid.values().filter(move |e| e.kind == kind)
    }

    /// The number of recorded assets.
    pub fn len(&self) -> usize {
        self.by_uid.len()
    }

    /// Whether the database has no entries.
    pub fn is_empty(&self) -> bool {
        self.by_uid.is_empty()
    }

    /// Resolves `uid` to a loaded [`Handle<T>`] via `server`, or `None` if the
    /// uid is unknown. The handle is deduplicated by the server, so resolving
    /// the same uid (even after the project moved) yields the same asset.
    pub fn load<T: Send + Sync + 'static>(
        &self,
        server: &AssetServer,
        uid: AssetUid,
    ) -> Option<Handle<T>> {
        let path = self.absolute_path(uid)?;
        Some(server.load::<T>(path))
    }

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

    fn highest_uid(&self) -> u64 {
        self.by_uid.keys().map(|u| u.0).max().unwrap_or(0)
    }
}

/// Normalizes a path to the database's canonical relative form: forward slashes,
/// no leading `./` or separator.
fn normalize_relative(path: &str) -> String {
    let trimmed = path.replace('\\', "/");
    let trimmed = trimmed.strip_prefix("./").unwrap_or(&trimmed);
    trimmed.trim_start_matches('/').to_string()
}

/// Recursively collects files under `dir`, pushing each one's path relative to
/// `base` (forward slashes) into `out`. A missing `dir` is silently skipped.
fn collect_files(dir: &Path, base: &Path, out: &mut Vec<String>) {
    let Ok(read) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in read.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_files(&path, base, out);
        } else if let Ok(rel) = path.strip_prefix(base) {
            out.push(rel.to_string_lossy().replace('\\', "/"));
        }
    }
}

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

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

    fn temp_root(tag: &str) -> PathBuf {
        let mut path = std::env::temp_dir();
        path.push(format!(
            "oxide_assetdb_test_{}_{}_{tag}",
            std::process::id(),
            COUNTER.fetch_add(1, Ordering::SeqCst),
        ));
        path
    }

    /// Creates `assets/<rel>` under `root` with placeholder contents.
    fn touch_asset(root: &Path, rel: &str) {
        let full = root.join(ASSETS_DIR).join(rel);
        std::fs::create_dir_all(full.parent().unwrap()).unwrap();
        std::fs::write(full, b"x").unwrap();
    }

    #[test]
    fn classify_by_folder_then_extension() {
        assert_eq!(AssetKind::classify("fonts/Inter.ttf"), AssetKind::Font);
        assert_eq!(AssetKind::classify("ui/menu.ron"), AssetKind::Ui);
        assert_eq!(AssetKind::classify("textures/wall.png"), AssetKind::Texture);
        // No typed folder → fall back to extension.
        assert_eq!(AssetKind::classify("loose.glb"), AssetKind::Model);
        assert_eq!(AssetKind::classify("notes.md"), AssetKind::Other);
    }

    #[test]
    fn asset_ref_target_parses_and_maps_to_kind() {
        // The derive's spelling (spaces around the generic args), for both the
        // serializable `AssetRef<T>` and a bare `Handle<T>`.
        assert_eq!(asset_ref_target("AssetRef < Font >"), Some("Font"));
        assert_eq!(asset_ref_target("Handle < Font >"), Some("Font"));
        // Compact and module-qualified spellings.
        assert_eq!(asset_ref_target("AssetRef<GltfModel>"), Some("GltfModel"));
        assert_eq!(asset_ref_target("Handle<crate::ui::Font>"), Some("Font"));
        // Non-reference and unsupported (nested / multi-arg) fields.
        assert_eq!(asset_ref_target("f32"), None);
        assert_eq!(asset_ref_target("Vec<AssetRef<Font>>"), None);
        assert_eq!(asset_ref_target("HashMap<String, u32>"), None);

        // Target type -> picker filter kind.
        assert_eq!(AssetKind::for_handle_target("Font"), Some(AssetKind::Font));
        assert_eq!(
            AssetKind::for_handle_target("GltfModel"),
            Some(AssetKind::Model)
        );
        assert_eq!(AssetKind::for_handle_target("Whatever"), None);
    }

    #[test]
    fn asset_ref_serializes_as_uid_and_resolves() {
        // Empty and populated references round-trip through RON as just the uid.
        let empty = AssetRef::<String>::none();
        assert!(!empty.is_some());
        let ron_empty = ron::to_string(&empty).unwrap();
        assert_eq!(
            ron::from_str::<AssetRef<String>>(&ron_empty).unwrap(),
            empty
        );

        let r = AssetRef::<String>::new(AssetUid(7));
        let round: AssetRef<String> = ron::from_str(&ron::to_string(&r).unwrap()).unwrap();
        assert_eq!(round.uid(), Some(AssetUid(7)));

        // resolve() goes ref -> db -> server -> handle.
        let root = temp_root("assetref");
        let full = root.join(ASSETS_DIR).join("textures");
        std::fs::create_dir_all(&full).unwrap();
        std::fs::write(full.join("a.txt"), "hi").unwrap();
        let mut db = AssetDatabase::new(&root);
        let uid = db.register("textures/a.txt");
        let server = AssetServer::empty();
        server.register_loader(TxtLoader);

        let reference = AssetRef::<String>::new(uid);
        let handle = reference.resolve(&db, &server).unwrap();
        assert_eq!(handle.get().unwrap().as_str(), "hi");
        // An empty ref resolves to nothing.
        assert!(AssetRef::<String>::none().resolve(&db, &server).is_none());

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

    #[test]
    fn register_is_idempotent_and_infers_kind() {
        let mut db = AssetDatabase::new(temp_root("register"));
        let a = db.register("fonts/Inter-Regular.ttf");
        let b = db.register("fonts/Inter-Regular.ttf");
        assert_eq!(a, b, "same path returns same uid");
        assert_eq!(db.len(), 1);
        assert_eq!(db.entry(a).unwrap().kind, AssetKind::Font);
        // Path normalization: a `./`-prefixed, back-slashed spelling collapses.
        assert_eq!(db.uid_of(".\\fonts\\Inter-Regular.ttf"), Some(a));
    }

    #[test]
    fn scan_picks_up_typed_folders_and_prunes_missing() {
        let root = temp_root("scan");
        touch_asset(&root, "fonts/Inter.ttf");
        touch_asset(&root, "textures/wall.png");
        touch_asset(&root, "models/cube.glb");

        let mut db = AssetDatabase::new(&root);
        assert_eq!(db.scan(), 3);
        assert_eq!(db.len(), 3);
        assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);

        // Remove one file and rescan: it is pruned, the rest keep their uids.
        let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
        let wall_uid = db.uid_of("textures/wall.png").unwrap();
        std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
        assert_eq!(db.scan(), 0);
        assert_eq!(db.len(), 2);
        assert!(db.entry(font_uid).is_none());
        assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));

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

    #[test]
    fn manifest_round_trips_uids() {
        let root = temp_root("manifest");
        std::fs::create_dir_all(&root).unwrap();
        touch_asset(&root, "fonts/Inter.ttf");
        touch_asset(&root, "audio/click.wav");

        let mut db = AssetDatabase::new(&root);
        db.scan();
        let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
        let click_uid = db.uid_of("audio/click.wav").unwrap();
        let next = db.next_uid;
        db.save().unwrap();

        // Reload from the manifest: every uid is preserved, and the allocator
        // does not reuse a freed id.
        let reloaded = AssetDatabase::open(&root);
        assert_eq!(reloaded.uid_of("fonts/Inter.ttf"), Some(font_uid));
        assert_eq!(reloaded.uid_of("audio/click.wav"), Some(click_uid));
        assert_eq!(reloaded.next_uid, next);

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

    #[test]
    fn reference_survives_save_load_and_moved_project() {
        // A reference (uid) saved with the project must resolve to the same
        // handle after reload AND after the whole project directory moves.
        let root = temp_root("move_src");
        touch_asset(&root, "fonts/Inter.ttf");
        let mut db = AssetDatabase::open(&root);
        db.scan();
        db.save().unwrap();
        let uid = db.uid_of("fonts/Inter.ttf").unwrap();

        // Simulate moving the project to a new directory on disk.
        let moved = temp_root("move_dst");
        std::fs::create_dir_all(&moved).unwrap();
        copy_dir(&root, &moved);

        // Open the database from the new location: same uid, new absolute path.
        let moved_db = AssetDatabase::open(&moved);
        assert_eq!(moved_db.uid_of("fonts/Inter.ttf"), Some(uid));
        let abs = moved_db.absolute_path(uid).unwrap();
        assert!(abs.starts_with(&moved));
        assert!(abs.exists());

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

    #[test]
    fn load_dedups_through_the_server() {
        // Use a tiny custom loader so we don't need a real asset format.
        let root = temp_root("load");
        let full = root.join(ASSETS_DIR).join("textures");
        std::fs::create_dir_all(&full).unwrap();
        std::fs::write(full.join("a.txt"), "hi").unwrap();

        let mut db = AssetDatabase::new(&root);
        let uid = db.register("textures/a.txt");

        let server = AssetServer::empty();
        server.register_loader(TxtLoader);
        let h1 = db.load::<String>(&server, uid).unwrap();
        let h2 = db.load::<String>(&server, uid).unwrap();
        assert_eq!(h1.id(), h2.id(), "same uid resolves to one shared asset");
        assert_eq!(h1.get().unwrap().as_str(), "hi");
        assert!(db.load::<String>(&server, AssetUid(999)).is_none());

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

    struct TxtLoader;
    impl crate::asset::AssetLoader for TxtLoader {
        type Asset = String;
        fn extensions(&self) -> &'static [&'static str] {
            &["txt"]
        }
        fn load(&self, path: &Path) -> Result<String, crate::asset::AssetError> {
            std::fs::read_to_string(path).map_err(|e| crate::asset::AssetError::Load {
                path: path.to_path_buf(),
                message: e.to_string(),
            })
        }
    }

    fn copy_dir(from: &Path, to: &Path) {
        for entry in std::fs::read_dir(from).unwrap().flatten() {
            let dst = to.join(entry.file_name());
            if entry.path().is_dir() {
                std::fs::create_dir_all(&dst).unwrap();
                copy_dir(&entry.path(), &dst);
            } else {
                std::fs::copy(entry.path(), dst).unwrap();
            }
        }
    }
}
