"""
	Asset: Godot Simple Portal System
	File: portal.gd
	Description: A simple portal system for viewport-based portals in Godot 4.
	Instructions: For detailed documentation, see the README or visit: https://github.com/Donitzo/godot-simple-portal-system
	Repository: https://github.com/Donitzo/godot-simple-portal-system
	License: CC0 License
"""

extends MeshInstance3D
class_name Portal
## The portal represents a single portal mesh in a pair of portals.

## The delay between the main viewport changing size and the portal viewport resizing.
const _RESIZE_THROTTLE_SECONDS:float = 0.1

## The minimum camera near clipping distance.
const _EXIT_CAMERA_NEAR_MIN:float = 0.01

## The portal mesh's local bounding box.
@onready var _mesh_aabb:AABB = mesh.get_aabb()

## The vertical resolution of the portal viewport which covers the entire screen not just the portal mesh. Use 0 to use the real resolution.
@export var vertical_viewport_resolution:int = 512

## Disable viewport distance. Portals further away than this won't have their viewports rendered.
@export var disable_viewport_distance:float = 11
## Whether to destroy the disabled viewport to save texture memory. Useful when you have a lot of portals. The viewport is re/-created when within disable_viewport_distance and visible.
@export var destroy_disabled_viewport:bool = true

## The maximum fade-out distance.
@export var fade_out_distance_max:float = 10
## The minimum fade-out distance.
@export var fade_out_distance_min:float = 8
## The fade-out color.
@export var fade_out_color:Color = Color.WHITE

## The scale of the exit side of the portal. < 1 means the exit is smaller than the entrance.
@export var exit_scale:float = 1.0
## A value subtracted from the exit camera near clipping plane. Useful for handling clipping issues.
@export var exit_near_subtract:float = 0.05

## The main camera. Leave unset to use the default 3D camera.
@export var main_camera:Camera3D

## An environment set for the exit camera. Leave unset to use the default environment.
@export var exit_environment:Environment
## The cull mask for the exit camera. Use it to hide certain objects in the exit camera.
@export_flags_3d_render var exit_cull_mask:int = 0b11111111111111111111

## The exit portal. Leave unset to use this portal as an exit only.
@export var exit_portal:Portal

## An environment set for the exit camera. Leave unset to use the default environment.
@export var portal_shader:Shader = preload("res://addons/simple-portal-system/shaders/portal.gdshader")

# The viewport rendering the portal surface
var _viewport:SubViewport

# The exit camera copies the main camera's position relative to the exit portal
var _exit_camera:Camera3D

# The number of seconds until the viewport updates its size
var _seconds_until_resize:float

func _enter_tree() -> void:
	add_to_group("portals")

func _ready() -> void:
	if not is_inside_tree():
		push_error("The portal \"%s\" is not inside a SceneTree." % name)

	# An exit-free portal does not need to do anything
	if exit_portal == null:
		visible = false
		set_process(false)
		return

	if not exit_portal.is_inside_tree() or exit_portal.get_tree() != get_tree():
		push_error("The exit_portal \"%s\" of \"%s\" is not inside the same SceneTree." % [exit_portal.name, name])

	# Non-uniform parent scaling can introduce skew which isn't compensated for
	if get_parent() != null:
		var parent_scale:Vector3 = get_parent().global_transform.basis.get_scale()
		if abs(parent_scale.x - parent_scale.y) > 0.01 or abs(parent_scale.x - parent_scale.z) > 0.01:
			push_warning("The parent of \"%s\" is not uniformly scaled. The portal will not work correctly." % name)

	# The portals should be updated last so the main camera has its final position
	process_priority = 1000

	# Used in raycasting
	add_to_group("portals")
	
	# Get the main camera
	if main_camera == null:
		main_camera = get_viewport().get_camera_3d()

	# The portal shader renders the viewport on-top of the portal mesh in screen-space
	material_override = ShaderMaterial.new()
	material_override.shader = portal_shader
	material_override.set_shader_parameter("fade_out_distance_max", fade_out_distance_max)
	material_override.set_shader_parameter("fade_out_distance_min", fade_out_distance_min)
	material_override.set_shader_parameter("fade_out_color", fade_out_color)   

	# Create the viewport when _ready if it's not destroyed when disabled.
	# This may potentially get rid of the initial lag when the viewport is first created at the cost of texture memory.
	if not destroy_disabled_viewport:
		_create_viewport()

	get_viewport().connect("size_changed", _handle_resize)

func _handle_resize() -> void:
	_seconds_until_resize = _RESIZE_THROTTLE_SECONDS

func _create_viewport() -> void:
	# Create the viewport for the portal surface
	_viewport = SubViewport.new()
	_viewport.name = "Viewport"
	_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE
	add_child(_viewport)
	material_override.set_shader_parameter("albedo", _viewport.get_texture())

	# Create the exit camera which renders the portal surface for the viewport
	_exit_camera = Camera3D.new()
	_exit_camera.name = "Camera"
	_exit_camera.environment = exit_environment
	_exit_camera.cull_mask = exit_cull_mask
	_viewport.add_child(_exit_camera)

	# Resize the viewport on the next _process
	_seconds_until_resize = 0

func _process(delta:float) -> void:
	# Disable the viewport if the portal is further away than disable_viewport_distance or if the portal is invisible in the scene tree
	var disable_viewport:bool = not is_visible_in_tree() or\
		main_camera.global_position.distance_squared_to(global_position) > disable_viewport_distance * disable_viewport_distance

	# Enable or disable 3D rendering for the viewport (if it exists)
	if _viewport != null:
		_viewport.disable_3d = disable_viewport

	if disable_viewport:
		# Destroy the disabled viewport to save memory
		if _viewport != null and destroy_disabled_viewport:
			material_override.set_shader_parameter("albedo", null)
			_viewport.queue_free()
			_viewport = null

		# Ensure the portal can re-size the second it is enabled again
		if not is_nan(_seconds_until_resize):
			_seconds_until_resize = 0

		# Don't process the rest if the viewport is disabled
		return

	# Re/-Create viewport
	if _viewport == null:
		_create_viewport()

	# Throttle the viewport resizing for better performance
	if not is_nan(_seconds_until_resize):
		_seconds_until_resize -= delta
		if _seconds_until_resize <= 0:
			_seconds_until_resize = NAN

			var viewport_size:Vector2i = get_viewport().size
			if vertical_viewport_resolution == 0:
				# Resize the viewport to the main viewport size
				_viewport.size = viewport_size
			else:
				# Resize the viewport to the fixed height vertical_viewport_resolution and dynamic width
				var aspect_ratio:float = float(viewport_size.x) / viewport_size.y
				_viewport.size = Vector2i(int(vertical_viewport_resolution * aspect_ratio + 0.5), vertical_viewport_resolution)

	# Move the exit camera relative to the exit portal based on the main camera's position relative to the entrance portal    
	_exit_camera.global_transform = real_to_exit_transform(main_camera.global_transform)

	# Get the four X, Y corners of the scaled entrance portal bounding box clamped to Z=0 (portal surface) relative to the exit portal.
	# The entrance portal bounding box is used since the entrance portal mesh does not need to match the exit portal mesh.
	var corner_1:Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x, _mesh_aabb.position.y, 0) * exit_scale)
	var corner_2:Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x + _mesh_aabb.size.x, _mesh_aabb.position.y, 0) * exit_scale)
	var corner_3:Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x + _mesh_aabb.size.x, _mesh_aabb.position.y + _mesh_aabb.size.y, 0) * exit_scale)
	var corner_4:Vector3 = exit_portal.to_global(Vector3(_mesh_aabb.position.x, _mesh_aabb.position.y + _mesh_aabb.size.y, 0) * exit_scale)

	# Calculate the distance along the exit camera forward vector at which each of the portal corners projects
	var camera_forward:Vector3 = -_exit_camera.global_transform.basis.z.normalized()

	var d_1:float = (corner_1 - _exit_camera.global_position).dot(camera_forward)
	var d_2:float = (corner_2 - _exit_camera.global_position).dot(camera_forward)
	var d_3:float = (corner_3 - _exit_camera.global_position).dot(camera_forward)
	var d_4:float = (corner_4 - _exit_camera.global_position).dot(camera_forward)

	# The near clip distance is the shortest distance which still contains all the corners
	_exit_camera.near = max(_EXIT_CAMERA_NEAR_MIN, min(d_1, d_2, d_3, d_4) - exit_near_subtract)
	_exit_camera.far = main_camera.far
	_exit_camera.fov = main_camera.fov
	_exit_camera.keep_aspect = main_camera.keep_aspect

## Return a new Transform3D relative to the exit portal based on the real Transform3D relative to this portal.
func real_to_exit_transform(real:Transform3D) -> Transform3D:
	# Convert from global space to local space at the entrance (this) portal
	var local:Transform3D = global_transform.affine_inverse() * real
	# Compensate for any scale the entrance portal may have
	var unscaled:Transform3D = local.scaled(global_transform.basis.get_scale())
	# Flip it (the portal always flips the view 180 degrees)
	var flipped:Transform3D = unscaled.rotated(Vector3.UP, PI)
	# Apply any scale the exit portal may have (and apply custom exit scale)
	var exit_scale_vector:Vector3 = exit_portal.global_transform.basis.get_scale()
	var scaled_at_exit:Transform3D = flipped.scaled(Vector3.ONE / exit_scale_vector * exit_scale)
	# Convert from local space at the exit portal to global space
	var local_at_exit:Transform3D = exit_portal.global_transform * scaled_at_exit
	return local_at_exit

## Return a new position relative to the exit portal based on the real position relative to this portal.
func real_to_exit_position(real:Vector3) -> Vector3:
	# Convert from global space to local space at the entrance (this) portal
	var local:Vector3 = global_transform.affine_inverse() * real
	# Compensate for any scale the entrance portal may have
	var unscaled:Vector3 = local * global_transform.basis.get_scale()
	# Apply any scale the exit portal may have (and apply custom exit scale)
	var exit_scale_vector:Vector3 = Vector3(-1, 1, 1) * exit_portal.global_transform.basis.get_scale()
	var scaled_at_exit:Vector3 = unscaled / exit_scale_vector * exit_scale
	# Convert from local space at the exit portal to global space
	var local_at_exit:Vector3 = exit_portal.global_transform * scaled_at_exit
	return local_at_exit

## Return a new direction relative to the exit portal based on the real direction relative to this portal.
func real_to_exit_direction(real:Vector3) -> Vector3:
	# Convert from global to local space at the entrance (this) portal
	var local:Vector3 = global_transform.basis.inverse() * real
	# Compensate for any scale the entrance portal may have
	var unscaled:Vector3 = local * global_transform.basis.get_scale()
	# Flip it (the portal always flips the view 180 degrees)
	var flipped:Vector3 = unscaled.rotated(Vector3.UP, PI)
	# Apply any scale the exit portal may have (and apply custom exit scale)
	var exit_scale_vector:Vector3 = exit_portal.global_transform.basis.get_scale()
	var scaled_at_exit:Vector3 = flipped / exit_scale_vector * exit_scale
	# Convert from local space at the exit portal to global space
	var local_at_exit:Vector3 = exit_portal.global_transform.basis * scaled_at_exit
	return local_at_exit

## Raycast against portals (See instructions).
static func raycast(tree:SceneTree, from:Vector3, dir:Vector3, handle_raycast:Callable, 
	max_distance:float = INF, max_recursions:int = 16, ignore_backside:bool = true) -> void:
	var portals:Array = tree.get_nodes_in_group("portals")
	var ignore_portal:Portal = null
	var recursive_distance:float = 0
	
	for r in max_recursions + 1:
		var closest_hit:Vector3
		var closest_dir:Vector3
		var closest_portal:Portal
		var closest_distance_sqr:float = INF

		# Find the closest portal the ray intersects       
		for portal in portals:
			# Ignore exit portals and invisible portals
			if portal == ignore_portal or not portal.is_inside_tree() or tree != portal.get_tree() or not portal.is_visible_in_tree():
				continue

			var local_from:Vector3 = portal.to_local(from)
			var local_dir:Vector3 = portal.global_transform.basis.inverse() * dir

			# Check if ray is parallel to the portal                
			if local_dir.z == 0:
				continue

			# Ignore backside    
			if local_dir.z > 0 and ignore_backside:
				continue

			# Get the intersection point of the ray with the Z axis
			var t:float = -local_from.z / local_dir.z
			
			# Is the intersection behind the start position?
			if t < 0:
				continue

			# Check if the ray hit inside the portal bounding box (ignoring Z)
			var local_hit:Vector3 = local_from + t * local_dir
			var aabb:AABB = portal._mesh_aabb
			if local_hit.x < aabb.position.x or local_hit.x > aabb.position.x + aabb.size.x or\
				local_hit.y < aabb.position.y or local_hit.y > aabb.position.y + aabb.size.y:
				continue

			# Check if this was the closest portal
			var hit:Vector3 = portal.to_global(local_hit)
			var distance_sqr:float = hit.distance_squared_to(from)
			if distance_sqr < closest_distance_sqr:
				closest_hit = hit
				closest_dir = dir
				closest_distance_sqr = distance_sqr
				closest_portal = portal

		# Calculate the ray distance
		var hit_distance:float = INF if is_inf(closest_distance_sqr) else sqrt(closest_distance_sqr)

		# Call the user-defined raycast function
		if handle_raycast.call(from, dir, hit_distance, recursive_distance, r):
			break

		# Was no portal hit or was the maximum raycast distance reached?
		recursive_distance += hit_distance
		if is_inf(closest_distance_sqr) or recursive_distance >= max_distance:
			break
		
		# Re-direct the ray through the portal
		from = closest_portal.real_to_exit_position(closest_hit)
		dir = closest_portal.real_to_exit_direction(closest_dir)
		ignore_portal = closest_portal.exit_portal
