#![forbid(unsafe_code)]

mod app_state;
mod load;

use app_state::AppState;
use bevy::{
    ecs::schedule::ShouldRun,
    prelude::*,
    render::camera::RenderTarget,
    window::{PresentMode, WindowMode},
};
use bevy_rapier2d::prelude::*;
use load::{decode_audio, load_setup, AudioServerInternal, SparksAtlas};
use rand::Rng;

const WINDOW_WIDTH: f32 = 1024.0;
const WINDOW_HEIGHT: f32 = 576.0;
const SPRITE_SCALE: f32 = 2.0;
const PIXELS_PER_METER: f32 = 32.0;

struct CursorPos(Vec2);

#[derive(Component)]
struct MainCamera;

#[derive(Component)]
struct PlayerBox;

#[derive(Component)]
struct AnimationTimer(Timer);

struct ReadyForPhysics(bool, u8);

fn main() {
    App::new()
        .insert_resource(WindowDescriptor {
            width: WINDOW_WIDTH,
            height: WINDOW_HEIGHT,
            title: "Bevy demo!!".to_owned(),
            present_mode: PresentMode::Fifo,
            resizable: false,
            cursor_visible: true,
            mode: WindowMode::Windowed,
            transparent: false,
            ..default()
        })
        .insert_resource(ClearColor(Color::rgb(0.3, 0.34, 0.32)))
        .insert_resource(ReadyForPhysics(false, 0))
        .add_plugins(DefaultPlugins)
        .add_plugin(
            RapierPhysicsPlugin::<()>::pixels_per_meter(PIXELS_PER_METER)
                .with_default_system_setup(false),
        )
        .add_state(AppState::Load)
        .add_system_set(
            SystemSet::on_enter(AppState::Load).with_system(load_setup),
        )
        .add_system_set(
            SystemSet::on_update(AppState::Load).with_system(decode_audio),
        )
        .add_system_set(
            SystemSet::on_enter(AppState::Play).with_system(play_setup),
        )
        .add_system_set(
            SystemSet::on_update(AppState::Play)
                .with_system(convert_cursor_pos)
                .with_system(control_player_box.after(convert_cursor_pos))
                .with_system(process_collisions)
                .with_system(animate_sprites)
                .with_system(add_colliders),
        )
        .add_stage_after(
            CoreStage::Update,
            PhysicsStages::SyncBackend,
            SystemStage::parallel().with_system_set(
                RapierPhysicsPlugin::<()>::get_systems(
                    PhysicsStages::SyncBackend,
                )
                .with_run_criteria(ready_for_physics),
            ),
        )
        .add_stage_after(
            PhysicsStages::SyncBackend,
            PhysicsStages::StepSimulation,
            SystemStage::parallel().with_system_set(
                RapierPhysicsPlugin::<()>::get_systems(
                    PhysicsStages::StepSimulation,
                )
                .with_run_criteria(ready_for_physics),
            ),
        )
        .add_stage_after(
            PhysicsStages::StepSimulation,
            PhysicsStages::Writeback,
            SystemStage::parallel().with_system_set(
                RapierPhysicsPlugin::<()>::get_systems(
                    PhysicsStages::Writeback,
                )
                .with_run_criteria(ready_for_physics),
            ),
        )
        .add_stage_before(
            CoreStage::Last,
            PhysicsStages::DetectDespawn,
            SystemStage::parallel().with_system_set(
                RapierPhysicsPlugin::<()>::get_systems(
                    PhysicsStages::DetectDespawn,
                )
                .with_run_criteria(ready_for_physics),
            ),
        )
        .run();
}

fn ready_for_physics(rfp: Res<ReadyForPhysics>) -> ShouldRun {
    if rfp.0 {
        ShouldRun::Yes
    } else {
        ShouldRun::No
    }
}

fn play_setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut rfp: ResMut<ReadyForPhysics>,
) {
    let mut rng = rand::thread_rng();

    // Basic global stuff.
    commands
        .spawn()
        .insert(MainCamera)
        .insert_bundle(OrthographicCameraBundle::new_2d());
    commands.insert_resource(CursorPos(default()));

    // Physical walls/boundaries.
    commands
        .spawn()
        .insert(Collider::halfspace(-Vec2::X).unwrap())
        .insert_bundle(TransformBundle::from(Transform::from_xyz(
            WINDOW_WIDTH / 2.0,
            0.0,
            0.0,
        )));
    commands
        .spawn()
        .insert(Collider::halfspace(-Vec2::Y).unwrap())
        .insert_bundle(TransformBundle::from(Transform::from_xyz(
            0.0,
            WINDOW_HEIGHT / 2.0,
            0.0,
        )));
    commands
        .spawn()
        .insert(Collider::halfspace(Vec2::X).unwrap())
        .insert_bundle(TransformBundle::from(Transform::from_xyz(
            -WINDOW_WIDTH / 2.0,
            0.0,
            0.0,
        )));
    commands
        .spawn()
        .insert(Collider::halfspace(Vec2::Y).unwrap())
        .insert_bundle(TransformBundle::from(Transform::from_xyz(
            0.0,
            -WINDOW_HEIGHT / 2.0,
            0.0,
        )));

    // Boxes.
    commands
        .spawn()
        .insert(PlayerBox)
        .insert(Velocity::default())
        .insert(ExternalForce::default())
        .insert(Damping {
            linear_damping: 2.0,
            angular_damping: 1.0,
        })
        .insert_bundle(SpriteBundle {
            texture: asset_server.load("metal.png"),
            transform: Transform {
                translation: Vec3::ZERO,
                rotation: Quat::IDENTITY,
                scale: Vec3::splat(SPRITE_SCALE),
            },
            ..default()
        });

    let wood_th = asset_server.load("wood.png");
    for _ in 0..16 {
        commands
            .spawn()
            .insert(Velocity::default())
            .insert(Damping {
                linear_damping: 2.0,
                angular_damping: 1.0,
            })
            .insert(Restitution {
                coefficient: 0.85,
                combine_rule: CoefficientCombineRule::Max,
            })
            .insert_bundle(SpriteBundle {
                texture: wood_th.clone(),
                transform: Transform {
                    translation: Vec3::new(
                        rng.gen::<f32>() * (WINDOW_WIDTH - 50.0)
                            - (WINDOW_WIDTH / 2.0 - 25.0),
                        rng.gen::<f32>() * (WINDOW_HEIGHT / 2.0 - 25.0),
                        0.0,
                    ),
                    rotation: Quat::from_rotation_z(
                        rng.gen::<f32>() * ::std::f32::consts::TAU,
                    ),
                    scale: Vec3::splat(SPRITE_SCALE),
                },
                ..default()
            });
    }

    rfp.0 = true;
}

fn add_colliders(
    mut commands: Commands,
    mut rfp: ResMut<ReadyForPhysics>,
    vel_q: Query<(Entity, Option<&PlayerBox>), With<Velocity>>,
) {
    match rfp.1 {
        0 => rfp.1 += 1,
        1 => {
            rfp.1 += 1;
            for (ent, pb) in vel_q.iter() {
                commands
                    .entity(ent)
                    .insert(RigidBody::Dynamic)
                    .insert(Collider::cuboid(12.5, 12.5))
                    .insert(GravityScale(if pb.is_some() {
                        0.0
                    } else {
                        8.0
                    }));
            }
        }
        _ => (),
    }
}

/// <https://en.wikipedia.org/wiki/Hooke%27s_law>
fn control_player_box(
    cursor_pos: Res<CursorPos>,
    mut player_box_q: Query<(&Transform, &mut ExternalForce), With<PlayerBox>>,
) {
    let (player_box_tf, mut player_box_force) = player_box_q.single_mut();
    let cursor_box_disp = cursor_pos.0 - player_box_tf.translation.truncate();
    *player_box_force = ExternalForce {
        force: 48.0 * cursor_box_disp,
        torque: 0.0,
    };
}

fn process_collisions(
    mut commands: Commands,
    rapier_context: Res<RapierContext>,
    sparks_atlas: Res<SparksAtlas>,
    asi: Res<AudioServerInternal>,
    audio: Res<Audio>,
    box_q: Query<(Entity, &Velocity, Option<&PlayerBox>)>,
) {
    let (mut sparks_sounded, mut wood_sounded) = (false, false);

    // Query Rapier for any contact pairs that were just generated during this
    // frame.
    for contact_pair in rapier_context.contact_pairs() {
        // If this contact pair was determined not to be an active contact
        // during the narrow-phase, we ignore it, as it does not actually
        // represent a contact at all.
        if !contact_pair.has_any_active_contacts() {
            continue;
        }

        let (collider1_vel, collider1_is_player) = box_q
            .get(contact_pair.collider1())
            .map(|(_, v, pb)| (v.linvel, pb.is_some()))
            .unwrap_or((Vec2::ZERO, false));
        let (collider2_vel, collider2_is_player) = box_q
            .get(contact_pair.collider2())
            .map(|(_, v, pb)| (v.linvel, pb.is_some()))
            .unwrap_or((Vec2::ZERO, false));

        // Observe just one manifold (there probably is only one anyways, and
        // it seems that one is all that we need) associated with this contact
        // pair.
        if let Some(manifold) = contact_pair.manifolds().next() {
            // The normal is already expressed in world-space.
            let manifold_normal = manifold.normal();

            // Solver contacts are conveniently already expressed in
            // world-space.
            for solver_contact in manifold.solver_contacts() {
                // We check if the contact is new. Without this, we would
                // continuously generate sparks for two colliders that are
                // merely pressed up against each other.
                if !solver_contact.is_new() {
                    continue;
                }

                let mut rng = rand::thread_rng();

                if collider1_is_player || collider2_is_player {
                    if !sparks_sounded {
                        sparks_sounded = true;

                        if let Some(audio_handle) = asi.get(if rng.gen() {
                            "clang.ogg"
                        } else {
                            "clang1.ogg"
                        }) {
                            let player_box_speed = if collider1_is_player {
                                collider1_vel.length()
                            } else {
                                collider2_vel.length()
                            };
                            let speed_normalised =
                                player_box_speed.min(2_048.0) / 2_048.0;
                            audio.play_with_settings(
                                audio_handle,
                                PlaybackSettings {
                                    repeat: false,
                                    volume: 0.875
                                        * speed_normalised
                                        * speed_normalised,
                                    ..default()
                                },
                            );
                        }
                    }

                    commands
                        .spawn_bundle(SpriteSheetBundle {
                            texture_atlas: sparks_atlas.0.clone(),
                            transform: Transform {
                                translation: (PIXELS_PER_METER
                                    * solver_contact.point())
                                .extend(10.0),
                                rotation: Quat::from_rotation_arc_2d(
                                    Vec2::Y,
                                    manifold_normal,
                                ),
                                scale: Vec3::splat(SPRITE_SCALE),
                            },
                            ..default()
                        })
                        .insert(AnimationTimer(Timer::from_seconds(
                            0.1, true,
                        )));
                } else if !wood_sounded {
                    wood_sounded = true;

                    if let Some(audio_handle) = asi.get(if rng.gen() {
                        "wood_strike.ogg"
                    } else {
                        "wood_strike1.ogg"
                    }) {
                        let box_speed =
                            collider1_vel.length().max(collider2_vel.length());
                        let speed_normalised = box_speed.min(768.0) / 768.0;
                        if speed_normalised >= 0.062_5 {
                            audio.play_with_settings(
                                audio_handle,
                                PlaybackSettings {
                                    repeat: false,
                                    volume: 0.875
                                        * speed_normalised
                                        * speed_normalised,
                                    ..default()
                                },
                            );
                        }
                    }
                }
            }
        }
    }
}

fn animate_sprites(
    mut commands: Commands,
    time: Res<Time>,
    texture_atlases: Res<Assets<TextureAtlas>>,
    mut query: Query<(
        Entity,
        &mut AnimationTimer,
        &mut TextureAtlasSprite,
        &Handle<TextureAtlas>,
    )>,
) {
    for (entity, mut timer, mut sprite, texture_atlas_handle) in
        query.iter_mut()
    {
        timer.0.tick(time.delta());
        if timer.0.just_finished() {
            let texture_atlas =
                texture_atlases.get(texture_atlas_handle).unwrap();
            sprite.index += 1;
            if sprite.index >= texture_atlas.textures.len() {
                commands.entity(entity).despawn();
            }
        }
    }
}

/// Partially nabbed from
/// <https://bevy-cheatbook.github.io/cookbook/cursor2world.html#2d-games>.
fn convert_cursor_pos(
    windows: Res<Windows>,
    mut cursor_pos: ResMut<CursorPos>,
    camera_q: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
    mut cursor_left_er: EventReader<CursorLeft>,
) {
    // Get the camera info and its transform.
    // We assume that there is exactly one main camera entity, thus
    // `Query::single()` is okay to use.
    let (camera, camera_transform) = camera_q.single();

    // Get the window that the camera is displaying to; failing that, we just
    // get the primary window.
    let window = if let RenderTarget::Window(wid) = camera.target {
        windows.get(wid).unwrap()
    } else {
        windows.get_primary().unwrap()
    };

    // Check if the cursor is inside the window; if so, we get its position
    // within the window.
    if let Some(screen_pos) = window.cursor_position() {
        let window_size = Vec2::new(window.width(), window.height());

        // Convert screen position, which represents each component as a member
        // of [0, `resolution`], to NDC (“normalised device coordinates”,
        // a.k.a. GPU coordinates), which instead represent each coordinate as
        // a member of [-1, 1].
        let ndc = (screen_pos / window_size) * 2.0 - Vec2::ONE;

        // Matrix for undoing the projection and camera transform.
        let ndc_to_world = camera_transform.compute_matrix()
            * camera.projection_matrix.inverse();

        // Use the above matrix to convert NDC to world-space coordinates.
        let world_pos = ndc_to_world.project_point3(ndc.extend(-1.0));

        // Reduce back to two dimensions.
        let world_pos = world_pos.truncate();

        *cursor_pos = CursorPos(world_pos);
    }

    // Clamp the cursor position to the nearest point on the border of the
    // window, should the cursor leave the window.
    for left_ev in cursor_left_er.iter() {
        if left_ev.id == window.id() {
            // This condition determines whether the old cursor position is
            // closer to a horizontal edge of the window (top or bottom edge),
            // or to a vertical edge of the window (left or right edge). Then,
            // we know whether to clamp the Y coordinate, or the X coordinate,
            // respectively.
            let clamped_pos = if WINDOW_WIDTH / 2.0 - cursor_pos.0.x.abs()
                >= WINDOW_HEIGHT / 2.0 - cursor_pos.0.y.abs()
            {
                Vec2::new(
                    cursor_pos.0.x,
                    if cursor_pos.0.y >= 0.0 {
                        WINDOW_HEIGHT / 2.0
                    } else {
                        -WINDOW_HEIGHT / 2.0
                    },
                )
            } else {
                Vec2::new(
                    if cursor_pos.0.x >= 0.0 {
                        WINDOW_WIDTH / 2.0
                    } else {
                        -WINDOW_WIDTH / 2.0
                    },
                    cursor_pos.0.y,
                )
            };

            *cursor_pos = CursorPos(clamped_pos);
        }
    }
}
