//! Offscreen model thumbnail studio for FBX and untextured glTF assets.

use std::collections::VecDeque;
use std::path::Path;

use bevy::camera::visibility::RenderLayers;
use bevy::camera::RenderTarget;
use bevy::gltf::GltfAssetLabel;
use bevy::math::bounding::Aabb3d;
use bevy::mesh::VertexAttributeValues;
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy_egui::EguiUserTextures;
use shared::{material_from_desc, standard_material_asset_path, ModelRef};

use super::cache::AssetThumbnailCache;
use super::job::{ThumbnailJob, ThumbnailJobSource};
use super::sources::{source_for_extension, uses_scene_root};
use crate::assets::asset_server_path;
use crate::infra::EditorOnly;

pub(crate) const THUMB_SIZE: u32 = 128;
pub(crate) const THUMBNAIL_LAYER: usize = 31;
const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians();
const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5;
const SCENE_FRAME_PADDING: f32 = 1.35;
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
const MESH_WARMUP_FRAMES: u8 = 12;
const POST_ATTACH_FRAMES: u8 = 8;
const RENDER_FRAMES: u8 = 3;
const COOLDOWN_FRAMES: u8 = 2;
const LOAD_TIMEOUT_FRAMES: u32 = 240;

#[derive(Component)]
struct ThumbnailStudioCamera;

#[derive(Component)]
struct ThumbnailStudioLight;

#[derive(Component)]
struct ThumbnailStudioLayer;

#[derive(Component)]
struct ThumbnailSceneRoot;

#[derive(Resource)]
pub struct ThumbnailStudio {
    camera: Entity,
    render_image: Handle<Image>,
    _lights: Vec<Entity>,
    queue: VecDeque<ThumbnailJob>,
    active: Option<ActiveModelThumbnail>,
    cooldown_frames: u8,
}

struct ActiveModelThumbnail {
    cache_key: String,
    label: String,
    render_image: Handle<Image>,
    root: Entity,
    meshes_ready: bool,
    mesh_warmup_frames: u8,
    camera_active: bool,
    post_attach_frames: u8,
    framed: bool,
    frames_remaining: u8,
    wait_frames: u32,
    framing: ThumbnailFraming,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ThumbnailFraming {
    SceneBounds,
    MaterialSphere,
}

impl ThumbnailFraming {
    fn for_source(source: &ThumbnailJobSource) -> Self {
        match source {
            ThumbnailJobSource::SourceMaterial { .. }
            | ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere,
            ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => {
                Self::SceneBounds
            }
        }
    }
}

pub struct ThumbnailStudioPlugin;

impl Plugin for ThumbnailStudioPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, setup_thumbnail_studio)
            .add_systems(PostUpdate, apply_thumbnail_studio_layers)
            .add_systems(Last, process_thumbnail_studio);
    }
}

impl ThumbnailStudio {
    pub fn enqueue(&mut self, cache_key: String, model_path: String) -> bool {
        self.enqueue_source(cache_key, ThumbnailJobSource::Model { model_path })
    }

    pub fn enqueue_source(&mut self, cache_key: String, source: ThumbnailJobSource) -> bool {
        if self
            .active
            .as_ref()
            .is_some_and(|active| active.cache_key == cache_key)
        {
            return false;
        }
        if self.queue.iter().any(|job| job.cache_key == cache_key) {
            return false;
        }
        self.queue.push_back(ThumbnailJob { cache_key, source });
        true
    }

    pub fn clear_queue(&mut self) {
        self.queue.clear();
        self.cooldown_frames = 0;
    }

    pub fn clear_jobs(&mut self) -> StudioCleanup {
        StudioCleanup {
            camera: self.camera,
            active_root: self.active.take().map(|active| active.root),
        }
    }
}

pub struct StudioCleanup {
    pub camera: Entity,
    pub active_root: Option<Entity>,
}

pub fn model_scene_asset_path(path: &str, scene_index: usize) -> String {
    let path = asset_server_path(path);
    if path.ends_with(".fbx") {
        ModelRef::fbx_scene_asset_path(&path, scene_index)
    } else {
        GltfAssetLabel::Scene(scene_index)
            .from_asset(path)
            .to_string()
    }
}

fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
    let render_image = images.add(studio_render_image());

    let camera = commands
        .spawn((
            Name::new("Thumbnail Studio Camera"),
            EditorOnly,
            ThumbnailStudioCamera,
            ThumbnailStudioLayer,
            RenderLayers::layer(THUMBNAIL_LAYER),
            Camera3d::default(),
            Msaa::Off,
            Camera {
                is_active: false,
                order: -50,
                clear_color: ClearColorConfig::Custom(Color::srgb(0.12, 0.12, 0.14)),
                ..default()
            },
            RenderTarget::Image(render_image.clone().into()),
            Transform::from_xyz(2.0, 1.4, 2.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
            Projection::Perspective(PerspectiveProjection {
                fov: THUMBNAIL_VERTICAL_FOV,
                ..default()
            }),
        ))
        .id();

    let key_light = commands
        .spawn((
            EditorOnly,
            ThumbnailStudioLight,
            ThumbnailStudioLayer,
            RenderLayers::layer(THUMBNAIL_LAYER),
            DirectionalLight {
                illuminance: 12_000.0,
                shadow_maps_enabled: false,
                ..default()
            },
            Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.8, 0.9, 0.0)),
        ))
        .id();

    let fill_light = commands
        .spawn((
            EditorOnly,
            ThumbnailStudioLight,
            ThumbnailStudioLayer,
            RenderLayers::layer(THUMBNAIL_LAYER),
            DirectionalLight {
                illuminance: 3_500.0,
                shadow_maps_enabled: false,
                ..default()
            },
            Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.4, -2.2, 0.0)),
        ))
        .id();

    commands.insert_resource(ThumbnailStudio {
        camera,
        render_image,
        _lights: vec![key_light, fill_light],
        queue: VecDeque::new(),
        active: None,
        cooldown_frames: 0,
    });
}

#[allow(clippy::too_many_arguments)]
fn process_thumbnail_studio(
    mut commands: Commands,
    mut studio: ResMut<ThumbnailStudio>,
    mut cache: ResMut<AssetThumbnailCache>,
    mut textures: ResMut<EguiUserTextures>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut mesh_storage: ResMut<Assets<Mesh>>,
    mut images: ResMut<Assets<Image>>,
    asset_server: Res<AssetServer>,
    mut cameras: Query<&mut Camera, With<ThumbnailStudioCamera>>,
    mut camera_targets: Query<&mut RenderTarget, With<ThumbnailStudioCamera>>,
    mut camera_transforms: Query<&mut Transform, With<ThumbnailStudioCamera>>,
    children: Query<&Children>,
    transforms: Query<&GlobalTransform>,
    mesh3d: Query<&Mesh3d>,
) {
    if studio.cooldown_frames > 0 {
        studio.cooldown_frames -= 1;
        return;
    }

    if let Some(mut active) = studio.active.take() {
        if !active.framed {
            active.wait_frames += 1;

            if !active.meshes_ready {
                if thumbnail_meshes_present(active.root, &children, &mesh3d, &mesh_storage) {
                    active.meshes_ready = true;
                    active.mesh_warmup_frames = 0;
                }
            } else if active.mesh_warmup_frames < MESH_WARMUP_FRAMES {
                active.mesh_warmup_frames += 1;
            } else if frame_thumbnail_scene(
                active.root,
                &children,
                &transforms,
                &mesh3d,
                &mesh_storage,
                &mut camera_transforms,
                active.framing,
            ) {
                active.framed = true;
                active.post_attach_frames = 0;
                studio.active = Some(active);
                return;
            }

            if active.wait_frames >= LOAD_TIMEOUT_FRAMES {
                warn!(
                    "Model thumbnail timed out framing {} (meshes may still be loading)",
                    active.label
                );
                fail_active_thumbnail(
                    &mut commands,
                    &mut studio,
                    &mut cache,
                    &mut cameras,
                    &mut camera_targets,
                    active,
                    "load timeout",
                    true,
                );
            } else {
                studio.active = Some(active);
            }
            return;
        }

        if !active.camera_active {
            active.post_attach_frames += 1;
            if active.post_attach_frames >= POST_ATTACH_FRAMES {
                if let Ok(mut camera) = cameras.get_mut(studio.camera) {
                    camera.is_active = true;
                }
                active.camera_active = true;
                active.frames_remaining = RENDER_FRAMES;
            }
            studio.active = Some(active);
            return;
        }

        if active.camera_active {
            if active.frames_remaining > 0 {
                active.frames_remaining -= 1;
                studio.active = Some(active);
            } else {
                cache.complete_studio_thumbnail(
                    &active.cache_key,
                    active.render_image.clone(),
                    &mut textures,
                );
                finish_active_thumbnail(
                    &mut commands,
                    &mut studio,
                    &mut cameras,
                    &mut camera_targets,
                    active,
                );
            }
            return;
        }

        studio.active = Some(active);
        return;
    }

    let Some(job) = studio.queue.pop_front() else {
        deactivate_studio_camera(&mut cameras, studio.camera);
        return;
    };

    let render_image = images.add(studio_render_image());
    if let Ok(mut target) = camera_targets.get_mut(studio.camera) {
        *target = RenderTarget::Image(render_image.clone().into());
    }
    if let Ok(mut camera) = cameras.get_mut(studio.camera) {
        camera.is_active = false;
    }

    let root = commands
        .spawn((
            EditorOnly,
            ThumbnailSceneRoot,
            ThumbnailStudioLayer,
            RenderLayers::layer(THUMBNAIL_LAYER),
            Transform::default(),
            Visibility::Visible,
            InheritedVisibility::default(),
            ViewVisibility::default(),
        ))
        .id();

    let label = job.source.label().to_string();
    let framing = ThumbnailFraming::for_source(&job.source);
    let content_spawned = spawn_thumbnail_job_content(
        &mut commands,
        &mut mesh_storage,
        &mut materials,
        &asset_server,
        root,
        &job.source,
    );

    if !content_spawned {
        fail_active_thumbnail(
            &mut commands,
            &mut studio,
            &mut cache,
            &mut cameras,
            &mut camera_targets,
            ActiveModelThumbnail {
                cache_key: job.cache_key,
                label,
                render_image,
                root,
                meshes_ready: false,
                mesh_warmup_frames: 0,
                camera_active: false,
                post_attach_frames: 0,
                framed: false,
                frames_remaining: 0,
                wait_frames: 0,
                framing,
            },
            "no renderable meshes",
            true,
        );
        return;
    }

    studio.active = Some(ActiveModelThumbnail {
        cache_key: job.cache_key,
        label,
        render_image,
        root,
        meshes_ready: false,
        mesh_warmup_frames: 0,
        camera_active: false,
        post_attach_frames: 0,
        framed: false,
        frames_remaining: 0,
        wait_frames: 0,
        framing,
    });
}

fn spawn_thumbnail_job_content(
    commands: &mut Commands,
    mesh_storage: &mut Assets<Mesh>,
    materials: &mut Assets<StandardMaterial>,
    asset_server: &AssetServer,
    root: Entity,
    source: &ThumbnailJobSource,
) -> bool {
    match source {
        ThumbnailJobSource::Model { model_path } => {
            if uses_scene_root(model_path) {
                commands.entity(root).insert(WorldAssetRoot(
                    asset_server.load(model_scene_asset_path(model_path, 0)),
                ));
            }
            if let Some(source) = source_for_extension(model_path) {
                source.spawn_preview(commands, mesh_storage, materials, root, model_path)
            } else {
                uses_scene_root(model_path)
            }
        }
        ThumbnailJobSource::MeshSubAsset {
            model_path,
            mesh_label,
            material_label,
        } => spawn_mesh_subasset_preview(
            commands,
            materials,
            asset_server,
            root,
            model_path,
            mesh_label,
            material_label.as_deref(),
        ),
        ThumbnailJobSource::SourceMaterial {
            model_path,
            material_label,
        } => spawn_material_sphere(
            commands,
            mesh_storage,
            root,
            asset_server.load(standard_material_asset_path(
                &asset_server_path(model_path),
                material_label,
            )),
        ),
        ThumbnailJobSource::MaterialAsset { material, .. } => {
            let material = materials.add(material_from_desc(asset_server, material));
            spawn_material_sphere(commands, mesh_storage, root, material)
        }
    }
}

fn spawn_mesh_subasset_preview(
    commands: &mut Commands,
    materials: &mut Assets<StandardMaterial>,
    asset_server: &AssetServer,
    root: Entity,
    model_path: &str,
    mesh_label: &str,
    material_label: Option<&str>,
) -> bool {
    let source_path = asset_server_path(model_path);
    let mesh: Handle<Mesh> = asset_server.load(labeled_asset_path(&source_path, mesh_label));
    let material = if let Some(material_label) = material_label {
        asset_server.load(standard_material_asset_path(&source_path, material_label))
    } else {
        materials.add(StandardMaterial {
            base_color: Color::srgb(0.72, 0.72, 0.75),
            perceptual_roughness: 0.65,
            ..default()
        })
    };
    commands.entity(root).with_children(|parent| {
        parent.spawn((
            RenderLayers::layer(THUMBNAIL_LAYER),
            Mesh3d(mesh),
            MeshMaterial3d(material),
            Transform::default(),
        ));
    });
    true
}

fn spawn_material_sphere(
    commands: &mut Commands,
    mesh_storage: &mut Assets<Mesh>,
    root: Entity,
    material: Handle<StandardMaterial>,
) -> bool {
    let mesh = mesh_storage.add(Sphere::new(0.65).mesh().uv(48, 24));
    commands.entity(root).with_children(|parent| {
        parent.spawn((
            RenderLayers::layer(THUMBNAIL_LAYER),
            Mesh3d(mesh),
            MeshMaterial3d(material),
            Transform::default(),
        ));
    });
    true
}

fn labeled_asset_path(path: &str, label: &str) -> String {
    format!("{path}#{label}")
}

fn studio_render_image() -> Image {
    Image::new_target_texture(THUMB_SIZE, THUMB_SIZE, TextureFormat::Rgba16Float, None)
}

fn finish_active_thumbnail(
    commands: &mut Commands,
    studio: &mut ThumbnailStudio,
    cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
    camera_targets: &mut Query<&mut RenderTarget, With<ThumbnailStudioCamera>>,
    active: ActiveModelThumbnail,
) {
    despawn_thumbnail_root(commands, active.root);
    if let Ok(mut target) = camera_targets.get_mut(studio.camera) {
        *target = RenderTarget::Image(studio.render_image.clone().into());
    }
    deactivate_studio_camera(cameras, studio.camera);
    studio.cooldown_frames = COOLDOWN_FRAMES;
}

#[expect(
    clippy::too_many_arguments,
    reason = "thumbnail failure coordinates the job, cache, render target, and camera state"
)]
fn fail_active_thumbnail(
    commands: &mut Commands,
    studio: &mut ThumbnailStudio,
    cache: &mut AssetThumbnailCache,
    cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
    camera_targets: &mut Query<&mut RenderTarget, With<ThumbnailStudioCamera>>,
    active: ActiveModelThumbnail,
    reason: &str,
    retryable: bool,
) {
    cache.mark_studio_failed(&active.cache_key, reason, retryable);
    finish_active_thumbnail(commands, studio, cameras, camera_targets, active);
}

fn apply_thumbnail_studio_layers(
    mut commands: Commands,
    roots: Query<Entity, With<ThumbnailSceneRoot>>,
    children: Query<&Children>,
    tagged: Query<(), With<ThumbnailStudioLayer>>,
) {
    for root in &roots {
        let mut stack = vec![root];
        while let Some(entity) = stack.pop() {
            if tagged.get(entity).is_err() {
                commands
                    .entity(entity)
                    .insert((ThumbnailStudioLayer, RenderLayers::layer(THUMBNAIL_LAYER)));
            }
            if let Ok(kids) = children.get(entity) {
                stack.extend(kids.iter());
            }
        }
    }
}

fn deactivate_studio_camera(
    cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
    camera: Entity,
) {
    if let Ok(mut camera) = cameras.get_mut(camera) {
        camera.is_active = false;
    }
}

fn despawn_thumbnail_root(commands: &mut Commands, root: Entity) {
    commands.entity(root).despawn_children();
    commands.entity(root).despawn();
}

fn thumbnail_meshes_present(
    root: Entity,
    children: &Query<&Children>,
    meshes: &Query<&Mesh3d>,
    mesh_assets: &Assets<Mesh>,
) -> bool {
    let mut entities = Vec::new();
    collect_descendants(root, children, &mut entities);

    entities.iter().any(|&entity| {
        let Ok(mesh3d) = meshes.get(entity) else {
            return false;
        };
        mesh_assets
            .get(&mesh3d.0)
            .is_some_and(|mesh| mesh_positions(mesh).next().is_some())
    })
}

fn mesh_positions(mesh: &Mesh) -> impl Iterator<Item = [f32; 3]> + '_ {
    let positions = mesh
        .attribute(Mesh::ATTRIBUTE_POSITION)
        .and_then(|values| match values {
            VertexAttributeValues::Float32x3(positions) => Some(positions.as_slice()),
            _ => None,
        });
    positions
        .into_iter()
        .flat_map(|slice| slice.iter().copied())
}

fn frame_thumbnail_scene(
    root: Entity,
    children: &Query<&Children>,
    transforms: &Query<&GlobalTransform>,
    meshes: &Query<&Mesh3d>,
    mesh_assets: &Assets<Mesh>,
    camera_transforms: &mut Query<&mut Transform, With<ThumbnailStudioCamera>>,
    framing: ThumbnailFraming,
) -> bool {
    let mut entities = Vec::new();
    collect_descendants(root, children, &mut entities);

    let mut min = Vec3::splat(f32::INFINITY);
    let mut max = Vec3::splat(f32::NEG_INFINITY);
    let mut found_mesh = false;

    for entity in entities {
        let Ok(mesh3d) = meshes.get(entity) else {
            continue;
        };
        let Ok(global) = transforms.get(entity) else {
            continue;
        };

        if let Some(mesh) = mesh_assets.get(&mesh3d.0) {
            let mut local_min = Vec3::splat(f32::INFINITY);
            let mut local_max = Vec3::splat(f32::NEG_INFINITY);
            let mut has_positions = false;
            for position in mesh_positions(mesh) {
                let p = Vec3::from_array(position);
                local_min = local_min.min(p);
                local_max = local_max.max(p);
                has_positions = true;
            }
            if has_positions {
                found_mesh = true;
                for corner in aabb_corners(&Aabb3d::from_min_max(
                    Vec3A::from(local_min),
                    Vec3A::from(local_max),
                )) {
                    let world = global.transform_point(corner);
                    min = min.min(world);
                    max = max.max(world);
                }
            }
        }
    }

    if !found_mesh {
        return false;
    }

    let center = (min + max) * 0.5;
    let distance = thumbnail_camera_distance(min, max, framing);
    let eye = center + Vec3::new(1.0, 0.75, 1.0).normalize() * distance;

    if let Ok(mut transform) = camera_transforms.single_mut() {
        *transform = Transform::from_translation(eye).looking_at(center, Vec3::Y);
    }

    true
}

fn thumbnail_camera_distance(min: Vec3, max: Vec3, framing: ThumbnailFraming) -> f32 {
    let bounds_size = max - min;
    let (radius, padding) = match framing {
        ThumbnailFraming::SceneBounds => {
            (bounds_size.length().max(0.25) * 0.5, SCENE_FRAME_PADDING)
        }
        ThumbnailFraming::MaterialSphere => (
            bounds_size.max_element().max(0.5) * 0.5,
            MATERIAL_SPHERE_FRAME_PADDING,
        ),
    };
    radius / THUMBNAIL_HALF_VERTICAL_FOV.tan() * padding
}

fn collect_descendants(entity: Entity, children: &Query<&Children>, out: &mut Vec<Entity>) {
    out.push(entity);
    let Ok(child_list) = children.get(entity) else {
        return;
    };
    for child in child_list.iter() {
        collect_descendants(child, children, out);
    }
}

fn aabb_corners(aabb: &Aabb3d) -> [Vec3; 8] {
    let min = Vec3::from(aabb.min);
    let max = Vec3::from(aabb.max);
    [
        Vec3::new(min.x, min.y, min.z),
        Vec3::new(max.x, min.y, min.z),
        Vec3::new(min.x, max.y, min.z),
        Vec3::new(max.x, max.y, min.z),
        Vec3::new(min.x, min.y, max.z),
        Vec3::new(max.x, min.y, max.z),
        Vec3::new(min.x, max.y, max.z),
        Vec3::new(max.x, max.y, max.z),
    ]
}

/// Skip studio jobs when the source file is missing on disk.
pub fn model_file_exists(catalog_path: &str) -> bool {
    Path::new(catalog_path).is_file()
}

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

    #[test]
    fn material_sphere_framing_fills_more_of_the_thumbnail_with_padding() {
        let sphere_radius = 0.65;
        let min = Vec3::splat(-sphere_radius);
        let max = Vec3::splat(sphere_radius);

        let scene_distance = thumbnail_camera_distance(min, max, ThumbnailFraming::SceneBounds);
        let material_distance =
            thumbnail_camera_distance(min, max, ThumbnailFraming::MaterialSphere);
        let projected_radius_fraction =
            sphere_radius / (material_distance * THUMBNAIL_HALF_VERTICAL_FOV.tan());

        assert!(material_distance < scene_distance * 0.6);
        assert!((0.75..0.82).contains(&projected_radius_fraction));
    }
}
