//! Thumbnail cache for asset browser grid cells.

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

use bevy::asset::{AssetId, LoadState};
use bevy::prelude::*;
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
use egui_phosphor_icons::icons;

use super::sources::gltf::validate_gltf_dependencies;
use super::studio::{model_file_exists, ThumbnailStudio};
use super::ThumbnailJobSource;
use crate::assets::{
    asset_cache_key, asset_server_path, EditorAsset, EditorAssetKind, EditorAssets,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThumbnailState {
    Pending,
    Ready,
    Failed { reason: String, retryable: bool },
}

#[derive(Debug, Clone)]
struct ThumbnailFailure {
    reason: String,
    retryable: bool,
}

#[derive(Resource, Default)]
pub struct AssetThumbnailCache {
    pub texture_ids: HashMap<String, egui::TextureId>,
    registered_images: HashMap<String, AssetId<Image>>,
    pending: HashMap<String, Handle<Image>>,
    pub(crate) studio_pending: HashSet<String>,
    failed: HashMap<String, ThumbnailFailure>,
    prefetched_folder: Option<String>,
}

pub struct AssetThumbnailsPlugin;

impl Plugin for AssetThumbnailsPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<AssetThumbnailCache>()
            .add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
    }
}

impl AssetThumbnailCache {
    pub fn state(&self, key: &str) -> Option<ThumbnailState> {
        if self.texture_ids.contains_key(key) {
            return Some(ThumbnailState::Ready);
        }
        if self.failed.contains_key(key) {
            let failure = self.failed.get(key)?;
            return Some(ThumbnailState::Failed {
                reason: failure.reason.clone(),
                retryable: failure.retryable,
            });
        }
        if self.pending.contains_key(key) || self.studio_pending.contains(key) {
            return Some(ThumbnailState::Pending);
        }
        None
    }

    /// Clears one thumbnail and returns the image registration when this key was its last owner.
    /// The caller must remove that registration from `EguiUserTextures` after releasing the cache
    /// borrow.
    pub fn retry(&mut self, key: &str) -> Option<AssetId<Image>> {
        self.failed.remove(key);
        self.pending.remove(key);
        self.studio_pending.remove(key);
        self.texture_ids.remove(key);
        self.release_registered_image(key)
    }

    pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) {
        if matches!(
            self.state(&key),
            Some(ThumbnailState::Ready | ThumbnailState::Pending)
        ) {
            return;
        }
        if self.failed.contains_key(&key) {
            return;
        }
        if !self.texture_source_ready(&key, &path) {
            return;
        }
        let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
        self.pending.insert(key, handle);
    }

    pub fn request_model(&mut self, key: String, model_path: String, studio: &mut ThumbnailStudio) {
        if matches!(
            self.state(&key),
            Some(ThumbnailState::Ready | ThumbnailState::Pending)
        ) {
            return;
        }
        if self.failed.contains_key(&key) {
            return;
        }

        if !self.model_source_ready(&key, &model_path) {
            return;
        }

        if studio.enqueue(key.clone(), model_path) {
            self.studio_pending.insert(key);
        }
    }

    pub fn request_mesh_subasset(
        &mut self,
        key: String,
        model_path: String,
        mesh_label: String,
        material_label: Option<String>,
        requires_scene_instance: bool,
        studio: &mut ThumbnailStudio,
    ) {
        if matches!(
            self.state(&key),
            Some(ThumbnailState::Ready | ThumbnailState::Pending)
        ) || self.failed.contains_key(&key)
        {
            return;
        }
        if !self.model_source_ready(&key, &model_path) {
            return;
        }
        let source = if requires_scene_instance {
            ThumbnailJobSource::Model { model_path }
        } else {
            ThumbnailJobSource::MeshSubAsset {
                model_path,
                mesh_label,
                material_label,
            }
        };
        if studio.enqueue_source(key.clone(), source) {
            self.studio_pending.insert(key);
        }
    }

    pub fn request_source_material(
        &mut self,
        key: String,
        model_path: String,
        material_label: String,
        studio: &mut ThumbnailStudio,
    ) {
        if matches!(
            self.state(&key),
            Some(ThumbnailState::Ready | ThumbnailState::Pending)
        ) || self.failed.contains_key(&key)
        {
            return;
        }
        if !self.source_material_ready(&key, &model_path) {
            return;
        }
        if studio.enqueue_source(
            key.clone(),
            ThumbnailJobSource::SourceMaterial {
                model_path,
                material_label,
            },
        ) {
            self.studio_pending.insert(key);
        }
    }

    pub fn request_material_asset(
        &mut self,
        key: String,
        path: String,
        studio: &mut ThumbnailStudio,
    ) {
        if matches!(
            self.state(&key),
            Some(ThumbnailState::Ready | ThumbnailState::Pending)
        ) || self.failed.contains_key(&key)
        {
            return;
        }
        let material = match shared::load_resolved_material_from_path(&path) {
            Ok((material, _)) => material,
            Err(error) => {
                self.mark_studio_failed(&key, &error, true);
                return;
            }
        };
        let label = shared::MaterialAsset::load_from_path(&path)
            .map(|asset| asset.label)
            .or_else(|_| {
                shared::MaterialInstanceAsset::load_from_path(&path).map(|asset| asset.label)
            })
            .unwrap_or_else(|_| "Material".into());
        if studio.enqueue_source(
            key.clone(),
            ThumbnailJobSource::MaterialAsset {
                label,
                material: Box::new(material),
            },
        ) {
            self.studio_pending.insert(key);
        }
    }

    fn model_source_ready(&mut self, key: &str, model_path: &str) -> bool {
        if !model_file_exists(model_path) {
            self.mark_studio_failed(key, "file not found", false);
            return false;
        }
        if let Err(reason) = validate_gltf_dependencies(model_path) {
            self.mark_studio_failed(key, &reason, false);
            return false;
        }
        true
    }

    fn texture_source_ready(&mut self, key: &str, texture_path: &str) -> bool {
        if Path::new(texture_path).is_file() {
            return true;
        }
        self.mark_studio_failed(key, &format!("missing texture: {texture_path}"), false);
        false
    }

    fn source_material_ready(&mut self, key: &str, model_path: &str) -> bool {
        if !self.model_source_ready(key, model_path) {
            return false;
        }
        if model_path
            .rsplit_once('.')
            .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("fbx"))
        {
            if let Err(reason) =
                crate::assets::import::validate_fbx_dependencies(std::path::Path::new(model_path))
            {
                self.mark_studio_failed(key, &reason, false);
                return false;
            }
        }
        true
    }

    pub(crate) fn complete_studio_thumbnail(
        &mut self,
        key: &str,
        image: Handle<Image>,
        textures: &mut EguiUserTextures,
    ) {
        self.studio_pending.remove(key);
        self.failed.remove(key);
        self.register_thumbnail(key, image, textures);
    }

    pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) {
        self.studio_pending.remove(key);
        self.pending.remove(key);
        self.failed.insert(
            key.to_string(),
            ThumbnailFailure {
                reason: reason.to_string(),
                retryable,
            },
        );
    }

    /// Invalidates every cache entry and returns each distinct Egui image registration exactly
    /// once so the world-level invalidation path can relinquish GPU ownership.
    pub fn invalidate_all(&mut self) -> Vec<AssetId<Image>> {
        self.texture_ids.clear();
        self.pending.clear();
        self.studio_pending.clear();
        self.failed.clear();
        self.prefetched_folder = None;
        self.registered_images
            .drain()
            .map(|(_, image)| image)
            .collect::<HashSet<_>>()
            .into_iter()
            .collect()
    }

    fn register_thumbnail(
        &mut self,
        key: &str,
        image: Handle<Image>,
        textures: &mut EguiUserTextures,
    ) {
        let image_id = image.id();
        if self.registered_images.get(key).copied() != Some(image_id) {
            if let Some(previous) = self.release_registered_image(key) {
                textures.remove_image(previous);
            }
            self.registered_images.insert(key.to_string(), image_id);
        }
        let texture_id = textures
            .image_id(image_id)
            .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(image)));
        self.texture_ids.insert(key.to_string(), texture_id);
    }

    fn release_registered_image(&mut self, key: &str) -> Option<AssetId<Image>> {
        let image = self.registered_images.remove(key)?;
        (!self
            .registered_images
            .values()
            .any(|registered| *registered == image))
        .then_some(image)
    }

    pub fn snapshot(&self) -> ThumbnailCacheSnapshot {
        ThumbnailCacheSnapshot {
            texture_ids: self.texture_ids.clone(),
            pending_keys: self
                .pending
                .keys()
                .chain(self.studio_pending.iter())
                .cloned()
                .collect(),
            failed_keys: self.failed.keys().cloned().collect(),
            failure_reasons: self
                .failed
                .iter()
                .map(|(key, failure)| (key.clone(), failure.reason.clone()))
                .collect(),
        }
    }
}

fn register_loaded_thumbnails(
    asset_server: Res<AssetServer>,
    mut cache: ResMut<AssetThumbnailCache>,
    mut textures: ResMut<EguiUserTextures>,
) {
    let failed: Vec<(String, String)> = cache
        .pending
        .iter()
        .filter_map(|(key, handle)| match asset_server.load_state(handle.id()) {
            LoadState::Failed(error) => Some((key.clone(), error.to_string())),
            _ => None,
        })
        .collect();
    let ready: Vec<(String, Handle<Image>)> = cache
        .pending
        .iter()
        .filter(|(_, handle)| asset_server.is_loaded_with_dependencies(*handle))
        .map(|(key, handle)| (key.clone(), handle.clone()))
        .collect();

    for (key, error) in failed {
        cache.mark_studio_failed(&key, &format!("texture load failed: {error}"), false);
    }

    for (key, handle) in ready {
        cache.pending.remove(&key);
        cache.failed.remove(&key);
        cache.register_thumbnail(&key, handle, &mut textures);
    }
}

pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon {
    match kind {
        EditorAssetKind::Primitive(_) => icons::CUBE,
        EditorAssetKind::Light(_) => icons::LIGHTBULB,
        EditorAssetKind::Model => icons::CUBE_TRANSPARENT,
        EditorAssetKind::Texture => icons::IMAGE,
        EditorAssetKind::Material => icons::PAINT_BRUSH,
        EditorAssetKind::AudioClip => icons::WAVEFORM,
        EditorAssetKind::Level => icons::MAP_TRIFOLD,
        EditorAssetKind::Prefab => icons::PACKAGE,
        EditorAssetKind::PostProcessVolume => icons::CAMERA,
        EditorAssetKind::PostProcessEffect => icons::SPARKLE,
        EditorAssetKind::RenderingProfile => icons::SLIDERS,
        EditorAssetKind::ShaderSchema => icons::CODE,
    }
}

#[derive(Clone)]
pub struct ThumbnailCacheSnapshot {
    pub texture_ids: HashMap<String, egui::TextureId>,
    pub pending_keys: Vec<String>,
    pub failed_keys: Vec<String>,
    pub failure_reasons: HashMap<String, String>,
}

impl ThumbnailCacheSnapshot {
    pub fn texture_for_key(&self, key: &str) -> Option<egui::TextureId> {
        self.texture_ids.get(key).copied()
    }

    pub fn is_pending_key(&self, key: &str) -> bool {
        self.pending_keys.iter().any(|pending| pending == key)
    }

    pub fn is_failed_key(&self, key: &str) -> bool {
        self.failed_keys.iter().any(|failed| failed == key)
    }

    pub fn failure_reason_for_key(&self, key: &str) -> Option<&str> {
        self.failure_reasons.get(key).map(String::as_str)
    }

    pub fn texture_for(&self, asset: &EditorAsset) -> Option<egui::TextureId> {
        self.texture_ids.get(&asset_cache_key(asset)).copied()
    }

    pub fn is_pending(&self, asset: &EditorAsset) -> bool {
        self.pending_keys
            .iter()
            .any(|key| key == &asset_cache_key(asset))
    }

    pub fn is_failed(&self, asset: &EditorAsset) -> bool {
        self.failed_keys
            .iter()
            .any(|key| key == &asset_cache_key(asset))
    }

    pub fn failure_reason(&self, asset: &EditorAsset) -> Option<&str> {
        self.failure_reason_for_key(&asset_cache_key(asset))
    }
}

pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) {
    {
        let cache = world.resource::<AssetThumbnailCache>();
        if cache.prefetched_folder.as_deref() == Some(folder) {
            return;
        }
    }

    let requests: Vec<(String, String, EditorAssetKind)> = {
        let assets = world.resource::<EditorAssets>();
        assets
            .assets
            .iter()
            .filter(|asset| asset.folder_path == folder)
            .filter(|asset| {
                matches!(
                    asset.kind,
                    EditorAssetKind::Texture | EditorAssetKind::Model | EditorAssetKind::Material
                )
            })
            .filter_map(|asset| {
                Some((
                    asset_cache_key(asset),
                    asset.path.clone()?,
                    asset.kind.clone(),
                ))
            })
            .collect()
    };
    let asset_server = world.resource::<AssetServer>().clone();
    world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
        world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
            for (key, path, kind) in requests {
                match kind {
                    EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server),
                    EditorAssetKind::Model => cache.request_model(key, path, &mut studio),
                    EditorAssetKind::Material => {
                        cache.request_material_asset(key, path, &mut studio)
                    }
                    _ => {}
                }
            }
        });
    });
    world
        .resource_mut::<AssetThumbnailCache>()
        .prefetched_folder = Some(folder.to_string());
}

/// Requests thumbnails for an explicit cross-folder asset set.
pub fn prefetch_asset_thumbnails(world: &mut World, assets: &[EditorAsset]) {
    let requests = assets
        .iter()
        .filter_map(|asset| {
            Some((
                asset_cache_key(asset),
                asset.path.clone()?,
                asset.kind.clone(),
            ))
        })
        .collect::<Vec<_>>();
    let asset_server = world.resource::<AssetServer>().clone();
    world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
        world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
            for (key, path, kind) in requests {
                match kind {
                    EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server),
                    EditorAssetKind::Model => cache.request_model(key, path, &mut studio),
                    EditorAssetKind::Material => {
                        cache.request_material_asset(key, path, &mut studio)
                    }
                    _ => {}
                }
            }
        });
    });
}

/// Evicts one thumbnail while releasing the backing Egui image if no other cache key shares it.
pub fn retry_thumbnail(world: &mut World, key: &str) {
    let registration = world.resource_mut::<AssetThumbnailCache>().retry(key);
    if let Some(registration) = registration {
        if let Some(mut textures) = world.get_resource_mut::<EguiUserTextures>() {
            textures.remove_image(registration);
        }
    }
}

pub fn invalidate_on_catalog_refresh(world: &mut World) {
    let registrations = world.resource_mut::<AssetThumbnailCache>().invalidate_all();
    if let Some(mut textures) = world.get_resource_mut::<EguiUserTextures>() {
        for registration in registrations {
            textures.remove_image(registration);
        }
    }
    if !world.contains_resource::<ThumbnailStudio>() {
        return;
    }
    let cleanup = {
        let mut studio = world.resource_mut::<ThumbnailStudio>();
        studio.clear_queue();
        studio.clear_jobs()
    };
    if let Some(root) = cleanup.active_root {
        world.commands().entity(root).despawn_children();
        world.commands().entity(root).despawn();
    }
}

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

    #[test]
    fn missing_fbx_material_dependencies_are_one_stable_non_retryable_state() {
        let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/models/painted_wooden_chair_02_2k.fbx")
            .to_string_lossy()
            .into_owned();
        let mut cache = AssetThumbnailCache::default();

        assert!(cache.model_source_ready("model", &model_path));
        assert!(!cache.source_material_ready("material", &model_path));
        let first = cache.state("material");
        assert!(!cache.source_material_ready("material", &model_path));

        assert_eq!(cache.state("material"), first);
        let Some(ThumbnailState::Failed { reason, retryable }) = first else {
            panic!("missing FBX textures should produce one stable failure");
        };
        assert!(!retryable);
        assert!(reason.starts_with("missing 3 FBX source texture(s):"));
        assert!(cache.studio_pending.is_empty());
    }

    #[test]
    fn missing_texture_is_one_stable_non_retryable_state() {
        let path = std::env::temp_dir().join(format!(
            "blacksite-missing-thumbnail-{}-{}.png",
            std::process::id(),
            std::thread::current().name().unwrap_or("test")
        ));
        let path = path.to_string_lossy().into_owned();
        let mut cache = AssetThumbnailCache::default();

        assert!(!cache.texture_source_ready("texture", &path));
        let first = cache.state("texture");
        assert!(!cache.texture_source_ready("texture", &path));

        assert_eq!(cache.state("texture"), first);
        let Some(ThumbnailState::Failed { reason, retryable }) = first else {
            panic!("missing texture should produce one stable failure");
        };
        assert!(!retryable);
        assert_eq!(reason, format!("missing texture: {path}"));
        assert!(cache.pending.is_empty());
    }

    #[test]
    fn poly_haven_desk_model_queues_geometry_instead_of_its_albedo_texture() {
        let model_path = Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("../../assets/Furniture/Office/metal_office_desk_2k.gltf")
            .to_string_lossy()
            .into_owned();
        let mut cache = AssetThumbnailCache::default();
        let mut studio = ThumbnailStudio::for_test();

        cache.request_model("model:desk".into(), model_path.clone(), &mut studio);

        let jobs = studio.queued_jobs_for_test();
        assert_eq!(jobs.len(), 1);
        assert_eq!(jobs[0].cache_key, "model:desk");
        assert!(matches!(
            &jobs[0].source,
            ThumbnailJobSource::Model { model_path: queued } if queued == &model_path
        ));
        assert!(!jobs[0].source.label().contains("_diff_"));
    }

    #[test]
    fn endurance_guard_repeated_thumbnail_replacement_and_invalidation_release_egui_images() {
        let mut cache = AssetThumbnailCache::default();
        let mut textures = EguiUserTextures::default();
        let mut images = Assets::<Image>::default();

        for revision in 0..32 {
            let image = images.add(Image::default());
            let image_id = image.id();
            cache.complete_studio_thumbnail("material:shared", image, &mut textures);
            assert!(textures.image_id(image_id).is_some(), "revision {revision}");
            assert_eq!(cache.registered_images.len(), 1);
        }

        let registrations = cache.invalidate_all();
        assert_eq!(registrations.len(), 1);
        for registration in registrations {
            assert!(textures.remove_image(registration).is_some());
        }
        assert!(cache.registered_images.is_empty());
        assert!(cache.texture_ids.is_empty());
    }

    #[test]
    fn endurance_guard_shared_thumbnail_image_is_released_only_after_the_last_key() {
        let mut cache = AssetThumbnailCache::default();
        let mut textures = EguiUserTextures::default();
        let mut images = Assets::<Image>::default();
        let image = images.add(Image::default());
        let image_id = image.id();

        cache.complete_studio_thumbnail("first", image.clone(), &mut textures);
        cache.complete_studio_thumbnail("second", image, &mut textures);
        assert!(cache.retry("first").is_none());
        assert!(textures.image_id(image_id).is_some());
        let registration = cache.retry("second").expect("last owner releases image");
        assert_eq!(registration, image_id);
        textures.remove_image(registration);
        assert!(textures.image_id(image_id).is_none());
    }
}
