extends SceneTree

## Builds lightweight terrain-splat resources for near-camera quality.
##
## Usage:
##   godot --headless --path <project> --script res://src/tools/build_adt_splat_cache.gd -- \
##     --map Azeroth --jobs auto --lod 0 --force

const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd")
const PROGRESS_DIR := "res://data/cache/splat_progress"
const PROGRESS_REPORT_INTERVAL_MS := 2000

var _builder
var _loader
var _tex_cache: Dictionary = {}
var _progress_path := ""
var _progress_worker_index := -1
var _progress_worker_count := 1
var _progress_total := 0
var _progress_processed := 0
var _progress_built := 0
var _progress_skipped := 0
var _progress_failed := 0
var _progress_last_tile := ""


func _initialize() -> void:
	var args := OS.get_cmdline_user_args()
	var map_name := _get_arg_value(args, "--map", "Azeroth")
	var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted"))
	var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/terrain_splat_v1"))
	var lod := clampi(_get_arg_value(args, "--lod", "0").to_int(), 0, 3)
	var worker_index: Variant = _get_optional_int_arg(args, "--worker-index")
	var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int())
	var tile_x: Variant = _get_optional_int_arg(args, "--tile-x")
	var tile_y: Variant = _get_optional_int_arg(args, "--tile-y")
	var default_jobs := "1" if tile_x != null or tile_y != null else "auto"
	var jobs := _parse_jobs_arg(_get_arg_value(args, "--jobs", default_jobs))
	var force := args.has("--force")

	if jobs > 1 and worker_index == null:
		_run_parallel_controller(args, jobs)
		return

	_builder = ADT_BUILDER_SCRIPT.new()
	if not ClassDB.class_exists("ADTLoader"):
		push_error("ADTLoader not found. Rebuild GDExtension first.")
		quit(1)
		return

	_loader = ClassDB.instantiate("ADTLoader")
	if _loader == null:
		push_error("Failed to instantiate ADTLoader.")
		quit(1)
		return

	var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name)
	var dir := DirAccess.open(map_dir)
	if dir == null:
		push_error("Cannot open map directory: %s" % map_dir)
		quit(1)
		return

	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name)))

	var built := 0
	var skipped := 0
	var failed := 0
	var started_ms := Time.get_ticks_msec()
	var tile_index := 0
	var worker_total := _count_worker_tiles(dir.get_files(), map_name, tile_x, tile_y, worker_index, worker_count)
	_progress_setup(worker_index, worker_count, worker_total)

	for file_name in dir.get_files():
		if not file_name.ends_with(".adt"):
			continue

		var stem := file_name.trim_suffix(".adt")
		var parts := stem.split("_")
		if parts.size() != 3 or parts[0] != map_name:
			continue
		if not parts[1].is_valid_int() or not parts[2].is_valid_int():
			continue

		var tx := parts[1].to_int()
		var ty := parts[2].to_int()
		if tile_x != null and tx != tile_x:
			continue
		if tile_y != null and ty != tile_y:
			continue
		if worker_index != null:
			if tile_index % worker_count != int(worker_index):
				tile_index += 1
				continue
		tile_index += 1

		var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
		var output_abs_path := ProjectSettings.globalize_path(output_res_path)
		if not force and FileAccess.file_exists(output_abs_path):
			skipped += 1
			_progress_update(tx, ty, built, skipped, failed)
			continue

		var source_res_path := map_dir.path_join(file_name)
		var source_abs_path := ProjectSettings.globalize_path(source_res_path)
		var data: Dictionary = _loader.call("load_adt", source_abs_path)
		if data.is_empty() or not data.has("chunks"):
			push_warning("Splat cache failed to parse ADT: %s" % source_res_path)
			failed += 1
			_progress_update(tx, ty, built, skipped, failed)
			continue

		var payload: Dictionary = _builder.build_tile_coarse_render_payload(
			data,
			_tex_cache,
			ProjectSettings.globalize_path(extracted_dir),
			lod)
		var mesh: Mesh = payload.get("mesh", null)
		if mesh == null:
			push_warning("Splat cache produced empty mesh: %s" % source_res_path)
			failed += 1
			_progress_update(tx, ty, built, skipped, failed)
			continue

		var splat_tile: Resource = SPLAT_TILE_SCRIPT.new()
		splat_tile.set("format_version", SPLAT_TILE_SCRIPT.FORMAT_VERSION)
		splat_tile.set("map_name", map_name)
		splat_tile.set("tile_x", tx)
		splat_tile.set("tile_y", ty)
		splat_tile.set("tile_origin", _builder.get_tile_origin_for_data(data))
		splat_tile.set("lod", lod)
		splat_tile.set("terrain_mesh", mesh)

		var save_err := ResourceSaver.save(splat_tile, output_res_path)
		if save_err != OK:
			push_warning("Failed to save splat tile: %s (err=%d)" % [output_res_path, save_err])
			failed += 1
			_progress_update(tx, ty, built, skipped, failed)
			continue

		built += 1
		_progress_update(tx, ty, built, skipped, failed)
		if built % 25 == 0:
			print("Built %d splat tiles..." % built)

	var elapsed_ms := Time.get_ticks_msec() - started_ms
	print("Splat cache finished. built=%d skipped=%d failed=%d elapsed=%.2fs" % [
		built, skipped, failed, float(elapsed_ms) / 1000.0])
	_progress_finish(built, skipped, failed)
	quit(0 if failed == 0 else 2)


func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void:
	var started_ms := Time.get_ticks_msec()
	var last_report_ms := 0
	var threads: Array[Thread] = []
	_prepare_progress_dir()

	print("Starting parallel ADT splat cache: jobs=%d" % jobs)
	for worker_index in jobs:
		var thread := Thread.new()
		var err := thread.start(_run_worker_process.bind(args, worker_index, jobs))
		if err != OK:
			push_error("Failed to start splat worker thread %d (err=%d)" % [worker_index, err])
			quit(1)
			return
		threads.append(thread)

	var finished := false
	while not finished:
		OS.delay_msec(250)
		var now := Time.get_ticks_msec()
		if now - last_report_ms >= PROGRESS_REPORT_INTERVAL_MS:
			_print_parallel_progress(jobs, started_ms)
			last_report_ms = now
		finished = true
		for thread in threads:
			if thread.is_alive():
				finished = false
				break

	var failed := 0
	for thread in threads:
		var exit_code: int = int(thread.wait_to_finish())
		if exit_code != 0:
			failed += 1

	var elapsed_ms := Time.get_ticks_msec() - started_ms
	_print_parallel_progress(jobs, started_ms)
	print("Parallel splat cache finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [
		jobs, failed, float(elapsed_ms) / 1000.0])
	quit(0 if failed == 0 else 2)


func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int:
	var worker_args := PackedStringArray([
		"--headless",
		"--path",
		ProjectSettings.globalize_path("res://"),
		"--script",
		"res://src/tools/build_adt_splat_cache.gd",
		"--",
	])

	var skip_next := false
	for i in args.size():
		if skip_next:
			skip_next = false
			continue
		var arg := args[i]
		if arg == "--jobs" or arg == "--worker-index" or arg == "--worker-count":
			skip_next = i + 1 < args.size()
			continue
		worker_args.append(arg)

	worker_args.append("--jobs")
	worker_args.append("1")
	worker_args.append("--worker-index")
	worker_args.append(str(worker_index))
	worker_args.append("--worker-count")
	worker_args.append(str(worker_count))

	print("Splat worker %d/%d started" % [worker_index + 1, worker_count])
	var exit_code := OS.execute(OS.get_executable_path(), worker_args)
	print("Splat worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code])
	return exit_code


func _prepare_progress_dir() -> void:
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(PROGRESS_DIR))
	var dir := DirAccess.open(PROGRESS_DIR)
	if dir == null:
		return
	for file_name in dir.get_files():
		if file_name.begins_with("splat_worker_") and file_name.ends_with(".json"):
			dir.remove(file_name)


func _progress_setup(worker_index: Variant, worker_count: int, total: int) -> void:
	_progress_worker_index = int(worker_index) if worker_index != null else 0
	_progress_worker_count = worker_count
	_progress_total = total
	_progress_processed = 0
	_progress_built = 0
	_progress_skipped = 0
	_progress_failed = 0
	_progress_last_tile = ""
	_progress_path = PROGRESS_DIR.path_join("splat_worker_%d.json" % _progress_worker_index)
	_progress_write(false)


func _progress_update(tx: int, ty: int, built: int, skipped: int, failed: int) -> void:
	_progress_processed += 1
	_progress_built = built
	_progress_skipped = skipped
	_progress_failed = failed
	_progress_last_tile = "%d_%d" % [tx, ty]
	_progress_write(false)


func _progress_finish(built: int, skipped: int, failed: int) -> void:
	_progress_built = built
	_progress_skipped = skipped
	_progress_failed = failed
	_progress_processed = _progress_total
	_progress_write(true)


func _progress_write(done: bool) -> void:
	if _progress_path.is_empty():
		return
	var file := FileAccess.open(_progress_path, FileAccess.WRITE)
	if file == null:
		return
	file.store_string(JSON.stringify({
		"worker": _progress_worker_index,
		"workers": _progress_worker_count,
		"total": _progress_total,
		"processed": _progress_processed,
		"built": _progress_built,
		"skipped": _progress_skipped,
		"failed": _progress_failed,
		"last_tile": _progress_last_tile,
		"done": done,
	}))


func _print_parallel_progress(jobs: int, started_ms: int) -> void:
	var now := Time.get_ticks_msec()
	var total := 0
	var processed := 0
	var built := 0
	var skipped := 0
	var failed := 0
	var done := 0
	for worker_index in jobs:
		var path := PROGRESS_DIR.path_join("splat_worker_%d.json" % worker_index)
		if not FileAccess.file_exists(path):
			continue
		var file := FileAccess.open(path, FileAccess.READ)
		if file == null:
			continue
		var parsed: Variant = JSON.parse_string(file.get_as_text())
		if not (parsed is Dictionary):
			continue
		var row: Dictionary = parsed
		total += int(row.get("total", 0))
		processed += int(row.get("processed", 0))
		built += int(row.get("built", 0))
		skipped += int(row.get("skipped", 0))
		failed += int(row.get("failed", 0))
		if bool(row.get("done", false)):
			done += 1

	var elapsed := maxf(0.001, float(now - started_ms) / 1000.0)
	var rate := float(processed) / elapsed
	print("Progress: %d/%d tiles, built=%d skipped=%d failed=%d, workers done=%d/%d, %.2f tiles/s" % [
		processed, total, built, skipped, failed, done, jobs, rate])


func _count_worker_tiles(
		files: PackedStringArray,
		map_name: String,
		tile_x: Variant,
		tile_y: Variant,
		worker_index: Variant,
		worker_count: int) -> int:
	var count := 0
	var tile_index := 0
	for file_name in files:
		if not file_name.ends_with(".adt"):
			continue
		var parts := file_name.trim_suffix(".adt").split("_")
		if parts.size() != 3 or parts[0] != map_name:
			continue
		if not parts[1].is_valid_int() or not parts[2].is_valid_int():
			continue
		var tx := parts[1].to_int()
		var ty := parts[2].to_int()
		if tile_x != null and tx != tile_x:
			continue
		if tile_y != null and ty != tile_y:
			continue
		if worker_index != null and tile_index % worker_count != int(worker_index):
			tile_index += 1
			continue
		tile_index += 1
		count += 1
	return count


func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String:
	var index := args.find(name)
	if index >= 0 and index + 1 < args.size():
		return args[index + 1]
	return default_value


func _get_optional_int_arg(args: PackedStringArray, name: String):
	var index := args.find(name)
	if index >= 0 and index + 1 < args.size() and args[index + 1].is_valid_int():
		return args[index + 1].to_int()
	return null


func _parse_jobs_arg(value: String) -> int:
	var normalized := value.strip_edges().to_lower()
	if normalized == "auto":
		return clampi(OS.get_processor_count() - 2, 1, 8)
	return maxi(1, normalized.to_int())


func _normalize_res_path(path: String) -> String:
	if path.begins_with("res://"):
		return path
	return "res://%s" % path.trim_prefix("./").trim_prefix("/")
