Editor/CitizenRetarget/RetargetEnvironmentDiagnostics.cs
#nullable enable

using System.Diagnostics;
using System.Text;
using System.Text.Json;

namespace Editor.CitizenRetarget;

internal enum RetargetEnvironmentCheckSeverity
{
	Ok,
	Warning,
	Error
}

internal sealed class RetargetEnvironmentCheck
{
	public string Id { get; init; } = string.Empty;
	public string Title { get; init; } = string.Empty;
	public RetargetEnvironmentCheckSeverity Severity { get; init; }
	public bool BlocksRetarget { get; init; }
	public string Detail { get; init; } = string.Empty;
	public string FixHint { get; init; } = string.Empty;
	public string Path { get; init; } = string.Empty;
}

internal sealed class RetargetEnvironmentReport
{
	public DateTime CheckedAt { get; init; } = DateTime.Now;
	public string BackendRootPath { get; init; } = string.Empty;
	public string BackendSource { get; init; } = string.Empty;
	public string BlenderExecutablePath { get; init; } = string.Empty;
	public string BlenderVersion { get; init; } = string.Empty;
	public List<RetargetEnvironmentCheck> Checks { get; init; } = new();

	public bool CanRunRetarget => !Checks.Any( check => check.BlocksRetarget && check.Severity == RetargetEnvironmentCheckSeverity.Error );
	public IEnumerable<RetargetEnvironmentCheck> BlockingIssues => Checks.Where( check => check.BlocksRetarget && check.Severity == RetargetEnvironmentCheckSeverity.Error );
	public IEnumerable<RetargetEnvironmentCheck> Warnings => Checks.Where( check => check.Severity == RetargetEnvironmentCheckSeverity.Warning );

	public string BuildHeadline()
	{
		var blockingCount = BlockingIssues.Count();
		if ( blockingCount > 0 )
			return $"Setup is not ready. {blockingCount} blocking issue(s) need attention.";

		var warningCount = Warnings.Count();
		if ( warningCount > 0 )
			return $"Setup can run, with {warningCount} warning(s).";

		return "Setup is ready for retargeting.";
	}
}

internal sealed class RetargetEnvironmentDiagnosticsService
{
	private const string RokokoAddonName = "rokoko_studio_live_blender";
	private const string RokokoAddonDisplayName = "Rokoko Studio Live for Blender";

	public RetargetEnvironmentReport Check( CitizenRetargetJob job, bool probeBlenderAddon, Action<float, string>? progress = null )
	{
		var checks = new List<RetargetEnvironmentCheck>();
		var settings = RetargetToolSettings.Load();
		var setup = RetargetSetupResolver.Resolve( settings );
		var backendRoot = setup.BackendRootPath;
		var blenderExecutable = setup.BlenderExecutablePath;
		var blenderVersion = string.Empty;

		progress?.Invoke( 0.08f, "Checking backend files..." );
		CheckSettingsFile( checks );
		CheckBackendRoot( checks, setup );
		CheckBackendRecipe( checks, setup );
		CheckBackendPython( checks, setup );
		CheckTargetReference( checks, setup );
		CheckRecipePortability( checks, setup );

		progress?.Invoke( 0.26f, "Checking native FBX helper..." );
		CheckNativeHelper( checks );
		CheckCurrentTarget( checks, job );

		progress?.Invoke( 0.42f, "Detecting Blender..." );
		CheckBlenderExecutable( checks, setup );
		if ( !string.IsNullOrWhiteSpace( blenderExecutable ) )
			blenderVersion = CheckBlenderVersion( checks, blenderExecutable );

		if ( probeBlenderAddon )
		{
			progress?.Invoke( 0.68f, "Checking Rokoko addon in Blender..." );
			CheckRokokoAddon( checks, blenderExecutable, settings.RokokoAddonPath );
		}
		else
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon",
				Title = "Rokoko Blender addon",
				Severity = RetargetEnvironmentCheckSeverity.Warning,
				BlocksRetarget = true,
				Detail = "Not checked yet. Run setup diagnostics before the first retarget.",
				FixHint = "Press Re-scan Setup in Diagnostics, or start the queue and the plugin will check it automatically."
			} );
		}

		progress?.Invoke( 1f, "Environment diagnostics finished." );
		return new RetargetEnvironmentReport
		{
			CheckedAt = DateTime.Now,
			BackendRootPath = backendRoot,
			BackendSource = setup.BackendSource,
			BlenderExecutablePath = blenderExecutable,
			BlenderVersion = blenderVersion,
			Checks = checks
		};
	}

	private static void CheckSettingsFile( List<RetargetEnvironmentCheck> checks )
	{
		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "tool_settings",
			Title = "Tool settings",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = $"Using settings file {RetargetToolSettings.SettingsAssetPath}.",
			Path = RetargetToolSettings.SettingsAbsolutePath
		} );
	}

	private static void CheckBackendRoot( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( string.IsNullOrWhiteSpace( setup.BackendRootPath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "backend_root",
				Title = "Bundled retarget backend",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Bundled backend was not found at {RetargetSetupResolver.BundledBackendRootPath}.",
				FixHint = "Reinstall or re-sync the plugin. Developers can set Backend Override in Diagnostics to use a repo checkout."
			} );
			return;
		}

		if ( !Directory.Exists( setup.BackendRootPath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "backend_root",
				Title = "Retarget backend project",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Backend {setup.BackendSource} folder does not exist: {setup.BackendRootPath}",
				FixHint = string.IsNullOrWhiteSpace( setup.ConfiguredBackendRootPath )
					? "Reinstall or re-sync the plugin so the bundled backend is present."
					: "Clear Backend Override to use the bundled backend, or pick a folder that contains tools/blender/retarget_job.py.",
				Path = setup.BackendRootPath
			} );
			return;
		}

		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "backend_root",
			Title = "Retarget backend project",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = $"Using {setup.BackendSource} at {setup.BackendRootPath}.",
			Path = setup.BackendRootPath
		} );
	}

	private static void CheckBackendRecipe( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( string.IsNullOrWhiteSpace( setup.BackendRecipePath ) || !File.Exists( setup.BackendRecipePath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "backend_recipe",
				Title = "Backend recipe",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Missing recipe: {setup.BackendRecipePath}",
				FixHint = string.IsNullOrWhiteSpace( setup.ConfiguredBackendRootPath )
					? "The bundled backend package is incomplete. Reinstall or re-sync the plugin."
					: "Clear Backend Override to use the bundled backend, or point it to a folder that contains tools/blender/config/recipes/retarget_rokoko_citizen.json."
			} );
			return;
		}

		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "backend_recipe",
			Title = "Backend recipe",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = $"Using recipe {CitizenTargetProfile.DefaultBackendRecipeRelativePath}.",
			Path = setup.BackendRecipePath
		} );
	}

	private static void CheckBackendPython( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( string.IsNullOrWhiteSpace( setup.BackendScriptPath ) || !File.Exists( setup.BackendScriptPath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "backend_script",
				Title = "Backend Python script",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Missing script: {setup.BackendScriptPath}",
				FixHint = string.IsNullOrWhiteSpace( setup.ConfiguredBackendRootPath )
					? "The bundled backend package is incomplete. Reinstall or re-sync the plugin."
					: "Clear Backend Override to use the bundled backend, or point it to a folder that contains tools/blender/retarget_job.py."
			} );
			return;
		}

		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "backend_script",
			Title = "Backend Python script",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = "Found tools/blender/retarget_job.py.",
			Path = setup.BackendScriptPath
		} );
	}

	private static void CheckTargetReference( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( string.IsNullOrWhiteSpace( setup.TargetReferencePath ) || !File.Exists( setup.TargetReferencePath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "target_reference",
				Title = "Citizen reference FBX",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = "Could not resolve models/citizen/citizen_ref.fbx or models/citizen/citizen.fbx from this project.",
				FixHint = "Make sure the project has Citizen model assets installed before retargeting."
			} );
			return;
		}

		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "target_reference",
			Title = "Citizen reference FBX",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = "Retarget runs will inject this project-resolved reference into the backend recipe.",
			Path = setup.TargetReferencePath
		} );
	}

	private static void CheckRecipePortability( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( !File.Exists( setup.BackendRecipePath ) )
			return;

		try
		{
			using var document = JsonDocument.Parse( File.ReadAllText( setup.BackendRecipePath ) );
			var targetReferencePath = FindJsonStringProperty( document.RootElement, "targetReferencePath" );
			if ( string.IsNullOrWhiteSpace( targetReferencePath ) )
				return;

			var rooted = Path.IsPathRooted( targetReferencePath );
			if ( rooted )
			{
				checks.Add( new RetargetEnvironmentCheck
				{
					Id = "recipe_target_reference",
					Title = "Recipe target reference",
					Severity = RetargetEnvironmentCheckSeverity.Warning,
					BlocksRetarget = false,
					Detail = $"Recipe uses an absolute target reference path: {targetReferencePath}",
					FixHint = "The plugin will override this per-run with the project-resolved Citizen reference. Clean the backend recipe later to remove this warning."
				} );
			}
		}
		catch ( Exception exception )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "recipe_target_reference",
				Title = "Recipe target reference",
				Severity = RetargetEnvironmentCheckSeverity.Warning,
				Detail = $"Could not inspect recipe portability: {exception.Message}",
				FixHint = "Open the recipe and verify targetReferencePath is not machine-specific."
			} );
		}
	}

	private static void CheckNativeHelper( List<RetargetEnvironmentCheck> checks )
	{
		var dllPath = Path.Combine( CitizenRetargetPaths.NativeRoot, "win-x64", "ual2_ufbx_helper.dll" );
		if ( !File.Exists( dllPath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "native_fbx",
				Title = "Native FBX scanner",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Missing native helper DLL: {dllPath}",
				FixHint = "Press Download Helper in Diagnostics to install the verified GitHub release asset, or build it locally from Editor/CitizenRetarget/Native/build_win_x64.ps1."
			} );
			return;
		}

		try
		{
			using var native = new UfbxNative();
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "native_fbx",
				Title = "Native FBX scanner",
				Severity = RetargetEnvironmentCheckSeverity.Ok,
				Detail = "Native FBX helper DLL loads successfully.",
				Path = dllPath
			} );
		}
		catch ( Exception exception )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "native_fbx",
				Title = "Native FBX scanner",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Native helper failed to load: {exception.Message}",
				FixHint = "Check that the DLL architecture matches the editor process and all native dependencies are present.",
				Path = dllPath
			} );
		}
	}

	private static void CheckCurrentTarget( List<RetargetEnvironmentCheck> checks, CitizenRetargetJob job )
	{
		var targetPath = job.TargetVmdlPath ?? string.Empty;
		if ( string.IsNullOrWhiteSpace( targetPath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "target_model",
				Title = "Current target model",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = "No target VMDL is selected.",
				FixHint = "Create or open a target workspace on Home before retargeting."
			} );
			return;
		}

		var absoluteTargetPath = CitizenRetargetPaths.GetAssetAbsolutePath( targetPath );
		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "target_model",
			Title = "Current target model",
			Severity = File.Exists( absoluteTargetPath ) ? RetargetEnvironmentCheckSeverity.Ok : RetargetEnvironmentCheckSeverity.Warning,
			BlocksRetarget = false,
			Detail = File.Exists( absoluteTargetPath )
				? $"Current target exists: {targetPath}"
				: $"Current target does not exist yet and will be generated: {targetPath}",
			Path = absoluteTargetPath
		} );
	}

	private static void CheckBlenderExecutable( List<RetargetEnvironmentCheck> checks, RetargetSetupResolution setup )
	{
		if ( string.IsNullOrWhiteSpace( setup.BlenderExecutablePath ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "blender",
				Title = "Blender executable",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = "Could not locate blender.exe.",
				FixHint = "Set Blender Path in Diagnostics, install Blender 4.4, add Blender to PATH, or set CITIZEN_RETARGET_BLENDER."
			} );
			return;
		}

		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "blender",
			Title = "Blender executable",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = $"Found Blender via {setup.BlenderSource}: {setup.BlenderExecutablePath}",
			Path = setup.BlenderExecutablePath
		} );
	}

	private static string CheckBlenderVersion( List<RetargetEnvironmentCheck> checks, string blenderExecutable )
	{
		var result = RunProcess( blenderExecutable, new[] { "--version" }, CitizenRetargetPaths.ProjectRoot, 7000 );
		if ( result.TimedOut || result.ExitCode != 0 )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "blender_version",
				Title = "Blender version",
				Severity = RetargetEnvironmentCheckSeverity.Warning,
				Detail = result.TimedOut
					? "Blender version check timed out."
					: $"Blender version check exited with code {result.ExitCode}.",
				FixHint = "Open Blender manually once. If it shows a first-run prompt, finish setup and re-scan."
			} );
			return string.Empty;
		}

		var firstLine = result.Stdout.Split( [ '\r', '\n' ], StringSplitOptions.RemoveEmptyEntries ).FirstOrDefault()?.Trim() ?? "Blender version detected";
		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "blender_version",
			Title = "Blender version",
			Severity = firstLine.Contains( "4.4", StringComparison.OrdinalIgnoreCase )
				? RetargetEnvironmentCheckSeverity.Ok
				: RetargetEnvironmentCheckSeverity.Warning,
			BlocksRetarget = false,
			Detail = firstLine.Contains( "4.4", StringComparison.OrdinalIgnoreCase )
				? firstLine
				: $"{firstLine}. Blender 4.4 is the currently known-good Rokoko backend version.",
			FixHint = firstLine.Contains( "4.4", StringComparison.OrdinalIgnoreCase )
				? string.Empty
				: "If retargeting fails inside Rokoko, install Blender 4.4 and point the plugin to it."
		} );
		return firstLine;
	}

	private static void CheckRokokoAddon( List<RetargetEnvironmentCheck> checks, string blenderExecutable, string configuredAddonPath )
	{
		if ( string.IsNullOrWhiteSpace( blenderExecutable ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon",
				Title = "Rokoko Blender addon",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = "Skipped because Blender was not found.",
				FixHint = "Install Blender first, then install the Rokoko Blender addon."
			} );
			return;
		}

		if ( !TryResolveConfiguredRokokoAddonPath( configuredAddonPath, out var configuredAddonInitPath, out var configuredPathError ) )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon_path",
				Title = "Rokoko addon override",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = configuredPathError,
				FixHint = "Clear Rokoko Addon to auto-scan Blender addons, or paste the Rokoko addon folder/__init__.py used by this Blender.",
				Path = configuredAddonPath
			} );
			return;
		}

		var probeDirectory = CitizenRetargetPaths.GetTempPath( "environment" );
		Directory.CreateDirectory( probeDirectory );
		var probePath = Path.Combine( probeDirectory, "rokoko_addon_probe.py" );
		File.WriteAllText( probePath, BuildRokokoProbeScript( configuredAddonInitPath ) );
		var result = RunProcess(
			blenderExecutable,
			new[] { "--background", "--python", probePath },
			CitizenRetargetPaths.ProjectRoot,
			25000 );

		var payload = ParseProbePayload( result.Stdout + Environment.NewLine + result.Stderr );
		if ( result.TimedOut )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon",
				Title = "Rokoko Blender addon",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = "Blender timed out while checking the Rokoko addon.",
				FixHint = "Open Blender manually, verify Rokoko addon is installed, then re-scan setup."
			} );
			return;
		}

		if ( payload is null )
		{
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon",
				Title = "Rokoko Blender addon",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = $"Could not read Rokoko probe output. Exit code: {result.ExitCode}.",
				FixHint = "Open Run Log/console output if this repeats. The addon probe script may be incompatible with this Blender install."
			} );
			return;
		}

		if ( !payload.Installed || !payload.Enabled )
		{
			var foundText = string.IsNullOrWhiteSpace( payload.ModuleName )
				? string.Empty
				: $" Found candidate '{payload.AddonName}' as module '{payload.ModuleName}' at {payload.Path}.";
			checks.Add( new RetargetEnvironmentCheck
			{
				Id = "rokoko_addon",
				Title = "Rokoko Blender addon",
				Severity = RetargetEnvironmentCheckSeverity.Error,
				BlocksRetarget = true,
				Detail = string.IsNullOrWhiteSpace( payload.Error )
					? $"Could not find and enable {RokokoAddonDisplayName}.{foundText}"
					: payload.Error,
				FixHint = string.IsNullOrWhiteSpace( configuredAddonInitPath )
					? "Open the same Blender executable shown above, enable Rokoko Studio Live for Blender, then re-scan setup. If this is Blender 5.x and retargeting still fails, install Blender 4.4 and point Blender Path to it."
					: "The manual Rokoko Addon path must belong to the same Blender install. Clear it to auto-scan, or paste the correct addon folder/__init__.py."
			} );
			return;
		}

		var addonLabel = string.IsNullOrWhiteSpace( payload.AddonName ) ? RokokoAddonDisplayName : payload.AddonName;
		var moduleLabel = string.IsNullOrWhiteSpace( payload.ModuleName ) ? RokokoAddonName : payload.ModuleName;
		checks.Add( new RetargetEnvironmentCheck
		{
			Id = "rokoko_addon",
			Title = "Rokoko Blender addon",
			Severity = RetargetEnvironmentCheckSeverity.Ok,
			Detail = string.IsNullOrWhiteSpace( payload.Version )
				? $"{addonLabel} is installed and enabled as '{moduleLabel}'{(string.IsNullOrWhiteSpace( configuredAddonInitPath ) ? " via auto-scan" : " via manual path")}."
				: $"{addonLabel} is installed and enabled as '{moduleLabel}'{(string.IsNullOrWhiteSpace( configuredAddonInitPath ) ? " via auto-scan" : " via manual path")}. Version: {payload.Version}.",
			Path = payload.Path
		} );
	}

	private static bool TryResolveConfiguredRokokoAddonPath( string configuredPath, out string initPath, out string error )
	{
		initPath = string.Empty;
		error = string.Empty;
		var normalizedPath = (configuredPath ?? string.Empty).Trim().Trim( '"' );
		if ( string.IsNullOrWhiteSpace( normalizedPath ) )
			return true;

		if ( Directory.Exists( normalizedPath ) )
		{
			var candidate = Path.Combine( normalizedPath, "__init__.py" );
			if ( File.Exists( candidate ) )
			{
				initPath = Path.GetFullPath( candidate );
				return true;
			}

			error = $"Configured Rokoko addon folder does not contain __init__.py: {normalizedPath}";
			return false;
		}

		if ( File.Exists( normalizedPath ) )
		{
			if ( string.Equals( Path.GetFileName( normalizedPath ), "__init__.py", StringComparison.OrdinalIgnoreCase ) )
			{
				initPath = Path.GetFullPath( normalizedPath );
				return true;
			}

			error = $"Configured Rokoko addon file must be __init__.py, not {Path.GetFileName( normalizedPath )}.";
			return false;
		}

		error = $"Configured Rokoko addon path does not exist: {normalizedPath}";
		return false;
	}

	private static string? FindJsonStringProperty( JsonElement element, string propertyName )
	{
		if ( element.ValueKind == JsonValueKind.Object )
		{
			foreach ( var property in element.EnumerateObject() )
			{
				if ( property.NameEquals( propertyName ) && property.Value.ValueKind == JsonValueKind.String )
					return property.Value.GetString();

				var nested = FindJsonStringProperty( property.Value, propertyName );
				if ( !string.IsNullOrWhiteSpace( nested ) )
					return nested;
			}
		}
		else if ( element.ValueKind == JsonValueKind.Array )
		{
			foreach ( var child in element.EnumerateArray() )
			{
				var nested = FindJsonStringProperty( child, propertyName );
				if ( !string.IsNullOrWhiteSpace( nested ) )
					return nested;
			}
		}

		return null;
	}

	private static ProcessRunResult RunProcess( string fileName, IReadOnlyList<string> arguments, string workingDirectory, int timeoutMilliseconds )
	{
		using var process = new Process();
		process.StartInfo = new ProcessStartInfo
		{
			FileName = fileName,
			WorkingDirectory = workingDirectory,
			RedirectStandardOutput = true,
			RedirectStandardError = true,
			UseShellExecute = false,
			CreateNoWindow = true
		};
		foreach ( var argument in arguments )
			process.StartInfo.ArgumentList.Add( argument );

		var stdout = new StringBuilder();
		var stderr = new StringBuilder();
		process.OutputDataReceived += (_, args) =>
		{
			if ( args.Data is not null )
				stdout.AppendLine( args.Data );
		};
		process.ErrorDataReceived += (_, args) =>
		{
			if ( args.Data is not null )
				stderr.AppendLine( args.Data );
		};

		process.Start();
		process.BeginOutputReadLine();
		process.BeginErrorReadLine();
		var completed = process.WaitForExit( timeoutMilliseconds );
		if ( !completed )
		{
			try
			{
				process.Kill();
			}
			catch
			{
				// The timeout status is enough for diagnostics.
			}

			return new ProcessRunResult
			{
				ExitCode = -1,
				TimedOut = true,
				Stdout = stdout.ToString(),
				Stderr = stderr.ToString()
			};
		}

		process.WaitForExit();
		return new ProcessRunResult
		{
			ExitCode = process.ExitCode,
			Stdout = stdout.ToString(),
			Stderr = stderr.ToString()
		};
	}

	private static string BuildRokokoProbeScript( string configuredAddonInitPath )
	{
		var configuredAddonJson = JsonSerializer.Serialize( configuredAddonInitPath ?? string.Empty );
		return $$"""
import addon_utils
import json
import os
import sys

primary_name = "{{RokokoAddonName}}"
configured_addon_init_path = {{configuredAddonJson}}
payload = {
    "installed": False,
    "enabled": False,
    "version": "",
    "moduleName": "",
    "addonName": "",
    "path": "",
    "error": ""
}

def clean(value):
    return str(value or "").strip()

def normalized(value):
    return clean(value).lower().replace("_", "-")

def norm_path(value):
    value = clean(value)
    if not value:
        return ""
    try:
        return os.path.normcase(os.path.abspath(value))
    except Exception:
        return os.path.normcase(value)

def module_info(module):
    info = getattr(module, "bl_info", {}) or {}
    return info if isinstance(info, dict) else {}

def module_init_path(module):
    return norm_path(getattr(module, "__file__", ""))

def module_folder(module):
    path = module_init_path(module)
    return norm_path(os.path.dirname(path)) if path else ""

def matches_configured_path(module):
    if not configured_addon_init_path:
        return False
    configured = norm_path(configured_addon_init_path)
    configured_folder = norm_path(os.path.dirname(configured))
    return module_init_path(module) == configured or module_folder(module) == configured_folder

def is_rokoko_candidate(module):
    module_name = clean(getattr(module, "__name__", ""))
    info = module_info(module)
    addon_name = clean(info.get("name"))
    path = clean(getattr(module, "__file__", ""))
    haystack = normalized(" ".join([module_name, addon_name, path]))
    return "rokoko" in haystack and ("studio" in haystack or "live" in haystack or "rokoko-studio-live-blender" in haystack)

def find_candidate():
    configured_match = None
    exact = None
    rokoko = []
    for module in addon_utils.modules():
        if matches_configured_path(module):
            configured_match = module
            break
        module_name = clean(getattr(module, "__name__", ""))
        if module_name == primary_name:
            exact = module
            break
        if is_rokoko_candidate(module):
            rokoko.append(module)
    return configured_match or exact or (rokoko[0] if rokoko else None)

try:
    module = find_candidate()
    if module is not None:
        module_name = clean(getattr(module, "__name__", ""))
        payload["installed"] = True
        payload["moduleName"] = module_name
        payload["path"] = clean(getattr(module, "__file__", ""))
        info = module_info(module)
        payload["addonName"] = clean(info.get("name"))
        if not addon_utils.check(module_name)[1]:
            addon_utils.enable(module_name, default_set=False, persistent=False)
        payload["enabled"] = addon_utils.check(module_name)[1]
        module = sys.modules.get(module_name) or module
    else:
        info = {}
        if configured_addon_init_path:
            payload["error"] = "Configured Rokoko addon path is valid, but this Blender executable does not list it as an installed addon: " + configured_addon_init_path
    version = info.get("version") if isinstance(info, dict) else None
    if version:
        payload["version"] = ".".join(str(part) for part in version)
except Exception as exception:
    payload["error"] = str(exception)

print("CITIZEN_RETARGET_ENV_JSON:" + json.dumps(payload))
sys.exit(0 if payload["installed"] and payload["enabled"] else 2)
""";
	}

	private static RokokoProbePayload? ParseProbePayload( string output )
	{
		const string prefix = "CITIZEN_RETARGET_ENV_JSON:";
		foreach ( var line in output.Split( [ '\r', '\n' ], StringSplitOptions.RemoveEmptyEntries ) )
		{
			var index = line.IndexOf( prefix, StringComparison.Ordinal );
			if ( index < 0 )
				continue;

			var json = line[(index + prefix.Length)..].Trim();
			try
			{
				return JsonSerializer.Deserialize<RokokoProbePayload>( json, new JsonSerializerOptions
				{
					PropertyNameCaseInsensitive = true
				} );
			}
			catch
			{
				return null;
			}
		}

		return null;
	}

	private sealed class ProcessRunResult
	{
		public int ExitCode { get; init; }
		public bool TimedOut { get; init; }
		public string Stdout { get; init; } = string.Empty;
		public string Stderr { get; init; } = string.Empty;
	}

	private sealed class RokokoProbePayload
	{
		public bool Installed { get; init; }
		public bool Enabled { get; init; }
		public string Version { get; init; } = string.Empty;
		public string ModuleName { get; init; } = string.Empty;
		public string AddonName { get; init; } = string.Empty;
		public string Path { get; init; } = string.Empty;
		public string Error { get; init; } = string.Empty;
	}
}