use bytemuck::{Pod, Zeroable};
use glam::{Mat4, Vec3};
use wgpu::util::DeviceExt;

#[derive(Debug)]
pub struct Projection {
	pub aspect: f32,
	pub fovy: f32,
	pub znear: f32,
	pub zfar: f32,
}

impl Projection {
	pub fn resize(&mut self, width: f32, height: f32) {
		self.aspect = width / height;
	}

	#[must_use]
	pub fn to_perspective_matrix(&self) -> Mat4 {
		Mat4::perspective_rh(self.fovy, self.aspect, self.znear, self.zfar)
	}
}

#[derive(Debug)]
pub struct Settings {
	pub position: Vec3,
	pub yaw: f32,
	pub pitch: f32,

	pub projection: Projection,
}

impl Settings {
	#[must_use]
	pub fn new(width: f32, height: f32) -> Self {
		Self {
			position: Vec3::new(-2.0, 0.0, 0.0),
			yaw: 0.0,
			pitch: 0.0,
			projection: Projection {
				aspect: width / height,
				fovy: 80f32.to_radians(),
				znear: 0.1,
				zfar: 1000.0,
			},
		}
	}

	#[must_use]
	pub fn to_view_projection_matrix(&self) -> Mat4 {
		let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
		let (sin_yaw, cos_yaw) = self.yaw.sin_cos();

		self.projection.to_perspective_matrix()
			* Mat4::look_to_rh(
				self.position,
				Vec3::new(cos_pitch * cos_yaw, sin_pitch, cos_pitch * sin_yaw).normalize(),
				Vec3::Y,
			)
	}
}

#[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
pub struct CameraUniform {
	projection: [[f32; 4]; 4],
	position: [f32; 3],
	_padding: u32,
}

impl Default for CameraUniform {
	fn default() -> Self {
		Self {
			projection: Mat4::IDENTITY.to_cols_array_2d(),
			position: Vec3::ZERO.into(),
			_padding: 0,
		}
	}
}

impl CameraUniform {
	#[must_use]
	pub fn from_settings(settings: &Settings) -> Self {
		Self {
			projection: settings.to_view_projection_matrix().to_cols_array_2d(),
			position: settings.position.into(),
			_padding: 0,
		}
	}

	pub fn into_gpu(self, device: &wgpu::Device) -> GpuCamera {
		let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
			label: Some("Camera Buffer"),
			contents: bytemuck::cast_slice(&[self]),
			usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
		});

		let camera_bind_group_layout =
			device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
				entries: &[wgpu::BindGroupLayoutEntry {
					binding: 0,
					visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
					ty: wgpu::BindingType::Buffer {
						ty: wgpu::BufferBindingType::Uniform,
						has_dynamic_offset: false,
						min_binding_size: None,
					},
					count: None,
				}],
				label: Some("camera_bind_group_layout"),
			});

		let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
			layout: &camera_bind_group_layout,
			entries: &[wgpu::BindGroupEntry {
				binding: 0,
				resource: camera_buffer.as_entire_binding(),
			}],
			label: Some("camera_bind_group"),
		});

		GpuCamera {
			uniform: self,
			buffer: camera_buffer,
			bind_group: camera_bind_group,
			bind_group_layout: camera_bind_group_layout,
		}
	}
}

#[must_use]
#[derive(Debug)]
pub struct GpuCamera {
	pub(crate) uniform: CameraUniform,

	pub buffer: wgpu::Buffer,
	pub bind_group: wgpu::BindGroup,
	pub bind_group_layout: wgpu::BindGroupLayout,
}

impl GpuCamera {
	fn update(&mut self, queue: &wgpu::Queue, rotation: Mat4, position: Vec3) {
		self.uniform.position = position.into();
		self.uniform.projection = rotation.to_cols_array_2d();

		queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(&[self.uniform]));
	}

	pub fn recreate_bind_group(&mut self, device: &wgpu::Device) {
		self.bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
			layout: &self.bind_group_layout,
			entries: &[wgpu::BindGroupEntry {
				binding: 0,
				resource: self.buffer.as_entire_binding(),
			}],
			label: Some("camera_bind_group"),
		});
	}
}

#[derive(Debug)]
pub struct Camera {
	pub gpu: GpuCamera,
	pub settings: Settings,
}

impl Camera {
	/// Create a new camera with the given GPU camera and settings.
	#[must_use]
	pub fn new(gpu: GpuCamera, settings: Settings) -> Self {
		Self { gpu, settings }
	}

	/// Update the camera's view and projection matrices.
	pub fn update_view_proj(&mut self, queue: &wgpu::Queue) {
		self.gpu.update(
			queue,
			self.settings.to_view_projection_matrix(),
			self.settings.position,
		);
	}

	/// Apply a new position, yaw, and pitch to the camera.
	pub fn apply(&mut self, position: Vec3, yaw: f32, pitch: f32) {
		self.settings.position = position;
		self.settings.yaw = yaw;
		self.settings.pitch = pitch;
	}
}
