@tool
extends Node

## EditorSetting key used to defer a self_update telemetry event across the
## disable -> enable boundary. The runner runs while the plugin is disabled,
## so it can't send WebSocket events directly; it writes the outcome here
## and the re-enabled plugin's `_enter_tree` flushes it. See
## `plugin.gd::_flush_pending_self_update_telemetry`.
const PENDING_SELF_UPDATE_TELEMETRY_KEY := "godot_ai/pending_self_update_event"

## Self-update runner. Owns the install-and-reload sequence from
## `start(zip_path, temp_dir, detached_dock)` onward: extract files into
## `addons/godot_ai/` with rollback bookkeeping, scan the filesystem,
## re-enable the plugin, and clean up the detached dock.
##
## Single-phase install: writes the full `_new_file_paths +
## _existing_file_paths` set before issuing exactly one
## `EditorFileSystem.scan()`. Godot's scan-time reparse pass then sees one
## consistent v(N+1) snapshot, so new files and existing files can resolve
## each other's same-release API changes regardless of parse order.
##
## Not owned here: HTTP download (in `utils/update_manager.gd`), banner UI
## (in `mcp_dock.gd`), or server stop prep (called by
## `plugin.gd::install_downloaded_update` before this runner starts via
## `_lifecycle.prepare_for_update_reload()`).
##
## This node is deliberately tiny and not parented under the EditorPlugin:
## it survives `set_plugin_enabled(false)`, extracts the downloaded release,
## waits for Godot's filesystem scan, then enables the plugin again. The old
## dock is detached before this runner starts, kept alive while deferred
## Callables drain, and freed only after the new plugin instance is loaded.

const PLUGIN_CFG_PATH := "res://addons/godot_ai/plugin.cfg"
const PRE_DISABLE_DRAIN_FRAMES := 8
const POST_DISABLE_DRAIN_FRAMES := 2
const POST_ENABLE_FREE_FRAMES := 8
const INSTALL_BASE_PATH := "res://"
const ZIP_ADDON_PREFIX := "addons/godot_ai/"
const TEMP_FILE_SUFFIX := ".godot_ai_update_tmp"
const INSTALL_BACKUP_SUFFIX := ".update_backup"

## Outcome of `_install_zip_paths`. `OK` means all listed files were replaced.
## `FAILED_CLEAN` means a write/rename failed mid-batch but every previously
## written file was rolled back to its vN content (or removed, if the file
## was new in vN+1). `FAILED_MIXED` means rollback itself failed: the addons
## tree contains a mix of vN and vN+1 files. The runner MUST NOT re-enable
## the plugin in the MIXED case — see issue #297 finding #9 for the data-loss
## scenario this guards against.
enum InstallStatus { OK, FAILED_CLEAN, FAILED_MIXED }

var _zip_path := ""
var _temp_dir := ""
var _detached_dock = null
var _started := false
var _next_step := ""
var _frames_remaining := 0
var _waiting_for_scan := false
var _scan_next_step := ""
## Watchdog for `_start_filesystem_scan`: if Godot's `filesystem_changed`
## signal never fires (slow disk, NFS, AV holding the just-extracted addon
## files open), the runner used to hang in `_waiting_for_scan = true`
## forever and the dock stayed disabled. After this timeout we disconnect
## the signal and proceed anyway — worst case the new files aren't visible
## on the first frame, but they get picked up on the next scan. See
## audit-v2 finding #9 (issue #353). Untyped to match the codebase's
## defensive pattern for state that survives `fs.scan()` during update.
const SCAN_WATCHDOG_SECS := 30.0
var _scan_watchdog_timer = null
## Sticky flag set by `_on_scan_watchdog_timeout`. Subsequent
## `_start_filesystem_scan` calls in the same update bypass connect+scan
## so a delayed `filesystem_changed` emission from the timed-out scan
## can't fire on a freshly-armed listener for the next scan and falsely
## settle it before that scan actually completed. See PR #381 review for
## the cross-scan race this guards against.
var _scan_timed_out := false
## Keep Array fields untyped: this runner survives fs.scan() during update,
## and typed Variant storage is part of the hot-reload crash class.
var _new_file_paths = []
var _existing_file_paths = []
## Per-file install records accumulated during install so a later failure
## can roll back files already replaced earlier in the same update.
## Each entry is an untyped Dictionary with target_path / backup_path /
## had_original keys. Cleared by `_finalize_install_success` on full success
## and by `_rollback_paths_written` on failure.
var _paths_written = []
## Set true if `_install_zip_file`'s inner restore-from-backup couldn't
## complete (backup gone, copy failed). The failed file is NOT recorded in
## `_paths_written` because the function bails at that point — without this
## flag, `_rollback_paths_written` would walk only the prior records, all
## restore cleanly, and report FAILED_CLEAN even though the current target
## is missing or stale on disk. Surfaces FAILED_MIXED so the runner refuses
## to re-enable the plugin against a half-installed tree.
var _restore_failed := false
## Test-only opt-out for the scan-watchdog `push_warning` lines. The
## watchdog unit tests in `test_update_reload_runner.gd` invoke
## `_on_scan_watchdog_timeout()` and the post-timeout
## `_start_filesystem_scan` bypass branch directly to pin their behavior
## — but those code paths' `push_warning` calls then appear as yellow
## console noise in every `test_run`, training reviewers to ignore the
## runner's real production warnings. Tests set this true; production
## leaves it false so genuine scan timeouts during a real self-update
## still surface loudly. See issue #413.
var _suppress_scan_warnings := false


func start(zip_path: String, temp_dir: String, detached_dock) -> void:
	if _started:
		return
	_started = true
	_zip_path = zip_path
	_temp_dir = temp_dir
	_detached_dock = detached_dock
	_wait_frames(PRE_DISABLE_DRAIN_FRAMES, "_disable_old_plugin")


func _process(_delta: float) -> void:
	if _frames_remaining <= 0:
		set_process(false)
		return

	_frames_remaining -= 1
	if _frames_remaining <= 0:
		var step := _next_step
		_next_step = ""
		set_process(false)
		call(step)


func _wait_frames(frame_count: int, next_step: String) -> void:
	_next_step = next_step
	_frames_remaining = max(1, frame_count)
	set_process(true)


func _disable_old_plugin() -> void:
	## Disable before writing or scanning new scripts. This avoids both the
	## Dict/Array field-storage hot-reload crash (#245) and cached handler
	## constructor shape mismatches (#247) for plugin-owned instances.
	print("MCP | update runner disabling old plugin")
	EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, false)
	_wait_frames(POST_DISABLE_DRAIN_FRAMES, "_extract_and_scan")


func _extract_and_scan() -> void:
	if not _read_update_manifest():
		EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
		_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
		return

	var install_paths := []
	install_paths.append_array(_new_file_paths)
	install_paths.append_array(_existing_file_paths)

	var status := _install_zip_paths(install_paths)
	if status != InstallStatus.OK:
		_handle_install_failure(status)
		return

	_finalize_install_success()
	_cleanup_update_temp()
	## One scan covers both dependency directions: plugin.gd's preloads of
	## new files resolve because those files are already present, and new
	## files' references to new members or static-ness changes on existing
	## load-surface scripts resolve because those existing files are also
	## already at v(N+1). The goal is a consistent snapshot before scan, not
	## a tree-atomic install; per-file writes still use `.tmp` + rename and
	## rollback on failure.
	_start_filesystem_scan("_enable_new_plugin")


func _start_filesystem_scan(next_step: String = "_enable_new_plugin") -> void:
	var fs := EditorInterface.get_resource_filesystem()
	var deferred_step := next_step if not next_step.is_empty() else "_enable_new_plugin"
	if fs == null:
		call_deferred(deferred_step)
		return

	## Bypass: a previous scan in this update already watchdog'd, so the
	## editor's filesystem is unresponsive. Re-arming a `filesystem_changed`
	## listener now would race with a delayed emission from the timed-out
	## scan: that single emission would fire whichever listener is currently
	## connected to the shared signal, falsely settling this scan before it
	## actually completed. Skip the wait; Godot's normal background scan
	## catches up after the plugin re-enables. See PR #381 review.
	if _scan_timed_out:
		if not _suppress_scan_warnings:
			push_warning(
				"MCP | skipping filesystem_changed wait after previous timeout (next_step=%s)"
				% deferred_step
			)
		call_deferred(deferred_step)
		return

	_waiting_for_scan = true
	_scan_next_step = deferred_step
	if not fs.filesystem_changed.is_connected(_on_filesystem_changed):
		fs.filesystem_changed.connect(_on_filesystem_changed, CONNECT_ONE_SHOT)
	_arm_scan_watchdog()
	fs.scan()


func _arm_scan_watchdog() -> void:
	if _scan_watchdog_timer == null:
		_scan_watchdog_timer = Timer.new()
		_scan_watchdog_timer.one_shot = true
		_scan_watchdog_timer.timeout.connect(_on_scan_watchdog_timeout)
		add_child(_scan_watchdog_timer)
	_scan_watchdog_timer.start(SCAN_WATCHDOG_SECS)


func _stop_scan_watchdog() -> void:
	if _scan_watchdog_timer != null:
		_scan_watchdog_timer.stop()


func _on_scan_watchdog_timeout() -> void:
	## Signal didn't fire within SCAN_WATCHDOG_SECS — most likely the
	## filesystem scan is blocked behind a slow disk / NFS / AV scanner
	## still reading the just-extracted addon files.
	## Set the sticky `_scan_timed_out` flag so any subsequent
	## `_start_filesystem_scan` in this update skips its connect+scan
	## (otherwise a delayed emission from this scan would falsely settle
	## the next scan's listener — see PR #381 review).
	## Disconnect the current listener too, so this scan's listener can't
	## double-call `_finish_scan_wait` if the signal arrives quickly after
	## the timeout fires. `_finish_scan_wait` is idempotent on
	## `_waiting_for_scan == false`.
	if not _waiting_for_scan:
		return
	if not _suppress_scan_warnings:
		push_warning(
			"MCP | filesystem_changed didn't fire within %ds; proceeding without scan confirmation"
			% int(SCAN_WATCHDOG_SECS)
		)
	_scan_timed_out = true
	var fs := EditorInterface.get_resource_filesystem()
	if fs != null and fs.filesystem_changed.is_connected(_on_filesystem_changed):
		fs.filesystem_changed.disconnect(_on_filesystem_changed)
	_finish_scan_wait()


func _read_update_manifest() -> bool:
	var zip_path := ProjectSettings.globalize_path(_zip_path)
	var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH)

	var reader := ZIPReader.new()
	if reader.open(zip_path) != OK:
		print("MCP | update extract failed: could not open %s" % zip_path)
		return false

	_new_file_paths.clear()
	_existing_file_paths.clear()
	var has_plugin_cfg := false
	var has_plugin_script := false
	var files := reader.get_files()
	for file_path in files:
		if not file_path.begins_with(ZIP_ADDON_PREFIX):
			continue
		var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX)
		## Many zip builders (`zip -r` without `-D`, AssetLib uploads, hand-
		## built archives) emit zero-byte directory entries like
		## `addons/godot_ai/`. Skip those before the safety check; the
		## empty-segment guard in `_is_safe_zip_addon_file` would otherwise
		## flag the bare prefix as unsafe and abort the extract. Current
		## release.yml passes `-D` to strip them, but installed runners must
		## still tolerate older or manually built zips.
		if rel_path.is_empty() or file_path.ends_with("/"):
			continue
		if not _is_safe_zip_addon_file(file_path):
			print("MCP | update extract failed: unsafe zip path %s" % file_path)
			reader.close()
			return false
		if rel_path == "plugin.cfg":
			has_plugin_cfg = true
		elif rel_path == "plugin.gd":
			has_plugin_script = true
		var target_path := install_base.path_join(file_path)
		if FileAccess.file_exists(target_path):
			_existing_file_paths.append(file_path)
		else:
			_new_file_paths.append(file_path)
	reader.close()
	if not has_plugin_cfg:
		print("MCP | update extract failed: zip is missing plugin.cfg")
		return false
	if not has_plugin_script:
		print("MCP | update extract failed: zip is missing plugin.gd")
		return false
	return true


func _handle_install_failure(status: int) -> void:
	_record_pending_self_update({
		"status": "failed_mixed" if status == InstallStatus.FAILED_MIXED else "failed_clean",
	})
	if status == InstallStatus.FAILED_MIXED:
		## Half-installed addon tree on disk: re-enabling the plugin would
		## load a mix of vN and vN+1 files. Print a load-bearing diagnostic
		## and bail without re-enabling — user must restore manually. See
		## issue #297 finding #9 for the data-loss scenario.
		push_error(
			"MCP | self-update failed mid-install AND rollback could not"
			+ " restore the previous addons/godot_ai/ contents. The plugin"
			+ " is left disabled. Inspect addons/godot_ai/ for"
			+ " *.update_backup / *.godot_ai_update_tmp files and restore"
			+ " manually before re-enabling the plugin."
		)
		print(
			"MCP | self-update aborted: addons/godot_ai/ is in a mixed state;"
			+ " plugin left disabled (manual intervention required)."
		)
		_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")
		return
	## FAILED_CLEAN: rollback restored every previously-written file. Safe
	## to re-enable the previous plugin version.
	print("MCP | self-update rolled back; re-enabling previous plugin version")
	EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
	_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")


func _is_safe_zip_addon_file(file_path: String) -> bool:
	if file_path.is_absolute_path() or file_path.contains("\\"):
		return false
	if not file_path.begins_with(ZIP_ADDON_PREFIX):
		return false
	var rel_path := file_path.trim_prefix(ZIP_ADDON_PREFIX)
	if rel_path.is_empty() or rel_path.ends_with("/"):
		return false
	for segment in rel_path.split("/", true):
		if segment.is_empty() or segment == "." or segment == "..":
			return false
	return true


func _install_zip_paths(paths: Array) -> int:
	if paths.is_empty():
		return InstallStatus.OK

	var zip_path := ProjectSettings.globalize_path(_zip_path)
	var reader := ZIPReader.new()
	if reader.open(zip_path) != OK:
		print("MCP | update extract failed: could not reopen %s" % zip_path)
		## Nothing else can be written, but earlier files from this update
		## may have landed on disk; roll those back too.
		return _rollback_paths_written()

	var install_base := ProjectSettings.globalize_path(INSTALL_BASE_PATH)
	for file_path in paths:
		var record := _install_zip_file(reader, String(file_path), install_base)
		if record.is_empty():
			reader.close()
			return _rollback_paths_written()
		_paths_written.append(record)
	reader.close()
	return InstallStatus.OK


func _install_zip_file(
	reader: ZIPReader, file_path: String, install_base: String
) -> Dictionary:
	var target_path := install_base.path_join(file_path)
	var dir := target_path.get_base_dir()
	if DirAccess.make_dir_recursive_absolute(dir) != OK:
		print("MCP | update extract failed: could not create %s" % dir)
		return {}

	var temp_path := target_path + TEMP_FILE_SUFFIX
	DirAccess.remove_absolute(temp_path)
	var content := reader.read_file(file_path)
	var f := FileAccess.open(temp_path, FileAccess.WRITE)
	if f == null:
		print("MCP | update extract failed: could not write %s (error %d)" % [
			temp_path,
			FileAccess.get_open_error(),
		])
		return {}
	f.store_buffer(content)
	var write_error := f.get_error()
	f.close()
	if write_error != OK:
		print("MCP | update extract failed: write error %d for %s" % [
			write_error,
			temp_path,
		])
		DirAccess.remove_absolute(temp_path)
		return {}

	## Back up the original via COPY (not rename) so the source of truth
	## stays in place if a later step fails. Rolled back via
	## `_rollback_paths_written` if a subsequent file in this batch — or a
	## later batch — can't be installed.
	var had_original := FileAccess.file_exists(target_path)
	var backup_path := target_path + INSTALL_BACKUP_SUFFIX
	if had_original:
		DirAccess.remove_absolute(backup_path)
		if DirAccess.copy_absolute(target_path, backup_path) != OK:
			DirAccess.remove_absolute(temp_path)
			print("MCP | update extract failed: could not back up %s" % target_path)
			return {}

	if DirAccess.rename_absolute(temp_path, target_path) != OK:
		## POSIX and APFS replace atomically. Some filesystems reject
		## rename-over-existing; keep a fallback so the update can still
		## proceed, but the common path never exposes a truncated target.
		DirAccess.remove_absolute(target_path)
		if DirAccess.rename_absolute(temp_path, target_path) != OK:
			DirAccess.remove_absolute(temp_path)
			## Target was removed above; restore from the COPY backup so the
			## addons dir is left in its vN state before we surface failure.
			## Only delete the backup if the restore copy actually succeeded
			## — if it didn't, target_path is missing, and `_restore_failed`
			## tells `_rollback_paths_written` to surface FAILED_MIXED so the
			## runner refuses to re-enable the plugin. Leaving the backup on
			## disk also gives the user a manual recovery path. Without this
			## guard the failed file isn't tracked anywhere (we return `{}`,
			## not appended to `_paths_written`) and the caller would
			## erroneously see FAILED_CLEAN.
			if had_original:
				if (
					FileAccess.file_exists(backup_path)
					and DirAccess.copy_absolute(backup_path, target_path) == OK
				):
					DirAccess.remove_absolute(backup_path)
				else:
					_restore_failed = true
			print("MCP | update extract failed: could not replace %s" % target_path)
			return {}
	return {
		"target_path": target_path,
		"backup_path": backup_path,
		"had_original": had_original,
	}


## Restore (or remove) every file already touched in this update. Safe to
## call after a partial install — entries are processed in reverse so a
## given target is restored before the next earlier write of the same path
## could resurrect a stale value. Returns FAILED_CLEAN if every entry was
## restored AND no in-flight `_install_zip_file` left a target stranded
## (`_restore_failed`); FAILED_MIXED otherwise. The caller MUST NOT
## re-enable the plugin in the MIXED case.
func _rollback_paths_written() -> int:
	var any_failed := false
	var i := _paths_written.size() - 1
	while i >= 0:
		var record = _paths_written[i]
		var target := String(record.get("target_path", ""))
		var backup := String(record.get("backup_path", ""))
		var had_original := bool(record.get("had_original", false))
		if had_original:
			if not FileAccess.file_exists(backup):
				print("MCP | update rollback failed: backup missing for %s" % target)
				any_failed = true
			else:
				DirAccess.remove_absolute(target)
				if DirAccess.copy_absolute(backup, target) != OK:
					print("MCP | update rollback failed: could not restore %s" % target)
					any_failed = true
				else:
					DirAccess.remove_absolute(backup)
		else:
			if FileAccess.file_exists(target):
				if DirAccess.remove_absolute(target) != OK:
					print(
						"MCP | update rollback failed: could not delete %s" % target
					)
					any_failed = true
		i -= 1
	_paths_written.clear()
	if any_failed or _restore_failed:
		return InstallStatus.FAILED_MIXED
	return InstallStatus.FAILED_CLEAN


## Discard accumulated backups after the combined install succeeds. Backups
## are best-effort: a failure here doesn't compromise the new install, just
## leaves stray *.update_backup files for the user to clean up.
func _finalize_install_success() -> void:
	for record in _paths_written:
		if record.get("had_original", false):
			DirAccess.remove_absolute(String(record.get("backup_path", "")))
	_paths_written.clear()
	_record_pending_self_update({"status": "success"})


## Persist a self_update event description so the re-enabled plugin can
## emit it once its WebSocket is connected. Survives the disable -> enable
## window where the runner cannot send anything itself.
func _record_pending_self_update(data: Dictionary) -> void:
	var settings := EditorInterface.get_editor_settings()
	if settings == null:
		return
	settings.set_setting(PENDING_SELF_UPDATE_TELEMETRY_KEY, JSON.stringify(data))


func _cleanup_update_temp() -> void:
	DirAccess.remove_absolute(ProjectSettings.globalize_path(_zip_path))
	DirAccess.remove_absolute(ProjectSettings.globalize_path(_temp_dir))


func _on_filesystem_changed() -> void:
	_finish_scan_wait()


func _finish_scan_wait() -> void:
	if not _waiting_for_scan:
		return
	_waiting_for_scan = false
	_stop_scan_watchdog()
	var next_step := _scan_next_step
	_scan_next_step = ""
	set_process(false)
	if next_step.is_empty():
		next_step = "_enable_new_plugin"
	call_deferred(next_step)


func _enable_new_plugin() -> void:
	print("MCP | update runner enabling new plugin")
	EditorInterface.set_plugin_enabled(PLUGIN_CFG_PATH, true)
	_wait_frames(POST_ENABLE_FREE_FRAMES, "_cleanup_and_finish")


func _cleanup_and_finish() -> void:
	_cleanup_detached_dock()
	queue_free()


func _cleanup_detached_dock() -> void:
	if _detached_dock != null and is_instance_valid(_detached_dock):
		_detached_dock.queue_free()
	_detached_dock = null
