## DBC-driven outdoor sky controller for WoW 3.3.5a data.
extends Node

const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const COORDINATE_MAPPER_SCRIPT := preload("res://src/domain/coordinates/coordinate_mapper.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")

const LIGHT_COORD_SCALE := 36.0
const HALF_MINUTES_PER_DAY := 2880

const CH_AMBIENT := 0
const CH_DIFFUSE := 1
const CH_SKY_TOP := 2
const CH_SKY_MIDDLE := 3
const CH_SKY_BAND_1 := 4
const CH_SKY_BAND_2 := 5
const CH_FOG := 6

const FOG_END := 0
const FOG_START_SCALAR := 1
const CLOUD_DENSITY := 2
const FOG_DENSITY := 3

const AREA_LIGHT_PARAMS_BY_ZONE := {
	# Eastern Kingdoms outdoor fallback profiles for places not covered by Light.dbc volumes.
	1: 28,      # Dun Morogh
	3: 40,      # Badlands
	4: 40,      # Blasted Lands
	8: 40,      # Swamp of Sorrows
	10: 92,     # Duskwood
	11: 34,     # Wetlands
	12: 28,     # Elwynn Forest
	28: 92,     # Western Plaguelands
	33: 118,    # Stranglethorn Vale
	36: 34,     # Alterac Mountains
	38: 28,     # Loch Modan
	40: 28,     # Westfall
	41: 92,     # Deadwind Pass
	44: 28,     # Redridge Mountains
	45: 28,     # Arathi Highlands
	46: 40,     # Burning Steppes
	47: 118,    # Hinterlands
	51: 40,     # Searing Gorge
	85: 92,     # Tirisfal Glades
	130: 92,    # Silverpine Forest
	139: 92,    # Eastern Plaguelands
	267: 28,    # Hillsbrad Foothills
	1519: 28,   # Stormwind
	1537: 28,   # Ironforge
	1497: 92,   # Undercity
	2365: 12,   # Great Sea
}

@export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth"
@export var map_id: int = 0
@export var target_path: NodePath
@export var world_environment_path: NodePath
@export var sun_path: NodePath

@export var update_interval: float = 0.2
@export var use_system_time: bool = false
@export_range(0.0, 24.0, 0.1) var fixed_time_hours: float = 13.0
@export var time_speed: float = 0.0
@export var smooth_speed: float = 6.0
@export var debug_log_enabled: bool = true
@export var debug_log_interval: float = 5.0
@export var skybox_models_enabled: bool = true
@export var skybox_model_scale: float = 1.0
@export var skybox_model_height_offset: float = 0.0
## Non-zero forces a LightSkybox.dbc ID for visual testing on maps that do not use outdoor skybox models.
@export var debug_force_skybox_id: int = 0

var _world_environment: WorldEnvironment
var _environment: Environment
var _sky_material: ProceduralSkyMaterial
var _sun: DirectionalLight3D
var _target: Node3D

var _light_volumes_by_map: Dictionary = {}
var _default_light_params_by_map: Dictionary = {}
var _profiles: Dictionary = {}
var _light_skyboxes: Dictionary = {}
var _missing_skyboxes: Dictionary = {}
var _area_table: Dictionary = {}
var _adt_area_cache: Dictionary = {}
var _loaded := false
var _elapsed := 0.0
var _debug_elapsed := 0.0

var _current: Dictionary = {}
var _skybox_root: Node3D
var _active_skybox_id := -1
var _active_skybox_node: Node3D
var _last_logged_area_id := -1
var _last_logged_zone_id := -1
var _last_logged_profile_signature := ""
var _last_global_light_signature := ""


func _ready() -> void:
	_resolve_nodes()
	_prepare_environment()
	_ensure_wow_shader_globals()
	_loaded = _load_lighting_dbcs()
	if _loaded:
		print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [
			_profiles.size(),
			_area_table.size(),
			_get_light_volume_count(map_id),
			map_name])
	else:
		push_warning("WowSkyController: DBC lighting not loaded, using fallback sky")
	_apply_sky(1.0)
	_update_skybox_model()
	_update_skybox_transform()


func _process(delta: float) -> void:
	if not _environment or not _sky_material:
		return
	_elapsed += delta
	_debug_elapsed += delta
	fixed_time_hours = fposmod(fixed_time_hours + delta * time_speed, 24.0)
	if _elapsed < update_interval:
		_apply_sky(delta)
		_update_skybox_transform()
		return
	_elapsed = 0.0
	_sample_current_params()
	_apply_sky(delta)
	_update_skybox_model()
	_update_skybox_transform()


## Accepts an authoritative time snapshot without moving DBC visual selection out
## of this controller. Existing fixed-clock behavior is preserved and frozen until
## another snapshot or explicit local clock configuration is supplied.
func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
	if (
		world_environment_snapshot == null
		or world_environment_snapshot.get_script() != WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT
		or not bool(world_environment_snapshot.get("is_valid"))
	):
		return false
	use_system_time = false
	time_speed = 0.0
	fixed_time_hours = float(world_environment_snapshot.get("time_of_day_hours"))
	return true


func _resolve_nodes() -> void:
	if not world_environment_path.is_empty():
		_world_environment = get_node_or_null(world_environment_path) as WorldEnvironment
	else:
		_world_environment = get_parent().get_node_or_null("WorldEnvironment") as WorldEnvironment
	if not sun_path.is_empty():
		_sun = get_node_or_null(sun_path) as DirectionalLight3D
	else:
		_sun = get_parent().get_node_or_null("Sun") as DirectionalLight3D
	if not target_path.is_empty():
		_target = get_node_or_null(target_path) as Node3D
	_skybox_root = Node3D.new()
	_skybox_root.name = "SkyboxModelRoot"
	_skybox_root.top_level = true
	add_child(_skybox_root)


func _prepare_environment() -> void:
	if not _world_environment:
		push_warning("WowSkyController: WorldEnvironment not found")
		return

	_environment = _world_environment.environment
	if not _environment:
		_environment = Environment.new()
	else:
		_environment = _environment.duplicate(true)
	_world_environment.environment = _environment

	_environment.background_mode = Environment.BG_SKY
	_environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
	_environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY
	_environment.fog_enabled = true
	_environment.fog_mode = Environment.FOG_MODE_EXPONENTIAL

	var sky := _environment.sky
	if not sky:
		sky = Sky.new()
		_environment.sky = sky

	_sky_material = sky.sky_material as ProceduralSkyMaterial
	if not _sky_material:
		_sky_material = ProceduralSkyMaterial.new()
		sky.sky_material = _sky_material
	_sky_material.use_debanding = true
	sky.radiance_size = Sky.RADIANCE_SIZE_256


func _sample_current_params() -> void:
	var time_hours := _get_time_hours()
	var time_half := int(floor(fposmod(time_hours, 24.0) * 120.0)) % HALF_MINUTES_PER_DAY
	var wow_pos := _get_target_wow_position()
	var area_id := _get_target_area_id()
	var zone_id := _get_zone_area_id(area_id)
	var selected := _select_light_params(wow_pos, area_id, zone_id)
	var params := _sample_blended_params(selected, time_half)
	params["time_hours"] = time_hours
	params["light_params"] = selected
	params["skybox_id"] = _select_skybox_id(selected)
	params["area_id"] = area_id
	params["zone_id"] = zone_id
	_current = params

	var profile_signature := str(selected)
	if debug_log_enabled and (
		_debug_elapsed >= debug_log_interval
		or area_id != _last_logged_area_id
		or zone_id != _last_logged_zone_id
		or profile_signature != _last_logged_profile_signature
	):
		_debug_elapsed = 0.0
		_last_logged_area_id = area_id
		_last_logged_zone_id = zone_id
		_last_logged_profile_signature = profile_signature
		print("SKY_LIGHT time=%.2f map=%d area=%d:%s zone=%d:%s wow=(%.1f,%.1f,%.1f) profiles=%s skybox=%s fog=%.0f..%.0f density=%.3f" % [
			time_hours,
			map_id,
			area_id,
			_get_area_name(area_id),
			zone_id,
			_get_area_name(zone_id),
			wow_pos.x,
			wow_pos.y,
			wow_pos.z,
			str(selected),
			str(params["skybox_id"]),
			float(params.get("fog_start", 0.0)),
			float(params.get("fog_end", 0.0)),
			float(params.get("fog_density", 0.0))])


func _apply_sky(delta: float) -> void:
	if _current.is_empty():
		_sample_current_params()

	var blend := 1.0
	if delta > 0.0 and smooth_speed > 0.0:
		blend = clamp(delta * smooth_speed, 0.0, 1.0)

	var sky_top: Color = _current.get("sky_top", Color(0.13, 0.32, 0.62))
	var sky_mid: Color = _current.get("sky_middle", Color(0.36, 0.58, 0.86))
	var sky_horizon: Color = _current.get("sky_band_1", Color(0.62, 0.76, 0.9))
	var sky_low: Color = _current.get("sky_band_2", sky_horizon)
	var fog_color: Color = _current.get("fog_color", Color(0.55, 0.66, 0.72))
	var ambient: Color = _current.get("ambient", Color(0.72, 0.8, 0.88))
	var diffuse: Color = _current.get("diffuse", Color(1.0, 0.91, 0.78))
	var time_hours: float = float(_current.get("time_hours", _get_time_hours()))
	var sun_elevation := _sun_elevation01(time_hours)

	_sky_material.sky_top_color = _sky_material.sky_top_color.lerp(sky_top, blend)
	_sky_material.sky_horizon_color = _sky_material.sky_horizon_color.lerp(sky_horizon, blend)
	_sky_material.ground_horizon_color = _sky_material.ground_horizon_color.lerp(sky_low, blend)
	_sky_material.ground_bottom_color = _sky_material.ground_bottom_color.lerp(fog_color.darkened(0.55), blend)
	_sky_material.sky_energy_multiplier = lerpf(_sky_material.sky_energy_multiplier, lerpf(0.28, 1.18, sun_elevation), blend)
	_sky_material.ground_energy_multiplier = lerpf(_sky_material.ground_energy_multiplier, 0.45, blend)
	_sky_material.sun_angle_max = 10.0
	_sky_material.sun_curve = 0.08

	_environment.ambient_light_color = _environment.ambient_light_color.lerp(ambient, blend)
	_environment.ambient_light_energy = lerpf(_environment.ambient_light_energy, clamp(_color_luma(ambient) * 1.45, 0.18, 1.05), blend)
	_environment.ambient_light_sky_contribution = lerpf(_environment.ambient_light_sky_contribution, 0.65, blend)
	_environment.background_energy_multiplier = lerpf(_environment.background_energy_multiplier, lerpf(0.35, 0.95, sun_elevation), blend)
	_environment.fog_light_color = _environment.fog_light_color.lerp(fog_color, blend)
	_environment.fog_light_energy = lerpf(_environment.fog_light_energy, 0.55, blend)
	_environment.fog_sun_scatter = lerpf(_environment.fog_sun_scatter, 0.08, blend)
	_environment.fog_sky_affect = lerpf(_environment.fog_sky_affect, 0.35, blend)

	var fog_end: float = maxf(800.0, float(_current.get("fog_end", 5200.0)))
	var fog_start: float = clamp(float(_current.get("fog_start", fog_end * 0.25)), 0.0, fog_end - 50.0)
	var dbc_fog_density: float = clamp(float(_current.get("fog_density", 0.5)), 0.0, 1.0)
	_environment.fog_depth_begin = lerpf(_environment.fog_depth_begin, fog_start, blend)
	_environment.fog_depth_end = lerpf(_environment.fog_depth_end, fog_end, blend)
	_environment.fog_density = lerpf(_environment.fog_density, clamp(dbc_fog_density * 0.0002, 0.00002, 0.00022), blend)

	if _sun:
		var sun_color := diffuse.lerp(Color(0.42, 0.46, 0.7), 1.0 - sun_elevation)
		_sun.light_color = _sun.light_color.lerp(sun_color, blend)
		_sun.light_energy = lerpf(_sun.light_energy, clamp(_color_luma(diffuse) * lerpf(0.22, 1.45, sun_elevation), 0.05, 1.6), blend)
		_apply_sun_direction(time_hours)

	_update_wow_shader_globals(sun_elevation)


func _ensure_wow_shader_globals() -> void:
	pass


func _update_wow_shader_globals(sun_elevation: float) -> void:
	if not _environment:
		return
	var light_dir := Vector3(-0.35, 0.82, -0.45).normalized()
	if _sun:
		light_dir = -_sun.global_transform.basis.z.normalized()
	var fog_range := Vector2(_environment.fog_depth_begin, _environment.fog_depth_end)
	var raw_light: Color = _sun.light_color if _sun else Color(1.0, 0.91, 0.78, 1.0)
	var shader_ambient := _world_shader_color(_environment.ambient_light_color, 0.65, Color(0.72, 0.70, 0.64, 1.0), 0.22)
	var shader_light := _world_shader_color(raw_light, 0.45, Color(1.0, 0.92, 0.78, 1.0), 0.16)
	var shader_fog := _world_shader_color(_environment.fog_light_color, 0.75, Color(0.62, 0.66, 0.66, 1.0), 0.38)
	var density: float = clampf(_environment.fog_density * 2200.0, 0.0, 0.42)
	var signature := "%s|%s|%s|%s|%.4f|%.3f" % [
		str(shader_ambient),
		str(shader_light),
		str(light_dir.snapped(Vector3(0.001, 0.001, 0.001))),
		str(fog_range.snapped(Vector2(1.0, 1.0))),
		density,
		sun_elevation]
	if signature == _last_global_light_signature:
		return
	_last_global_light_signature = signature
	RenderingServer.global_shader_parameter_set(&"wow_ambient_color", shader_ambient)
	RenderingServer.global_shader_parameter_set(&"wow_light_color", shader_light)
	RenderingServer.global_shader_parameter_set(&"wow_light_dir", light_dir)
	RenderingServer.global_shader_parameter_set(&"wow_fog_color", shader_fog)
	RenderingServer.global_shader_parameter_set(&"wow_fog_range", fog_range)
	RenderingServer.global_shader_parameter_set(&"wow_fog_density", density)
	RenderingServer.global_shader_parameter_set(&"wow_sun_elevation", sun_elevation)


func _load_lighting_dbcs() -> bool:
	var base := _res_path(extracted_dir).path_join("DBFilesClient")
	var light := _load_wdbc(base.path_join("Light.dbc"))
	var light_params := _load_wdbc(base.path_join("LightParams.dbc"))
	var light_skybox := _load_wdbc(base.path_join("LightSkybox.dbc"))
	var int_band := _load_wdbc(base.path_join("LightIntBand.dbc"))
	var float_band := _load_wdbc(base.path_join("LightFloatBand.dbc"))
	var area_table := _load_wdbc(base.path_join("AreaTable.dbc"))
	if light.is_empty() or int_band.is_empty() or float_band.is_empty():
		return false
	_load_light_volumes(light)
	if not light_params.is_empty():
		_load_light_params(light_params)
	if not light_skybox.is_empty():
		_load_light_skyboxes(light_skybox)
	if not area_table.is_empty():
		_load_area_table(area_table)
	_load_int_bands(int_band)
	_load_float_bands(float_band)
	return not _profiles.is_empty()


func _load_light_volumes(dbc: Dictionary) -> void:
	_light_volumes_by_map.clear()
	_default_light_params_by_map.clear()
	for i in int(dbc["records"]):
		var record_map := _dbc_u32(dbc, i, 1)
		var wx := _dbc_float(dbc, i, 2) / LIGHT_COORD_SCALE
		var wz := _dbc_float(dbc, i, 3) / LIGHT_COORD_SCALE
		var wy := _dbc_float(dbc, i, 4) / LIGHT_COORD_SCALE
		var inner := maxf(0.0, _dbc_float(dbc, i, 5) / LIGHT_COORD_SCALE)
		var outer := maxf(inner, _dbc_float(dbc, i, 6) / LIGHT_COORD_SCALE)
		var normal := int(_dbc_u32(dbc, i, 7))
		var rain := int(_dbc_u32(dbc, i, 8))
		var underwater := int(_dbc_u32(dbc, i, 9))
		if normal <= 0:
			continue
		if outer <= 0.001:
			if not _default_light_params_by_map.has(record_map):
				_default_light_params_by_map[record_map] = normal
			continue
		if not _light_volumes_by_map.has(record_map):
			_light_volumes_by_map[record_map] = []
		_light_volumes_by_map[record_map].append({
			"pos": Vector3(wx, wy, wz),
			"inner": inner,
			"outer": outer,
			"normal": normal,
			"rain": rain,
			"underwater": underwater,
		})


func _load_int_bands(dbc: Dictionary) -> void:
	for i in int(dbc["records"]):
		var band_id := int(_dbc_u32(dbc, i, 0))
		if band_id <= 0:
			continue
		var param_id := int((band_id - 1) / 18) + 1
		var channel := (band_id - 1) % 18
		var profile := _get_or_create_profile(param_id)
		var colors: Array = profile["colors"]
		colors[channel] = _read_color_band(dbc, i)


func _load_float_bands(dbc: Dictionary) -> void:
	for i in int(dbc["records"]):
		var band_id := int(_dbc_u32(dbc, i, 0))
		if band_id <= 0:
			continue
		var param_id := int((band_id - 1) / 6) + 1
		var channel := (band_id - 1) % 6
		var profile := _get_or_create_profile(param_id)
		var floats: Array = profile["floats"]
		floats[channel] = _read_float_band(dbc, i)


func _load_light_params(dbc: Dictionary) -> void:
	for i in int(dbc["records"]):
		var param_id := int(_dbc_u32(dbc, i, 0))
		if param_id <= 0:
			continue
		var profile := _get_or_create_profile(param_id)
		profile["highlight_sky"] = int(_dbc_u32(dbc, i, 1))
		profile["skybox_id"] = int(_dbc_u32(dbc, i, 2))


func _load_light_skyboxes(dbc: Dictionary) -> void:
	_light_skyboxes.clear()
	for i in int(dbc["records"]):
		var skybox_id := int(_dbc_u32(dbc, i, 0))
		if skybox_id <= 0:
			continue
		var path := _dbc_string(dbc, i, 1).replace("\\", "/")
		if path.is_empty():
			continue
		_light_skyboxes[skybox_id] = {
			"path": path,
			"flags": int(_dbc_u32(dbc, i, 2)),
		}


func _load_area_table(dbc: Dictionary) -> void:
	_area_table.clear()
	for i in int(dbc["records"]):
		var area_id := int(_dbc_u32(dbc, i, 0))
		if area_id <= 0:
			continue
		_area_table[area_id] = {
			"map": int(_dbc_u32(dbc, i, 1)),
			"parent": int(_dbc_u32(dbc, i, 2)),
			"name": _dbc_string(dbc, i, 19),
		}


func _get_or_create_profile(param_id: int) -> Dictionary:
	if _profiles.has(param_id):
		return _profiles[param_id]
	var colors: Array = []
	var floats: Array = []
	colors.resize(18)
	floats.resize(6)
	var profile := {"colors": colors, "floats": floats}
	_profiles[param_id] = profile
	return profile


func _select_light_params(wow_pos: Vector3, area_id: int, zone_id: int) -> Array:
	var volumes: Array = _light_volumes_by_map.get(map_id, [])
	var weighted: Array = []
	for volume in volumes:
		var pos: Vector3 = volume["pos"]
		var d := Vector2(wow_pos.x - pos.x, wow_pos.y - pos.y).length()
		var outer: float = volume["outer"]
		if d > outer:
			continue
		var inner: float = volume["inner"]
		var weight := 1.0
		if outer > inner:
			weight = clamp((outer - d) / (outer - inner), 0.0, 1.0)
		if weight > 0.0:
			weighted.append({"id": int(volume["normal"]), "weight": weight, "source": "volume"})

	weighted.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		return float(a["weight"]) > float(b["weight"]))
	if weighted.size() > 4:
		weighted.resize(4)

	var total := 0.0
	for item in weighted:
		total += float(item["weight"])
	if total > 0.0:
		for item in weighted:
			item["weight"] = float(item["weight"]) / total
		return weighted

	var area_profile := _select_area_light_param(area_id, zone_id)
	if area_profile > 0:
		return [{"id": area_profile, "weight": 1.0, "source": "area", "area": area_id, "zone": zone_id}]

	var fallback := int(_default_light_params_by_map.get(map_id, _default_light_params_by_map.get(0, 12)))
	return [{"id": fallback, "weight": 1.0, "source": "default"}]


func _select_area_light_param(area_id: int, zone_id: int) -> int:
	if area_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(area_id):
		return int(AREA_LIGHT_PARAMS_BY_ZONE[area_id])
	if zone_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(zone_id):
		return int(AREA_LIGHT_PARAMS_BY_ZONE[zone_id])
	return 0


func _select_skybox_id(selected: Array) -> int:
	if debug_force_skybox_id > 0:
		return debug_force_skybox_id if _light_skyboxes.has(debug_force_skybox_id) else 0
	for item in selected:
		var id := int(item.get("id", 0))
		if not _profiles.has(id):
			continue
		var skybox_id := int((_profiles[id] as Dictionary).get("skybox_id", 0))
		if skybox_id > 0 and _light_skyboxes.has(skybox_id):
			return skybox_id
	return 0


func _sample_blended_params(selected: Array, time_half: int) -> Dictionary:
	var out := {
		"ambient": Color(0.72, 0.80, 0.88),
		"diffuse": Color(1.0, 0.91, 0.78),
		"fog_color": Color(0.55, 0.66, 0.72),
		"sky_top": Color(0.13, 0.32, 0.62),
		"sky_middle": Color(0.36, 0.58, 0.86),
		"sky_band_1": Color(0.62, 0.76, 0.90),
		"sky_band_2": Color(0.50, 0.62, 0.72),
		"fog_end": 5200.0,
		"fog_start": 1200.0,
		"fog_density": 0.6,
		"cloud_density": 0.0,
	}
	var accum := {}
	for key in out.keys():
		accum[key] = Color(0, 0, 0) if out[key] is Color else 0.0

	var total := 0.0
	for item in selected:
		var id := int(item["id"])
		var weight := float(item["weight"])
		if not _profiles.has(id) or weight <= 0.0:
			continue
		var sampled := _sample_profile(_profiles[id], time_half, out)
		for key in sampled.keys():
			accum[key] = accum[key] + sampled[key] * weight
		total += weight

	if total <= 0.0:
		return out
	for key in out.keys():
		out[key] = accum[key] if total == 1.0 else accum[key] / total
	return out


func _sample_profile(profile: Dictionary, time_half: int, fallback: Dictionary) -> Dictionary:
	var colors: Array = profile["colors"]
	var floats: Array = profile["floats"]
	var fog_end := _sample_float_band(floats[FOG_END], time_half, float(fallback["fog_end"]))
	var fog_scalar := _sample_float_band(floats[FOG_START_SCALAR], time_half, 0.25)
	return {
		"ambient": _sample_color_band(colors[CH_AMBIENT], time_half, fallback["ambient"]),
		"diffuse": _sample_color_band(colors[CH_DIFFUSE], time_half, fallback["diffuse"]),
		"fog_color": _sample_color_band(colors[CH_FOG], time_half, fallback["fog_color"]),
		"sky_top": _sample_color_band(colors[CH_SKY_TOP], time_half, fallback["sky_top"]),
		"sky_middle": _sample_color_band(colors[CH_SKY_MIDDLE], time_half, fallback["sky_middle"]),
		"sky_band_1": _sample_color_band(colors[CH_SKY_BAND_1], time_half, fallback["sky_band_1"]),
		"sky_band_2": _sample_color_band(colors[CH_SKY_BAND_2], time_half, fallback["sky_band_2"]),
		"fog_end": fog_end,
		"fog_start": maxf(0.0, fog_end * fog_scalar),
		"fog_density": _sample_float_band(floats[FOG_DENSITY], time_half, float(fallback["fog_density"])),
		"cloud_density": _sample_float_band(floats[CLOUD_DENSITY], time_half, float(fallback["cloud_density"])),
	}


func _read_color_band(dbc: Dictionary, record: int) -> Dictionary:
	var count := mini(int(_dbc_u32(dbc, record, 1)), 16)
	var times := PackedInt32Array()
	var values: Array[Color] = []
	for i in count:
		times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY)
		values.append(_dbc_color(_dbc_u32(dbc, record, 18 + i)))
	return {"times": times, "values": values}


func _read_float_band(dbc: Dictionary, record: int) -> Dictionary:
	var count := mini(int(_dbc_u32(dbc, record, 1)), 16)
	var times := PackedInt32Array()
	var values := PackedFloat32Array()
	for i in count:
		times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY)
		values.append(_dbc_float(dbc, record, 18 + i))
	return {"times": times, "values": values}


func _sample_color_band(band_variant: Variant, time_half: int, fallback: Color) -> Color:
	if not (band_variant is Dictionary):
		return fallback
	var band: Dictionary = band_variant
	var times: PackedInt32Array = band.get("times", PackedInt32Array())
	var values: Array = band.get("values", [])
	if times.is_empty() or values.is_empty():
		return fallback
	if times.size() == 1:
		return values[0]
	var pair := _find_time_pair(times, time_half)
	var t := pair.z
	return (values[pair.x] as Color).lerp(values[pair.y] as Color, t)


func _sample_float_band(band_variant: Variant, time_half: int, fallback: float) -> float:
	if not (band_variant is Dictionary):
		return fallback
	var band: Dictionary = band_variant
	var times: PackedInt32Array = band.get("times", PackedInt32Array())
	var values: PackedFloat32Array = band.get("values", PackedFloat32Array())
	if times.is_empty() or values.is_empty():
		return fallback
	if times.size() == 1:
		return values[0]
	var pair := _find_time_pair(times, time_half)
	return lerpf(values[pair.x], values[pair.y], pair.z)


func _find_time_pair(times: PackedInt32Array, time_half: int) -> Vector3:
	var idx1 := times.size() - 1
	var idx2 := 0
	for i in times.size():
		if time_half < times[i]:
			idx2 = i
			idx1 = i - 1 if i > 0 else times.size() - 1
			break
	var t1 := times[idx1]
	var t2 := times[idx2]
	var span := t2 - t1 if t2 > t1 else HALF_MINUTES_PER_DAY - t1 + t2
	var elapsed := time_half - t1 if time_half >= t1 else HALF_MINUTES_PER_DAY - t1 + time_half
	var alpha: float = clamp(float(elapsed) / maxf(1.0, float(span)), 0.0, 1.0)
	return Vector3(idx1, idx2, alpha)


func _load_wdbc(path: String) -> Dictionary:
	var abs_path := ProjectSettings.globalize_path(path)
	if not FileAccess.file_exists(abs_path):
		return {}
	var file := FileAccess.open(abs_path, FileAccess.READ)
	if not file:
		return {}
	var bytes := file.get_buffer(file.get_length())
	if bytes.size() < 20 or bytes[0] != 0x57 or bytes[1] != 0x44 or bytes[2] != 0x42 or bytes[3] != 0x43:
		return {}
	var records := int(bytes.decode_u32(4))
	var fields := int(bytes.decode_u32(8))
	var record_size := int(bytes.decode_u32(12))
	var string_size := int(bytes.decode_u32(16))
	var required := 20 + records * record_size + string_size
	if records < 0 or fields <= 0 or record_size <= 0 or required > bytes.size():
		return {}
	return {
		"bytes": bytes,
		"records": records,
		"fields": fields,
		"record_size": record_size,
		"records_offset": 20,
		"strings_offset": 20 + records * record_size,
		"string_size": string_size,
	}


func _dbc_u32(dbc: Dictionary, record: int, field: int) -> int:
	if record < 0 or record >= int(dbc["records"]) or field < 0:
		return 0
	var record_size := int(dbc["record_size"])
	var field_offset := field * 4
	if field_offset + 4 > record_size:
		return 0
	var bytes: PackedByteArray = dbc["bytes"]
	return int(bytes.decode_u32(int(dbc["records_offset"]) + record * record_size + field_offset))


func _dbc_float(dbc: Dictionary, record: int, field: int) -> float:
	if record < 0 or record >= int(dbc["records"]) or field < 0:
		return 0.0
	var record_size := int(dbc["record_size"])
	var field_offset := field * 4
	if field_offset + 4 > record_size:
		return 0.0
	var bytes: PackedByteArray = dbc["bytes"]
	return bytes.decode_float(int(dbc["records_offset"]) + record * record_size + field_offset)


func _dbc_string(dbc: Dictionary, record: int, field: int) -> String:
	var offset := _dbc_u32(dbc, record, field)
	var string_size := int(dbc.get("string_size", 0))
	if offset <= 0 or offset >= string_size:
		return ""
	var bytes: PackedByteArray = dbc["bytes"]
	var pos := int(dbc["strings_offset"]) + offset
	var end := pos
	var max_end := int(dbc["strings_offset"]) + string_size
	while end < max_end and bytes[end] != 0:
		end += 1
	if end <= pos:
		return ""
	return bytes.slice(pos, end).get_string_from_utf8()


func _dbc_color(value: int) -> Color:
	var r := float(value & 0xFF) / 255.0
	var g := float((value >> 8) & 0xFF) / 255.0
	var b := float((value >> 16) & 0xFF) / 255.0
	return Color(r, g, b, 1.0)


func _get_time_hours() -> float:
	if use_system_time:
		var now := Time.get_datetime_dict_from_system()
		return float(now.hour) + float(now.minute) / 60.0 + float(now.second) / 3600.0
	return fixed_time_hours


func _get_target_wow_position() -> Vector3:
	var world_pos := Vector3.ZERO
	if _target:
		world_pos = _target.global_position
	var canonical_position = COORDINATE_MAPPER_SCRIPT.godot_to_canonical(_typed_godot_position(world_pos))
	return Vector3(canonical_position.x_yards, canonical_position.y_yards, canonical_position.z_yards)


func _get_target_area_id() -> int:
	if not _target or map_name.is_empty():
		return 0
	var world_pos := _target.global_position
	var typed_world_position = _typed_godot_position(world_pos)
	var tile_coordinate = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile(typed_world_position)
	var local_position = COORDINATE_MAPPER_SCRIPT.godot_to_adt_tile_local(typed_world_position)
	var chunk_coordinate = COORDINATE_MAPPER_SCRIPT.adt_tile_local_to_chunk(tile_coordinate, local_position)
	var areas := _load_adt_area_grid(tile_coordinate.tile_x, tile_coordinate.tile_y)
	if areas.is_empty():
		return 0
	var chunk_x := clampi(chunk_coordinate.chunk_x, 0, 15)
	var chunk_y := clampi(chunk_coordinate.chunk_y, 0, 15)
	return int(areas[chunk_y * 16 + chunk_x])


func _typed_godot_position(world_position: Vector3):
	return GODOT_WORLD_POSITION_SCRIPT.new(world_position.x, world_position.y, world_position.z)


func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array:
	var key := "%d_%d" % [tile_x, tile_y]
	if _adt_area_cache.has(key):
		var cached: PackedInt32Array = _adt_area_cache[key]
		return cached

	var areas := PackedInt32Array()
	areas.resize(256)
	var rel_path := _res_path(extracted_dir).path_join("World/Maps").path_join(map_name).path_join("%s_%d_%d.adt" % [map_name, tile_x, tile_y])
	var abs_path := ProjectSettings.globalize_path(rel_path)
	if not FileAccess.file_exists(abs_path):
		_adt_area_cache[key] = areas
		return areas

	var file := FileAccess.open(abs_path, FileAccess.READ)
	if not file:
		_adt_area_cache[key] = areas
		return areas

	var bytes := file.get_buffer(file.get_length())
	var offset := 0
	while offset + 8 <= bytes.size():
		var payload := offset + 8
		var size := int(bytes.decode_u32(offset + 4))
		if size < 0 or payload + size > bytes.size():
			offset += 1
			continue

		if _is_mcnk_magic(bytes, offset) and size >= 56:
			var index_x := int(bytes.decode_u32(payload + 4))
			var index_y := int(bytes.decode_u32(payload + 8))
			var area_id := int(bytes.decode_u32(payload + 52))
			if index_x >= 0 and index_x < 16 and index_y >= 0 and index_y < 16:
				areas[index_y * 16 + index_x] = area_id

		offset = payload + size

	_adt_area_cache[key] = areas
	return areas


func _is_mcnk_magic(bytes: PackedByteArray, offset: int) -> bool:
	if offset + 4 > bytes.size():
		return false
	return (
		(bytes[offset] == 0x4B and bytes[offset + 1] == 0x4E and bytes[offset + 2] == 0x43 and bytes[offset + 3] == 0x4D)
		or (bytes[offset] == 0x4D and bytes[offset + 1] == 0x43 and bytes[offset + 2] == 0x4E and bytes[offset + 3] == 0x4B)
	)


func _get_zone_area_id(area_id: int) -> int:
	if area_id <= 0:
		return 0
	var current := area_id
	var visited := {}
	for i in 32:
		if not _area_table.has(current):
			return current
		var area: Dictionary = _area_table[current]
		var parent := int(area.get("parent", 0))
		if parent <= 0:
			return current
		if visited.has(current):
			return current
		visited[current] = true
		current = parent
	return current


func _get_area_name(area_id: int) -> String:
	if area_id <= 0 or not _area_table.has(area_id):
		return "-"
	var area: Dictionary = _area_table[area_id]
	var name := str(area.get("name", ""))
	return name if not name.is_empty() else "-"


func _update_skybox_model() -> void:
	if not skybox_models_enabled or not _skybox_root:
		return
	var skybox_id := int(_current.get("skybox_id", 0))
	if skybox_id == _active_skybox_id:
		return
	_active_skybox_id = skybox_id
	if _active_skybox_node:
		_active_skybox_node.queue_free()
		_active_skybox_node = null
	if skybox_id <= 0:
		return
	var rel_path := _get_skybox_m2_path(skybox_id)
	if rel_path.is_empty():
		return
	var node := _load_skybox_m2(rel_path)
	if not node:
		return
	node.name = "Skybox_%d" % skybox_id
	node.scale = Vector3.ONE * maxf(0.001, skybox_model_scale)
	_disable_skybox_shadows(node)
	_skybox_root.add_child(node)
	_active_skybox_node = node
	print("SKYBOX_MODEL id=%d path=%s" % [skybox_id, rel_path])


func _update_skybox_transform() -> void:
	if not _skybox_root:
		return
	var pos := Vector3.ZERO
	if _target:
		pos = _target.global_position
	pos.y += skybox_model_height_offset
	_skybox_root.global_position = pos


func _get_skybox_m2_path(skybox_id: int) -> String:
	if not _light_skyboxes.has(skybox_id):
		return ""
	var rel_path := str((_light_skyboxes[skybox_id] as Dictionary).get("path", "")).replace("\\", "/")
	if rel_path.is_empty():
		return ""
	if rel_path.get_extension().to_lower() == "mdx":
		rel_path = rel_path.get_basename() + ".m2"
	return rel_path


func _load_skybox_m2(rel_path: String) -> Node3D:
	if _missing_skyboxes.has(rel_path):
		return null
	if not ClassDB.class_exists("M2Loader"):
		return null
	var abs_path := ProjectSettings.globalize_path(_res_path(extracted_dir).path_join(rel_path))
	if not FileAccess.file_exists(abs_path):
		_missing_skyboxes[rel_path] = true
		push_warning("WowSkyController: missing skybox model: %s" % rel_path)
		return null
	var loader = ClassDB.instantiate("M2Loader")
	if loader == null:
		return null
	var data: Dictionary = loader.call("load_m2", abs_path)
	if data.is_empty():
		_missing_skyboxes[rel_path] = true
		return null
	return M2_BUILDER_SCRIPT.build(data, extracted_dir)


func _disable_skybox_shadows(node: Node) -> void:
	if node is GeometryInstance3D:
		(node as GeometryInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	for child in node.get_children():
		_disable_skybox_shadows(child)


func _apply_sun_direction(time_hours: float) -> void:
	var day_phase := fposmod(time_hours - 6.0, 24.0) / 24.0
	var azimuth := day_phase * TAU
	var elevation := sin(clamp((time_hours - 6.0) / 12.0, 0.0, 1.0) * PI)
	if time_hours < 6.0 or time_hours > 18.0:
		elevation = -0.25
	var horizontal := sqrt(maxf(0.0, 1.0 - elevation * elevation))
	var sun_dir := Vector3(cos(azimuth) * horizontal, elevation, sin(azimuth) * horizontal).normalized()
	var light_dir := -sun_dir
	if abs(light_dir.dot(Vector3.UP)) > 0.98:
		_sun.look_at(_sun.global_position + light_dir, Vector3.FORWARD)
	else:
		_sun.look_at(_sun.global_position + light_dir, Vector3.UP)


func _sun_elevation01(time_hours: float) -> float:
	if time_hours < 6.0 or time_hours > 18.0:
		return 0.0
	return clamp(sin(((time_hours - 6.0) / 12.0) * PI), 0.0, 1.0)


func _color_luma(color: Color) -> float:
	return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722


func _world_shader_color(color: Color, desaturate: float, neutral: Color, neutral_mix: float) -> Color:
	var luma := clampf(_color_luma(color), 0.0, 1.25)
	var gray := Color(luma, luma, luma, color.a)
	var result := color.lerp(gray, clampf(desaturate, 0.0, 1.0))
	result = result.lerp(neutral, clampf(neutral_mix, 0.0, 1.0))
	return Color(
		clampf(result.r, 0.0, 1.25),
		clampf(result.g, 0.0, 1.25),
		clampf(result.b, 0.0, 1.25),
		color.a)


func _get_light_volume_count(id: int) -> int:
	return int((_light_volumes_by_map.get(id, []) as Array).size())


func _res_path(path: String) -> String:
	return path.trim_suffix("/")
