@tool
extends RefCounted

## Scanner that detects whether `addons/godot_ai/` is in a half-installed
## state left behind by a self-update whose rollback couldn't restore the
## previous addon contents (`UpdateReloadRunner.InstallStatus.FAILED_MIXED`).
##
## Without this surface the user sees "plugin won't start" with no actionable
## context, re-runs the update, and compounds the mismatch (issue #354 /
## audit-v2 #10). The dock paints a banner from `diagnose()` and
## `editor_handler.gd::get_editor_state` includes the same Dictionary so an
## MCP agent can see and report the state.

const ADDON_DIR := "res://addons/godot_ai/"
## Producer is `update_reload_runner.gd::INSTALL_BACKUP_SUFFIX`. Inlined as a
## literal because old two-phase runners can parse this diagnostic script
## against stale runner Script-object content during their mixed-snapshot
## scan. `test_update_backup_suffix_stays_in_sync` guards against drift.
const BACKUP_SUFFIX := ".update_backup"
## Cap so a runaway addons tree (someone parented the wrong dir, an old
## crashed install left thousands of artifacts) can't blow the
## `editor_state` payload size or freeze the editor on first paint.
const MAX_BACKUP_RESULTS := 200
## TTL for the `diagnose()` cache. `editor_state` is one of the highest-
## traffic MCP tools (agents poll it constantly) and a recursive
## `DirAccess` walk on every call would put I/O on the 4ms `_process()`
## budget. Mixed-state is rare and persistent across editor restarts, so
## a few seconds of staleness is acceptable; the dock's Re-scan button
## bypasses the cache via `force=true` for immediate feedback.
const CACHE_TTL_MSEC := 5000

static var _cache_value: Dictionary = {}
static var _cache_timestamp_msec: int = -1


## Walk `dir` recursively and return every `res://`-relative path that ends
## in `.update_backup`, sorted ascending. Truncates at `MAX_BACKUP_RESULTS`
## — the truncation flag is exposed via `diagnose()`.
##
## Walk order is deterministic: entries within each directory are sorted
## alphabetically, subdirs pushed reverse-sorted so DFS pops them in
## ascending order. Without this two scans of the same mixed tree could
## return different 200-file slices when truncation kicks in (Godot's
## `list_dir` order isn't guaranteed stable across filesystems).
static func find_backups(dir: String = ADDON_DIR) -> Array:
	var results: Array = []
	var stack: Array = [dir]
	while not stack.is_empty():
		if results.size() >= MAX_BACKUP_RESULTS:
			break
		var current: String = stack.pop_back()
		var d := DirAccess.open(current)
		## Missing dir, permission error, or unreadable junction — skip
		## silently. A missing addons dir is the bare-clone case; mid-walk
		## errors stay quiet so a single permission glitch can't block the
		## diagnostic the rest of the scan would have produced.
		if d == null:
			continue
		var entries: Array = []
		d.list_dir_begin()
		while true:
			var entry := d.get_next()
			if entry.is_empty():
				break
			if entry == "." or entry == "..":
				continue
			entries.append({"name": entry, "is_dir": d.current_is_dir()})
		d.list_dir_end()
		entries.sort_custom(func(a, b): return a["name"] < b["name"])
		## Push subdirs reverse-sorted so the next outer iteration pops
		## them in ascending order — see method docstring for why this
		## determinism matters for the truncated case.
		for i in range(entries.size() - 1, -1, -1):
			var entry: Dictionary = entries[i]
			if entry["is_dir"]:
				stack.append(current.path_join(entry["name"]))
		for entry in entries:
			if entry["is_dir"]:
				continue
			if not String(entry["name"]).ends_with(BACKUP_SUFFIX):
				continue
			results.append(current.path_join(entry["name"]))
			if results.size() >= MAX_BACKUP_RESULTS:
				break
	results.sort()
	return results


## Build the structured diagnostic Dictionary surfaced via `editor_state`
## and the dock banner. Empty when the addons tree is clean — callers
## gate banner visibility / response field on `is_empty()`.
##
## Cached for `CACHE_TTL_MSEC` when scanning the default `ADDON_DIR` so
## per-`editor_state` polls don't re-walk the addons tree every frame.
## Tests passing a custom `dir` always see a fresh scan (cache only
## tracks the production path). `force=true` bypasses the cache — used
## by the dock's Re-scan button so a manual fix is reflected immediately.
static func diagnose(dir: String = ADDON_DIR, force: bool = false) -> Dictionary:
	var use_cache := dir == ADDON_DIR and not force
	if use_cache and _cache_timestamp_msec >= 0:
		if Time.get_ticks_msec() - _cache_timestamp_msec < CACHE_TTL_MSEC:
			return _cache_value.duplicate(true)

	var backups := find_backups(dir)
	var result: Dictionary = {}
	if not backups.is_empty():
		## Most commonly produced by `_rollback_paths_written` returning
		## FAILED_MIXED, but `_finalize_install_success` removes backups on
		## a best-effort basis so a successful install can also leave them
		## behind if the cleanup `remove_absolute` hit a permission error.
		## The recovery action — delete the *.update_backup files — is the
		## same in both cases, so the message acknowledges both
		## possibilities rather than asserting the alarming one.
		result = {
			"addon_dir": dir,
			"backup_files": backups,
			"backup_count": backups.size(),
			"truncated": backups.size() >= MAX_BACKUP_RESULTS,
			"message": (
				"Found .update_backup files in addons/godot_ai/. This usually"
				+ " means a self-update rollback couldn't restore the previous"
				+ " addon contents (FAILED_MIXED) — the plugin may load a mix"
				+ " of old and new files. Restore the addon from your VCS or a"
				+ " fresh release ZIP, then delete the listed *.update_backup"
				+ " files. If the plugin runs without issues these are likely"
				+ " stale from a successful install and safe to delete."
			),
		}
	if use_cache:
		_cache_value = result.duplicate(true)
		_cache_timestamp_msec = Time.get_ticks_msec()
	return result


## Reset the `diagnose()` cache. Tests that flip the addons-tree state
## between calls use this to avoid TTL-bound flakiness; the dock's
## Re-scan button uses `force=true` instead.
static func clear_cache() -> void:
	_cache_value = {}
	_cache_timestamp_msec = -1
