//! Demonstrates visibility ranges, also known as HLODs.

use std::f32::consts::PI;

use bevy::{
    camera::visibility::{NoCpuCulling, VisibilityRange},
    core_pipeline::prepass::{DepthPrepass, NormalPrepass},
    input::mouse::MouseWheel,
    light::{light_consts::lux::FULL_DAYLIGHT, CascadeShadowConfigBuilder},
    math::vec3,
    prelude::*,
};

use argh::FromArgs;

// Where the camera is focused.
const CAMERA_FOCAL_POINT: Vec3 = vec3(0.0, 0.3, 0.0);
// Speed in units per frame.
const CAMERA_KEYBOARD_ZOOM_SPEED: f32 = 0.05;
// Speed in radians per frame.
const CAMERA_KEYBOARD_PAN_SPEED: f32 = 0.01;
// Speed in units per frame.
const CAMERA_MOUSE_MOVEMENT_SPEED: f32 = 0.25;
// The minimum distance that the camera is allowed to be from the model.
const MIN_ZOOM_DISTANCE: f32 = 0.5;

// The visibility ranges for high-poly and low-poly models respectively, when
// both models are being shown.
static NORMAL_VISIBILITY_RANGE_HIGH_POLY: VisibilityRange = VisibilityRange {
    start_margin: 0.0..0.0,
    end_margin: 3.0..4.0,
    use_aabb: false,
};
static NORMAL_VISIBILITY_RANGE_LOW_POLY: VisibilityRange = VisibilityRange {
    start_margin: 3.0..4.0,
    end_margin: 8.0..9.0,
    use_aabb: false,
};

// A visibility model that we use to always show a model (until the camera is so
// far zoomed out that it's culled entirely).
static SINGLE_MODEL_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
    start_margin: 0.0..0.0,
    end_margin: 8.0..9.0,
    use_aabb: false,
};

// A visibility range that we use to completely hide a model.
static INVISIBLE_VISIBILITY_RANGE: VisibilityRange = VisibilityRange {
    start_margin: 0.0..0.0,
    end_margin: 0.0..0.0,
    use_aabb: false,
};

// Allows us to identify the main model.
#[derive(Component, Debug, Clone, Copy, PartialEq)]
enum MainModel {
    // The high-poly version.
    HighPoly,
    // The low-poly version.
    LowPoly,
}

// The current mode.
#[derive(Default, Resource)]
struct AppStatus {
    // Whether to show only one model.
    show_one_model_only: Option<MainModel>,
    // Whether to enable the prepass.
    prepass: bool,
}

/// Demonstrates visibility ranges, also known as HLODs
#[derive(FromArgs, Resource)]
struct Args {
    /// whether to use GPU culling only
    #[argh(switch)]
    no_cpu_culling: bool,
}

fn main() {
    #[cfg(not(target_arch = "wasm32"))]
    let args: Args = argh::from_env();
    #[cfg(target_arch = "wasm32")]
    let args = Args::from_args(&[], &[]).unwrap();

    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Visibility Range Example".into(),
                ..default()
            }),
            ..default()
        }))
        .init_resource::<AppStatus>()
        .insert_resource(args)
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                move_camera,
                set_visibility_ranges,
                update_help_text,
                update_mode,
                toggle_prepass,
            ),
        )
        .run();
}

// Set up a simple 3D scene. Load the two meshes.
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    asset_server: Res<AssetServer>,
    app_status: Res<AppStatus>,
) {
    // Spawn a plane.
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(50.0, 50.0))),
        MeshMaterial3d(materials.add(Color::srgb(0.1, 0.2, 0.1))),
    ));

    // Spawn the two HLODs.

    commands.spawn((
        WorldAssetRoot(
            asset_server
                .load(GltfAssetLabel::Scene(0).from_asset("models/FlightHelmet/FlightHelmet.gltf")),
        ),
        MainModel::HighPoly,
    ));

    commands.spawn((
        WorldAssetRoot(
            asset_server.load(
                GltfAssetLabel::Scene(0)
                    .from_asset("models/FlightHelmetLowPoly/FlightHelmetLowPoly.gltf"),
            ),
        ),
        MainModel::LowPoly,
    ));

    // Spawn a light.
    commands.spawn((
        DirectionalLight {
            illuminance: FULL_DAYLIGHT,
            shadow_maps_enabled: true,
            ..default()
        },
        Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, PI * -0.15, PI * -0.15)),
        CascadeShadowConfigBuilder {
            maximum_distance: 30.0,
            first_cascade_far_bound: 0.9,
            ..default()
        }
        .build(),
    ));

    // Spawn a camera.
    commands
        .spawn((
            Camera3d::default(),
            Transform::from_xyz(0.7, 0.7, 1.0).looking_at(CAMERA_FOCAL_POINT, Vec3::Y),
        ))
        .insert(EnvironmentMapLight {
            diffuse_map: asset_server.load("environment_maps/pisa_diffuse_rgb9e5_zstd.ktx2"),
            specular_map: asset_server.load("environment_maps/pisa_specular_rgb9e5_zstd.ktx2"),
            intensity: 150.0,
            ..default()
        });

    // Create the text.
    commands.spawn((
        app_status.create_text(),
        Node {
            position_type: PositionType::Absolute,
            bottom: px(12),
            left: px(12),
            ..default()
        },
    ));
}

// We need to add the `VisibilityRange` components manually, as glTF currently
// has no way to specify visibility ranges. This system watches for new meshes,
// determines which `Scene` they're under, and adds the `VisibilityRange`
// component as appropriate.
fn set_visibility_ranges(
    mut commands: Commands,
    mut new_meshes: Query<Entity, Added<Mesh3d>>,
    children: Query<(Option<&ChildOf>, Option<&MainModel>)>,
    args: Res<Args>,
) {
    // Loop over each newly-added mesh.
    for new_mesh in new_meshes.iter_mut() {
        // Search for the nearest ancestor `MainModel` component.
        let (mut current, mut main_model) = (new_mesh, None);
        while let Ok((child_of, maybe_main_model)) = children.get(current) {
            if let Some(model) = maybe_main_model {
                main_model = Some(model);
                break;
            }
            match child_of {
                Some(child_of) => current = child_of.parent(),
                None => break,
            }
        }

        // Add the `VisibilityRange` component.
        match main_model {
            Some(MainModel::HighPoly) => {
                let mut entity_commands = commands.entity(new_mesh);
                entity_commands
                    .insert(NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone())
                    .insert(MainModel::HighPoly);
                if args.no_cpu_culling {
                    entity_commands.insert(NoCpuCulling);
                }
            }
            Some(MainModel::LowPoly) => {
                let mut entity_commands = commands.entity(new_mesh);
                entity_commands
                    .insert(NORMAL_VISIBILITY_RANGE_LOW_POLY.clone())
                    .insert(MainModel::LowPoly);
                if args.no_cpu_culling {
                    entity_commands.insert(NoCpuCulling);
                }
            }
            None => {}
        }
    }
}

// Process the movement controls.
fn move_camera(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut mouse_wheel_reader: MessageReader<MouseWheel>,
    mut cameras: Query<&mut Transform, With<Camera3d>>,
) {
    let (mut zoom_delta, mut theta_delta) = (0.0, 0.0);

    // Process zoom in and out via the keyboard.
    if keyboard_input.pressed(KeyCode::KeyW) || keyboard_input.pressed(KeyCode::ArrowUp) {
        zoom_delta -= CAMERA_KEYBOARD_ZOOM_SPEED;
    } else if keyboard_input.pressed(KeyCode::KeyS) || keyboard_input.pressed(KeyCode::ArrowDown) {
        zoom_delta += CAMERA_KEYBOARD_ZOOM_SPEED;
    }

    // Process left and right pan via the keyboard.
    if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
        theta_delta -= CAMERA_KEYBOARD_PAN_SPEED;
    } else if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
        theta_delta += CAMERA_KEYBOARD_PAN_SPEED;
    }

    // Process zoom in and out via the mouse wheel.
    for mouse_wheel in mouse_wheel_reader.read() {
        zoom_delta -= mouse_wheel.y * CAMERA_MOUSE_MOVEMENT_SPEED;
    }

    // Update the camera transform.
    for transform in cameras.iter_mut() {
        let transform = transform.into_inner();

        let direction = transform.translation.normalize_or_zero();
        let magnitude = transform.translation.length();

        let new_direction = Mat3::from_rotation_y(theta_delta) * direction;
        let new_magnitude = (magnitude + zoom_delta).max(MIN_ZOOM_DISTANCE);

        transform.translation = new_direction * new_magnitude;
        transform.look_at(CAMERA_FOCAL_POINT, Vec3::Y);
    }
}

// Toggles modes if the user requests.
fn update_mode(
    mut meshes: Query<(&mut VisibilityRange, &MainModel)>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut app_status: ResMut<AppStatus>,
) {
    // Toggle the mode as requested.
    if keyboard_input.just_pressed(KeyCode::Digit1) || keyboard_input.just_pressed(KeyCode::Numpad1)
    {
        app_status.show_one_model_only = None;
    } else if keyboard_input.just_pressed(KeyCode::Digit2)
        || keyboard_input.just_pressed(KeyCode::Numpad2)
    {
        app_status.show_one_model_only = Some(MainModel::HighPoly);
    } else if keyboard_input.just_pressed(KeyCode::Digit3)
        || keyboard_input.just_pressed(KeyCode::Numpad3)
    {
        app_status.show_one_model_only = Some(MainModel::LowPoly);
    } else {
        return;
    }

    // Update the visibility ranges as appropriate.
    for (mut visibility_range, main_model) in meshes.iter_mut() {
        *visibility_range = match (main_model, app_status.show_one_model_only) {
            (&MainModel::HighPoly, Some(MainModel::LowPoly))
            | (&MainModel::LowPoly, Some(MainModel::HighPoly)) => {
                INVISIBLE_VISIBILITY_RANGE.clone()
            }
            (&MainModel::HighPoly, Some(MainModel::HighPoly))
            | (&MainModel::LowPoly, Some(MainModel::LowPoly)) => {
                SINGLE_MODEL_VISIBILITY_RANGE.clone()
            }
            (&MainModel::HighPoly, None) => NORMAL_VISIBILITY_RANGE_HIGH_POLY.clone(),
            (&MainModel::LowPoly, None) => NORMAL_VISIBILITY_RANGE_LOW_POLY.clone(),
        }
    }
}

// Toggles the prepass if the user requests.
fn toggle_prepass(
    mut commands: Commands,
    cameras: Query<Entity, With<Camera3d>>,
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut app_status: ResMut<AppStatus>,
) {
    if !keyboard_input.just_pressed(KeyCode::Space) {
        return;
    }

    app_status.prepass = !app_status.prepass;

    for camera in cameras.iter() {
        if app_status.prepass {
            commands
                .entity(camera)
                .insert(DepthPrepass)
                .insert(NormalPrepass);
        } else {
            commands
                .entity(camera)
                .remove::<DepthPrepass>()
                .remove::<NormalPrepass>();
        }
    }
}

// A system that updates the help text.
fn update_help_text(mut text_query: Query<&mut Text>, app_status: Res<AppStatus>) {
    for mut text in text_query.iter_mut() {
        *text = app_status.create_text();
    }
}

impl AppStatus {
    // Creates and returns help text reflecting the app status.
    fn create_text(&self) -> Text {
        format!(
            "\
{} (1) Switch from high-poly to low-poly based on camera distance
{} (2) Show only the high-poly model
{} (3) Show only the low-poly model
Press 1, 2, or 3 to switch which model is shown
Press WASD or use the mouse wheel to move the camera
Press Space to {} the prepass",
            if self.show_one_model_only.is_none() {
                '>'
            } else {
                ' '
            },
            if self.show_one_model_only == Some(MainModel::HighPoly) {
                '>'
            } else {
                ' '
            },
            if self.show_one_model_only == Some(MainModel::LowPoly) {
                '>'
            } else {
                ' '
            },
            if self.prepass { "disable" } else { "enable" }
        )
        .into()
    }
}
