## MapDownloader — Client-side map download and cache management
##
## Autoload singleton that downloads .pck map packs from the master server
## registry and loads them into the running game via ProjectSettings.load_resource_pack().
##
## ## Architecture
##
##   MapRegistry Server (Python)        MapDownloader (Godot)
##   ┌──────────────────────┐           ┌──────────────────┐
##   │  GET /maps           │ ◄── list ──│  get_map_list()  │
##   │  GET /maps/:name.pck │ ◄── dl  ──│  download_map()  │
##   │  GET /maps/:name.json│ ◄── meta ─│  get_map_info()  │
##   └──────────────────────┘           │                  │
##                                      │  Cache:          │
##          Disk cache                   │  user://maps/    │
##          user://maps/<name>.pck  ─────│  .pck files      │
##          user://maps/manifest.json    │  manifest.json   │
##                                      └──────────────────┘
##
## ## Map Lifecycle
##
##   1. Server sends map list (e.g. ["de_dust2", "de_inferno"])
##   2. MapDownloader checks local cache via manifest.json
##   3. Missing maps are downloaded from the registry server
##   4. Downloaded .pck is loaded via load_resource_pack()
##   5. Map scene becomes available at res://maps/<name>.tscn
##
## ## Configuration
##
##   Set the registry URL before first use:
##     MapDownloader.registry_url = "https://maps.example.com"
##     # or via env: MAP_REGISTRY_URL
##
## ## Signals
##
##   map_download_progress(map_name, bytes_received, bytes_total)
##   map_download_complete(map_name, success)
##   map_loaded(map_name)

extends Node

# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------

## Emitted during download for progress bar updates.
signal map_download_progress(map_name: String, bytes_received: int, bytes_total: int)

## Emitted when a map download finishes. success=true means the .pck is on disk.
signal map_download_complete(map_name: String, success: bool)

## Emitted after a .pck is loaded into the resource system.
signal map_loaded(map_name: String)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

const MAP_CACHE_DIR: String = "user://maps/"
const MANIFEST_FILE: String = "user://maps/manifest.json"
const DEFAULT_REGISTRY_URL: String = "http://127.0.0.1:8090"

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

## URL of the map registry server (without trailing slash).
## Can be overridden at runtime.
var registry_url: String = DEFAULT_REGISTRY_URL

## Timeout for HTTP requests in seconds.
var http_timeout: float = 30.0

## Max concurrent downloads.
var max_concurrent: int = 2

# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------

var _http: HTTPRequest = null
var _active_downloads: Dictionary = {}  # map_name → Dictionary (progress tracking)
var _download_queue: Array[Dictionary] = []
var _manifest: Dictionary = {}          # {map_name: {version, size, downloaded_at}}
var _loaded_pcks: Array[String] = []    # tracks which .pcks are already loaded

# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------

func _ready() -> void:
	# Create cache directory
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(MAP_CACHE_DIR))

	# Load local manifest
	_load_manifest()

	# Override registry URL from environment
	if OS.has_environment("MAP_REGISTRY_URL"):
		registry_url = OS.get_environment("MAP_REGISTRY_URL")

	print("[MapDownloader] Registry URL: %s" % registry_url)
	print("[MapDownloader] Cache dir:    %s" % MAP_CACHE_DIR)
	print("[MapDownloader] Cached maps:  %d" % _manifest.size())


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

## Fetch the list of available maps from the registry server.
## Returns a signal-based result via map_download_progress / etc.
## Call this first to discover what maps exist.
func fetch_map_list() -> void:
	var url: String = "%s/maps" % [registry_url]
	_http_get(url, _on_map_list_received)


## Check if a map is in the local cache.
func is_map_cached(map_name: String) -> bool:
	if not _manifest.has(map_name):
		return false

	var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
	var global_path: String = ProjectSettings.globalize_path(pck_path)
	return FileAccess.file_exists(global_path)


## Download a single map .pck from the registry server.
## The map is saved to user://maps/<name>.pck and loaded automatically.
func download_map(map_name: String) -> void:
	# Skip if already cached
	if is_map_cached(map_name):
		print("[MapDownloader] %s already cached — loading" % map_name)
		_load_map_pck(map_name)
		return

	# Check if already downloading
	if map_name in _active_downloads:
		print("[MapDownloader] %s is already downloading" % map_name)
		return

	# Queue or start download
	var entry := {
		map_name = map_name,
		url = "%s/maps/%s.pck" % [registry_url, map_name],
	}
	if _active_downloads.size() < max_concurrent:
		_start_download(entry)
	else:
		_download_queue.append(entry)
		print("[MapDownloader] %s queued (%d waiting)" % [map_name, _download_queue.size()])


## Download multiple maps. Pass an array of map names.
func download_maps(map_names: Array[String]) -> void:
	for name in map_names:
		download_map(name)


## Load a cached .pck into the resource system.
## Returns true if the pack was loaded successfully.
func load_map(map_name: String) -> bool:
	return _load_map_pck(map_name)


## Remove a cached map from disk and manifest.
func remove_map(map_name: String) -> void:
	var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
	var global_path: String = ProjectSettings.globalize_path(pck_path)
	if FileAccess.file_exists(global_path):
		DirAccess.remove_absolute(global_path)
	_manifest.erase(map_name)
	_save_manifest()
	_loaded_pcks.erase(map_name)
	print("[MapDownloader] Removed cached map: %s" % map_name)


## Get list of locally cached map names.
func get_cached_maps() -> Array[String]:
	return _manifest.keys()


## Get info about a cached map from the manifest.
func get_cached_map_info(map_name: String) -> Dictionary:
	return _manifest.get(map_name, {})


## Clear all cached maps.
func clear_cache() -> void:
	for name in _manifest.keys():
		remove_map(name)
	print("[MapDownloader] Cache cleared")


# ---------------------------------------------------------------------------
# Internal: HTTP helpers
# ---------------------------------------------------------------------------

func _http_get(url: String, callback: Callable) -> void:
	var http := HTTPRequest.new()
	add_child(http)
	http.connect("request_completed", callback)
	http.timeout = http_timeout
	http.request(url)


func _http_download(url: String, save_path: String, callback: Callable) -> void:
	var http := HTTPRequest.new()
	add_child(http)
	http.connect("request_completed", callback)
	http.download_file = save_path
	http.timeout = http_timeout

	# Connect download progress
	if http.has_signal("download_progress"):
		# Godot 4's HTTPRequest has request_completed but not always download_progress
		# We track via the file size after completion
		pass

	http.request(url)


# ---------------------------------------------------------------------------
# Internal: Download handling
# ---------------------------------------------------------------------------

func _start_download(entry: Dictionary) -> void:
	var map_name: String = entry.map_name
	var url: String = entry.url
	var save_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, map_name])

	_active_downloads[map_name] = entry
	print("[MapDownloader] Downloading: %s → %s" % [url, save_path])

	_http_download(url, save_path, _on_map_downloaded.bind(map_name))


func _process_download_queue() -> void:
	if _download_queue.is_empty():
		return
	var next: Dictionary = _download_queue.pop_front()
	_start_download(next)


# ---------------------------------------------------------------------------
# Internal: Callbacks
# ---------------------------------------------------------------------------

func _on_map_list_received(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
	if response_code != 200:
		push_error("[MapDownloader] Failed to fetch map list: HTTP %d" % response_code)
		return

	var json := JSON.new()
	var parse_err: Error = json.parse(body.get_string_from_utf8())
	if parse_err != OK:
		push_error("[MapDownloader] Failed to parse map list JSON: %s" % error_string(parse_err))
		return

	var data: Dictionary = json.data
	var maps: Array = data.get("maps", data.get("available", []))
	if maps.is_empty():
		print("[MapDownloader] No maps available on registry")
		return

	print("[MapDownloader] Registry offers %d maps: %s" % [maps.size(), maps])

	# Auto-download any maps we don't have cached
	var to_download: Array[String] = []
	for m in maps:
		var name: String = str(m) if typeof(m) == TYPE_STRING else ""
		if name.is_empty():
			# Support both string lists and object lists
			if typeof(m) == TYPE_DICTIONARY:
				name = m.get("name", "")
		if not name.is_empty() and not is_map_cached(name):
			to_download.append(name)

	if not to_download.is_empty():
		print("[MapDownloader] Downloading %d new maps: %s" % [to_download.size(), to_download])
		download_maps(to_download)
	else:
		print("[MapDownloader] All registry maps are already cached")


func _on_map_downloaded(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, map_name: String) -> void:
	_active_downloads.erase(map_name)

	if response_code != 200:
		push_error("[MapDownloader] Download failed for %s: HTTP %d" % [map_name, response_code])
		map_download_complete.emit(map_name, false)
		_process_download_queue()
		return

	print("[MapDownloader] Downloaded %s successfully" % map_name)
	map_download_complete.emit(map_name, true)

	# Update manifest
	var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
	var global_path: String = ProjectSettings.globalize_path(pck_path)
	var file_size: int = 0
	if FileAccess.file_exists(global_path):
		file_size = FileAccess.get_size(global_path)

	_manifest[map_name] = {
		version = _manifest.get(map_name, {}).get("version", 1),
		size = file_size,
		downloaded_at = Time.get_datetime_string_from_system(),
	}
	_save_manifest()

	# Load the map into the resource system
	_load_map_pck(map_name)

	_process_download_queue()


# ---------------------------------------------------------------------------
# Internal: PCK loading
# ---------------------------------------------------------------------------

## Load a .pck into Godot's resource system.
## After this call, res://maps/<name>.tscn becomes available.
func _load_map_pck(map_name: String) -> bool:
	# Skip if already loaded
	if map_name in _loaded_pcks:
		return true

	var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
	var global_path: String = ProjectSettings.globalize_path(pck_path)

	if not FileAccess.file_exists(global_path):
		push_error("[MapDownloader] Cannot load %s: .pck not found at %s" % [map_name, global_path])
		return false

	var err: Error = ProjectSettings.load_resource_pack(global_path)
	if err != OK:
		push_error("[MapDownloader] Failed to load map pack %s: %s" % [map_name, error_string(err)])
		return false

	_loaded_pcks.append(map_name)
	print("[MapDownloader] Loaded map pack: %s (%s)" % [map_name, pck_path])
	map_loaded.emit(map_name)
	return true


# ---------------------------------------------------------------------------
# Internal: Manifest persistence
# ---------------------------------------------------------------------------

func _load_manifest() -> void:
	var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
	if not FileAccess.file_exists(global_path):
		_manifest = {}
		return

	var f := FileAccess.open(global_path, FileAccess.READ)
	if f == null:
		_manifest = {}
		return

	var json := JSON.new()
	var parse_err: Error = json.parse(f.get_as_text())
	f.close()

	if parse_err != OK:
		push_warning("[MapDownloader] Failed to parse manifest — starting fresh")
		_manifest = {}
	else:
		_manifest = json.data
		_validate_manifest()


func _save_manifest() -> void:
	var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
	var f := FileAccess.open(global_path, FileAccess.WRITE)
	if f == null:
		push_error("[MapDownloader] Cannot save manifest to %s" % global_path)
		return

	f.store_string(JSON.stringify(_manifest, "\t", false))
	f.close()


## Validate the manifest: remove entries whose .pck files no longer exist on disk.
func _validate_manifest() -> void:
	var stale: Array[String] = []
	for name in _manifest.keys():
		var pck_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, name])
		if not FileAccess.file_exists(pck_path):
			stale.append(name)

	for name in stale:
		print("[MapDownloader] Manifest cleanup: %s (file missing)" % name)
		_manifest.erase(name)

	if not stale.is_empty():
		_save_manifest()
