<?php

/**
 * Domain handler for Plugin Builder actions.
 *
 * @package LolaCore\PluginBuilder\Handler
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\PluginBuilder\Handler;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Diagnostic exception messages; escaped at REST/chat presentation layer.

use LolaCore\Actions\ActionLogger;
use LolaCore\Actions\Contracts\ActionHandlerInterface;
use LolaCore\Memory\MemoryManager;
use LolaCore\PluginBuilder\Async\BuilderWorker;
use LolaCore\PluginBuilder\Async\JobManager;
use LolaCore\PluginBuilder\Generation\CodeReviewer;
use LolaCore\PluginBuilder\Generation\GenerationRetryOrchestrator;
use LolaCore\PluginBuilder\Generation\PluginBuilderGenerationCache;
use LolaCore\PluginBuilder\Generation\PluginGenerator;
use LolaCore\PluginBuilder\Generation\PluginStaticSafetyGate;
use LolaCore\PluginBuilder\Generation\RetryLedger;
use LolaCore\PluginBuilder\Gate\PlaygroundGate;
use LolaCore\PluginBuilder\Generation\RuntimeValidator;
use LolaCore\PluginBuilder\Packaging\LocalInstaller;
use LolaCore\PluginBuilder\Packaging\PluginFileNormalizer;
use LolaCore\PluginBuilder\Playground\BlueprintGenerator;
use LolaCore\PluginBuilder\PostType\PluginPostType;

if (! defined('ABSPATH')) {
	exit;
}

/**
 * Executes create, update, list, test, install, and delete flows for built plugins.
 */
class PluginBuilderHandler implements ActionHandlerInterface {

	/** Minimum reviewer confidence to accept without auto-retry. */
	public const MIN_CONFIDENCE_THRESHOLD = 80;

	/** Maximum automatic regeneration attempts when confidence is below threshold. */
	public const MAX_AUTO_RETRIES = 2;

	/** User-facing message when static safety checks block delivery. */
	public static function safetyCheckFailMessage(): string {
		return __('I couldn\'t deliver this plugin safely. Simplify the description or split it into smaller steps, then try again.', 'lolacore');
	}

	/** User-facing message when consecutive retries hit the same fatal safety codes. */
	public static function retryStuckFailMessage(): string {
		return __('I hit the same safety issue twice and could not fix it automatically. Try a simpler description, or split the plugin into smaller steps.', 'lolacore');
	}

	/**
	 * Combined ceiling (chars) for php+js+css returned inline by getPluginDetails.
	 *
	 * Generated plugins are small, so this almost never triggers; it is a guard
	 * against a pathological payload, not a context-window constraint (model input
	 * windows are ~1M tokens). When exceeded, files truncate in cohesion order.
	 */
	private const MAX_DETAILS_CODE_CHARS = 90000;

	private PluginGenerator $generator;
	private CodeReviewer $reviewer;
	private BlueprintGenerator $blueprintGenerator;
	private LocalInstaller $localInstaller;
	private RuntimeValidator $runtimeValidator;

	public function __construct(
		?PluginGenerator $generator = null,
		?CodeReviewer $reviewer = null,
		?BlueprintGenerator $blueprintGenerator = null,
		?LocalInstaller $localInstaller = null,
		?RuntimeValidator $runtimeValidator = null
	) {
		$this->generator           = $generator ?? new PluginGenerator();
		$this->reviewer            = $reviewer ?? new CodeReviewer();
		$this->blueprintGenerator  = $blueprintGenerator ?? new BlueprintGenerator();
		$this->localInstaller      = $localInstaller ?? new LocalInstaller();
		$this->runtimeValidator    = $runtimeValidator ?? new RuntimeValidator();
	}

	/**
	 * @return array<string>
	 */
	public function getSupportedActions(): array {
		return array(
			'create_plugin',
			'update_plugin_code',
			'list_built_plugins',
			'get_built_plugin_details',
			'get_installed_plugin_state',
			'test_plugin_playground',
			'install_plugin_local',
			'delete_built_plugin',
		);
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 *
	 * @throws \Exception When validation or execution fails.
	 */
	public function handle( string $actionId, array $params, bool $dryRun ): array {
		return match ($actionId) {
			'create_plugin'          => $this->createPlugin($params, $dryRun),
			'update_plugin_code'     => $this->updatePluginCode($params, $dryRun),
			'list_built_plugins'     => $this->listPlugins($params),
			'get_built_plugin_details' => $this->getPluginDetails($params),
			'get_installed_plugin_state' => $this->getInstalledPluginState($params),
			'test_plugin_playground' => $this->testInPlayground($params),
			'install_plugin_local'   => $this->installLocal($params, $dryRun),
			'delete_built_plugin'    => $this->deletePlugin($params, $dryRun),
			default                  => throw new \Exception(
				esc_html(
					sprintf(
						/* translators: %s: action identifier */
						__('PluginBuilderHandler: unknown action \'%s\'.', 'lolacore'),
						$actionId
					)
				)
			),
		};
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function createPlugin( array $params, bool $dryRun ): array {
		$this->assertManageOptions();

		// Literal-code path: programmatic clients (MCP/REST) deliver the exact plugin
		// source, stored verbatim with NO server-side LLM generation. Hard-gated to
		// non-chat sources so a chat user can never package pasted code — in the
		// wp-admin chat Lola always generates the code from a description.
		if ( self::callSource($params) !== 'chat' ) {
			return $this->saveLiteralCode($params, $dryRun, null);
		}

		$description = isset($params['description']) ? trim((string) $params['description']) : '';
		if ($description === '') {
			throw new \Exception(__('description is required.', 'lolacore'));
		}

		$requestedName = isset($params['name']) ? sanitize_text_field((string) $params['name']) : '';
		$prd           = isset($params['prd']) ? trim((string) $params['prd']) : '';

		if (! $dryRun) {
			$cached = PluginBuilderGenerationCache::consume('create_plugin', $params);
			if ($cached !== null) {
				return $this->finalizeCreatePlugin(
					$description,
					$requestedName,
					(array) ($cached['generated'] ?? array()),
					(array) ($cached['review'] ?? array()),
					true,
					$prd
				);
			}
		}

		// Async path: offload the heavy LLM work to a background worker
		// so the user's HTTP request returns immediately (~50ms).
		// Only on dryRun because that's the preview path where generation happens.
		if ($dryRun) {
			$asyncResult = $this->tryAsyncDispatch('create_plugin', array(
				'description' => $description,
				'name'        => $requestedName,
				'prd'         => $prd,
			));
			if ($asyncResult !== null) {
				return $asyncResult;
			}
			// Dispatch failed — fall through to synchronous generation.
		}

		$this->ensureGenerationRuntime();

		$generated = $this->generator->generate($description, array(), true, $prd);
		$review    = $this->reviewer->review(
			array_merge(
				$generated,
				array(
					'description'     => $description,
					'library_version' => 1,
				)
			)
		);

		// Auto-retry if confidence is below threshold.
		list($generated, $review, $retried) = $this->maybeRetryGeneration(
			$description,
			$generated,
			$review,
			true,
			$prd,
			1
		);

		if (self::reviewNeedsRetry($review)) {
			throw new \Exception( esc_html( self::qualityGateFailMessage( $review ) ) );
		}

		$title = $requestedName !== ''
			? $requestedName
			: $this->deriveTitleFromCode($generated['php'], $generated['slug']);

		if ($dryRun) {
			$cacheParams = PluginBuilderGenerationCache::stripInternalParams($params);
			$cacheKey    = PluginBuilderGenerationCache::store(
				'create_plugin',
				$cacheParams,
				array(
					'generated' => $generated,
					'review'    => $review,
					'title'     => $title,
				)
			);

			$previewData = self::enrichPreviewQuality(
				array(
					'preview'           => true,
					'name'              => $title,
					'slug'              => $generated['slug'],
					'confidence'        => $review['confidence'],
					'note'              => $review['note'],
					'suggestions'       => $review['suggestions'],
					'preview_cache_key' => $cacheKey,
					'message'           => __('Will generate and save a new built plugin in the library.', 'lolacore'),
				),
				$review
			);

			if ($retried) {
				$previewData['retried'] = true;
			}

			if ($review['confidence'] < self::MIN_CONFIDENCE_THRESHOLD) {
				$previewData['quality_warning'] = __('This build needs more work before saving. Review the suggestions carefully before approving.', 'lolacore');
			}

			return $previewData;
		}

		return $this->finalizeCreatePlugin($description, $requestedName, $generated, $review, false, $prd);
	}

	/**
	 * @param array{php: string, js: string, css: string, slug: string, dependencies: array<int, string>} $generated
	 * @param array{confidence: int, note: string, suggestions: array<int, string>} $review
	 * @return array<string, mixed>
	 */
	private function finalizeCreatePlugin(
		string $description,
		string $requestedName,
		array $generated,
		array $review,
		bool $reusedPreview = false,
		string $prd = ''
	): array {
		$title = $requestedName !== ''
			? $requestedName
			: $this->deriveTitleFromCode($generated['php'], $generated['slug']);

		$post_id = wp_insert_post(
			array(
				'post_type'   => PluginPostType::POST_TYPE,
				'post_title'  => $title,
				'post_status' => 'publish',
			),
			true
		);

		if (is_wp_error($post_id)) {
			throw new \Exception(
				esc_html(
					sprintf(
						/* translators: %s: WordPress error message */
						__('Could not save the built plugin: %s', 'lolacore'),
						$post_id->get_error_message()
					)
				)
			);
		}

		$this->persistPluginMeta((int) $post_id, $generated, $description, $review, 1, $prd);

		$logger = new ActionLogger();
		$logger->log(
			'create_plugin',
			sprintf('Plugin Builder created the plugin "%s" (post #%d).', $title, (int) $post_id),
			'success',
			'chat',
			array(
				'post_id'     => (int) $post_id,
				'slug'        => $generated['slug'],
				'confidence'  => $review['confidence'],
				'description' => $description,
			)
		);

		$memory = new MemoryManager();
		$memory->saveFact(
			'site_state',
			sprintf("Plugin Builder created the plugin '%s' (library entry #%d).", $title, (int) $post_id),
			7,
			'action'
		);

		return array(
			'post_id'         => (int) $post_id,
			'name'            => $title,
			'slug'            => $generated['slug'],
			'confidence'      => $review['confidence'],
			'confidence_note' => $review['note'],
			'suggestions'     => $review['suggestions'],
			'playground_url'  => null,
			'version'         => 1,
			'reused_preview'  => $reusedPreview,
		);
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function updatePluginCode( array $params, bool $dryRun ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		// Literal-code path (MCP/REST): save the provided code as a new revision with
		// no LLM. Hard-gated to non-chat sources (chat always iterates via the LLM).
		if ( self::callSource($params) !== 'chat' ) {
			return $this->saveLiteralCode($params, $dryRun, $post_id);
		}

		$instruction = isset($params['instruction']) ? trim((string) $params['instruction']) : '';
		if ($instruction === '') {
			throw new \Exception(__('instruction is required.', 'lolacore'));
		}

		// Validate the post exists before async dispatch so we fail fast.
		$post = $this->getPluginPost($post_id);
		$slug = (string) get_post_meta($post_id, PluginPostType::META_SLUG, true);
		$previous_description = (string) get_post_meta($post_id, PluginPostType::META_DESCRIPTION, true);
		$current_version      = max(1, (int) get_post_meta($post_id, PluginPostType::META_VERSION, true));
		$new_version          = $current_version + 1;

		if (! $dryRun) {
			$cached = PluginBuilderGenerationCache::consume('update_plugin_code', $params);
			if ($cached !== null) {
				return $this->finalizeUpdatePlugin(
					$post,
					$post_id,
					$instruction,
					$previous_description,
					(int) ($cached['new_version'] ?? $new_version),
					(array) ($cached['generated'] ?? array()),
					(array) ($cached['review'] ?? array()),
					true
				);
			}
		}

		// Async path: offload the heavy LLM work to a background worker.
		if ($dryRun) {
			$asyncResult = $this->tryAsyncDispatch('update_plugin_code', array(
				'post_id'     => $post_id,
				'instruction' => $instruction,
			));
			if ($asyncResult !== null) {
				return $asyncResult;
			}
		}

		$previous_suggestions = get_post_meta($post_id, PluginPostType::META_SUGGESTIONS, true);
		$iteration_prompt = self::buildUpdatePrompt(
			$previous_description,
			(string) get_post_meta($post_id, PluginPostType::META_PHP, true),
			(string) get_post_meta($post_id, PluginPostType::META_JS, true),
			(string) get_post_meta($post_id, PluginPostType::META_CSS, true),
			$instruction,
			array(
				'confidence'  => (int) get_post_meta($post_id, PluginPostType::META_CONFIDENCE, true),
				'note'        => (string) get_post_meta($post_id, PluginPostType::META_CONFIDENCE_NOTE, true),
				'suggestions' => is_array($previous_suggestions) ? $previous_suggestions : array(),
			),
			(string) get_post_meta($post_id, PluginPostType::META_PRD, true)
		);

		$this->ensureGenerationRuntime();

		$generated = $this->generator->generate($iteration_prompt, array(), false);
		if ($slug !== '') {
			$generated['slug'] = $slug;
		}

		$full_description = $previous_description . "\nUpdate: " . $instruction;
		$review           = $this->reviewer->review(
			array_merge(
				$generated,
				array(
					'description'     => $full_description,
					'library_version' => $new_version,
				)
			)
		);

		// Auto-retry if confidence is below threshold.
		list($generated, $review, $retried) = $this->maybeRetryGeneration(
			$full_description,
			$generated,
			$review,
			false,
			'',
			$new_version
		);
		if ($slug !== '') {
			$generated['slug'] = $slug;
		}

		if (self::reviewNeedsRetry($review)) {
			throw new \Exception( esc_html( self::qualityGateFailMessage( $review ) ) );
		}

		if ($dryRun) {
			$cacheParams = PluginBuilderGenerationCache::stripInternalParams($params);
			$cacheKey    = PluginBuilderGenerationCache::store(
				'update_plugin_code',
				$cacheParams,
				array(
					'generated'   => $generated,
					'review'      => $review,
					'new_version' => $new_version,
				)
			);

			$previewData = self::enrichPreviewQuality(
				array(
					'preview'           => true,
					'post_id'           => $post_id,
					'name'              => $post->post_title,
					'next_version'      => $new_version,
					'confidence'        => $review['confidence'],
					'confidence_note'   => $review['note'],
					'suggestions'       => $review['suggestions'],
					'preview_cache_key' => $cacheKey,
					'message'           => __('Will update the built plugin and create a new revision.', 'lolacore'),
				),
				$review
			);

			if ($retried) {
				$previewData['retried'] = true;
			}

			if ($review['confidence'] < self::MIN_CONFIDENCE_THRESHOLD) {
				$previewData['quality_warning'] = __('This build needs more work before saving. Review the suggestions carefully before approving.', 'lolacore');
			}

			return $previewData;
		}

		return $this->finalizeUpdatePlugin(
			$post,
			$post_id,
			$instruction,
			$previous_description,
			$new_version,
			$generated,
			$review
		);
	}

	/**
	 * @param array{php: string, js: string, css: string, slug: string, dependencies: array<int, string>} $generated
	 * @param array{confidence: int, note: string, suggestions: array<int, string>} $review
	 * @return array<string, mixed>
	 */
	private function finalizeUpdatePlugin(
		\WP_Post $post,
		int $post_id,
		string $instruction,
		string $previous_description,
		int $new_version,
		array $generated,
		array $review,
		bool $reusedPreview = false
	): array {
		$new_description = $previous_description . "\n\nUpdate: " . $instruction;
		// Persist meta before wp_update_post so post revisions snapshot the new code.
		$this->persistPluginMeta($post_id, $generated, $new_description, $review, $new_version);

		$updated = wp_update_post(
			array(
				'ID'         => $post_id,
				'post_title' => $post->post_title,
			),
			true
		);

		if (is_wp_error($updated)) {
			throw new \Exception(
				esc_html(
					sprintf(
						/* translators: %s: WordPress error message */
						__('Could not update the built plugin: %s', 'lolacore'),
						$updated->get_error_message()
					)
				)
			);
		}

		$logger = new ActionLogger();
		$logger->log(
			'update_plugin_code',
			sprintf('Plugin Builder updated "%s" to version %d.', $post->post_title, $new_version),
			'success',
			'chat',
			array(
				'post_id'     => $post_id,
				'version'     => $new_version,
				'instruction' => $instruction,
				'confidence'  => $review['confidence'],
			)
		);

		return array(
			'post_id'         => $post_id,
			'name'            => $post->post_title,
			'slug'            => $generated['slug'],
			'version'         => $new_version,
			'confidence'      => $review['confidence'],
			'confidence_note' => $review['note'],
			'suggestions'     => $review['suggestions'],
			'playground_url'  => null,
			'reused_preview'  => $reusedPreview,
		);
	}

	/**
	 * Save a plugin from LITERAL source code provided by a programmatic client,
	 * verbatim. The MCP/REST mode of create_plugin / update_plugin_code.
	 *
	 * No LLM generation runs: the exact php/js/css is stored as-is after the same
	 * Capa A static safety gate the generated path uses. This is the path an
	 * external agent (a code editor over MCP) takes — it already wrote the code, so
	 * asking the site's LLM to re-derive it from a description makes no sense. Pass
	 * postId = null to create a new library entry, or an int to store the code as a
	 * new revision of an existing one. persistPluginMeta() resets the Playground
	 * gate, so a saved plugin must still be tested in Playground and attested before
	 * install_plugin_local — the install-time safety posture is identical to a
	 * generated plugin.
	 *
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 *
	 * @throws \Exception When php is missing or the static safety gate blocks.
	 */
	private function saveLiteralCode( array $params, bool $dryRun, ?int $postId ): array {
		$this->assertManageOptions();

		$php = isset($params['php']) ? (string) $params['php'] : '';
		if (trim($php) === '') {
			throw new \Exception(__('php is required: provide the literal plugin source code. From an external client Lola stores your exact code — it does not run the server LLM. Use the wp-admin chat if you want Lola to generate the code from a description.', 'lolacore'));
		}

		$js  = isset($params['js']) ? (string) $params['js'] : '';
		$css = isset($params['css']) ? (string) $params['css'] : '';
		$dependencies = $this->sanitizeDependencyParam($params['dependencies'] ?? array());

		$requestedName = isset($params['name']) ? sanitize_text_field((string) $params['name']) : '';
		$requestedSlug = isset($params['slug']) ? (string) $params['slug'] : '';
		$description   = isset($params['description']) ? trim((string) $params['description']) : '';
		$instruction   = isset($params['instruction']) ? trim((string) $params['instruction']) : '';

		$post_id  = (int) ($postId ?? 0);
		$isUpdate = $post_id > 0;

		if ($isUpdate) {
			$post            = $this->getPluginPost($post_id);
			$existingSlug    = (string) get_post_meta($post_id, PluginPostType::META_SLUG, true);
			$slug            = $this->deriveSlug($php, $requestedSlug !== '' ? $requestedSlug : $existingSlug, $post->post_title);
			$current_version = max(1, (int) get_post_meta($post_id, PluginPostType::META_VERSION, true));
			$version         = $current_version + 1;
			$title           = $requestedName !== '' ? $requestedName : $post->post_title;
			$previous        = (string) get_post_meta($post_id, PluginPostType::META_DESCRIPTION, true);
			// Mirror the LLM iterate path: fold the change note into the description.
			$description     = $instruction !== ''
				? trim($previous . "\n\nUpdate: " . $instruction)
				: ($description !== '' ? $description : $previous);
		} else {
			$title   = $requestedName !== '' ? $requestedName : $this->deriveTitleFromCode($php, sanitize_title($requestedSlug));
			$slug    = $this->deriveSlug($php, $requestedSlug, $title);
			$version = 1;
			if ($description === '') {
				$description = __('Saved verbatim from author-provided source code (no AI generation).', 'lolacore');
			}
		}

		// Deterministic Capa A static safety gate — no LLM. The same gate the
		// generated path runs, applied here to author-provided literal code.
		$analysis = (new PluginStaticSafetyGate())->analyze(array(
			'php'          => $php,
			'slug'         => $slug,
			'dependencies' => $dependencies,
		));

		if (! empty($analysis['blocking'])) {
			throw new \Exception( esc_html( self::formatLiteralSafetyBlockMessage($analysis['findings']) ) );
		}

		$review = self::buildLiteralCodeReview($analysis);

		$phpForRuntime = PluginFileNormalizer::normalizePhp($php, $slug, $isUpdate ? $version : 1);
		$review['load_safe'] = $this->runtimeValidator->validate($phpForRuntime, $slug, $dependencies);

		if ($dryRun) {
			$preview = array(
				'preview'         => true,
				'name'            => $title,
				'slug'            => $slug,
				'next_version'    => $version,
				'confidence'      => $review['confidence'],
				'confidence_note' => $review['note'],
				'message'         => $isUpdate
					? __('Will save the provided code as a new revision of this built plugin.', 'lolacore')
					: __('Will save the provided code as a new built plugin in the library.', 'lolacore'),
			);
			if ($isUpdate) {
				$preview['post_id'] = $post_id;
			}

			return $preview;
		}

		$generated = array(
			'php'          => $php,
			'js'           => $js,
			'css'          => $css,
			'slug'         => $slug,
			'dependencies' => $dependencies,
		);

		if ($isUpdate) {
			// Persist meta before wp_update_post so post revisions snapshot the new code.
			$this->persistPluginMeta($post_id, $generated, $description, $review, $version);

			$updated = wp_update_post(
				array(
					'ID'         => $post_id,
					'post_title' => $title,
				),
				true
			);
			if (is_wp_error($updated)) {
				throw new \Exception(
					esc_html(
						sprintf(
							/* translators: %s: WordPress error message */
							__('Could not update the built plugin: %s', 'lolacore'),
							$updated->get_error_message()
						)
					)
				);
			}
		} else {
			$new_post_id = wp_insert_post(
				array(
					'post_type'   => PluginPostType::POST_TYPE,
					'post_title'  => $title,
					'post_status' => 'publish',
				),
				true
			);
			if (is_wp_error($new_post_id)) {
				throw new \Exception(
					esc_html(
						sprintf(
							/* translators: %s: WordPress error message */
							__('Could not save the built plugin: %s', 'lolacore'),
							$new_post_id->get_error_message()
						)
					)
				);
			}
			$post_id = (int) $new_post_id;
			$this->persistPluginMeta($post_id, $generated, $description, $review, $version);
		}

		$logger = new ActionLogger();
		$logger->log(
			'save_plugin_code',
			$isUpdate
				? sprintf('Plugin Builder saved literal code for "%s" as version %d (post #%d).', $title, $version, $post_id)
				: sprintf('Plugin Builder saved literal code as the plugin "%s" (post #%d).', $title, $post_id),
			'success',
			'chat',
			array(
				'post_id'    => $post_id,
				'slug'       => $slug,
				'version'    => $version,
				'confidence' => $review['confidence'],
				'source'     => 'literal',
			)
		);

		$memory = new MemoryManager();
		$memory->saveFact(
			'site_state',
			$isUpdate
				? sprintf("Plugin Builder saved literal code for '%s' (library entry #%d, version %d).", $title, $post_id, $version)
				: sprintf("Plugin Builder saved the plugin '%s' from literal code (library entry #%d).", $title, $post_id),
			7,
			'action'
		);

		return array(
			'post_id'         => $post_id,
			'name'            => $title,
			'slug'            => $slug,
			'version'         => $version,
			'confidence'      => $review['confidence'],
			'confidence_note' => $review['note'],
			'suggestions'     => $review['suggestions'],
			'safety_findings' => $analysis['findings'],
			'playground_url'  => null,
			'source'          => 'literal',
		);
	}

	/**
	 * The execution surface that invoked this action ('chat' | 'rest'), injected
	 * into the params by ActionExecutor. Defaults to 'chat' — the most restricted
	 * surface — when absent, so the literal-code path is never reachable unless a
	 * non-chat source is explicitly set.
	 *
	 * @param array<string, mixed> $params
	 */
	private static function callSource( array $params ): string {
		$source = isset($params['__lc_source']) ? (string) $params['__lc_source'] : 'chat';

		return $source !== '' ? $source : 'chat';
	}

	/**
	 * Resolve the plugin slug for a literal-code save: an explicit/preferred slug
	 * wins, then the plugin header, then the title. Always returns a non-empty
	 * sanitized slug so the file normalizer and installer have a stable target.
	 */
	private function deriveSlug( string $php, string $preferredSlug, string $title ): string {
		$preferredSlug = sanitize_title(trim($preferredSlug));
		if ($preferredSlug !== '') {
			return $preferredSlug;
		}

		if (preg_match('/Plugin\s+Name:\s*(.+)/i', $php, $matches) === 1) {
			$fromHeader = sanitize_title(trim($matches[1]));
			if ($fromHeader !== '') {
				return $fromHeader;
			}
		}

		$fromTitle = sanitize_title($title);

		return $fromTitle !== '' ? $fromTitle : 'built-plugin';
	}

	/**
	 * Normalize a free-form dependencies parameter to kebab-case plugin slugs.
	 *
	 * @param mixed $value
	 * @return array<int, string>
	 */
	private function sanitizeDependencyParam( $value ): array {
		if (! is_array($value)) {
			return array();
		}

		$slugs = array();
		foreach ($value as $slug) {
			if (! is_scalar($slug)) {
				continue;
			}
			$clean = sanitize_title((string) $slug);
			if ($clean !== '') {
				$slugs[] = $clean;
			}
		}

		return array_values(array_unique($slugs));
	}

	/**
	 * Build a synthetic reviewer verdict for verbatim author-provided code.
	 *
	 * No AI code review runs on literal code; the confidence reflects only the
	 * deterministic static safety gate (full when clean, capped when it raises
	 * non-blocking warnings). Findings carry through as suggestions so the library
	 * card and any later iteration still surface them.
	 *
	 * @param array{blocking: bool, confidence_cap: int|null, findings: array<int, array<string, mixed>>} $analysis
	 * @return array{confidence: int, note: string, suggestions: array<int, string>, safety_findings: array<int, array<string, mixed>>}
	 */
	private static function buildLiteralCodeReview( array $analysis ): array {
		$cap        = $analysis['confidence_cap'] ?? null;
		$confidence = is_int($cap) ? $cap : 100;

		$note = $confidence >= 100
			? __('Author-provided code stored verbatim; passed deterministic static safety checks (no AI code review ran).', 'lolacore')
			: __('Author-provided code stored verbatim; deterministic static safety checks raised non-blocking warnings (no AI code review ran).', 'lolacore');

		$suggestions = array();
		foreach ($analysis['findings'] as $finding) {
			$message = isset($finding['message']) ? trim((string) $finding['message']) : '';
			if ($message !== '') {
				$suggestions[] = $message;
			}
			if (count($suggestions) >= 5) {
				break;
			}
		}

		return array(
			'confidence'      => $confidence,
			'note'            => $note,
			'suggestions'     => $suggestions,
			'safety_findings' => $analysis['findings'],
		);
	}

	/**
	 * Format the blocking (fatal/error) findings of the static safety gate into a
	 * single actionable message so the caller can fix the code and save it again.
	 *
	 * @param array<int, array{severity?: string, code?: string, message?: string, line?: int}> $findings
	 */
	private static function formatLiteralSafetyBlockMessage( array $findings ): string {
		$lines = array();
		foreach ($findings as $finding) {
			$severity = (string) ( $finding['severity'] ?? '' );
			if ($severity !== 'fatal' && $severity !== 'error') {
				continue;
			}
			$line = isset($finding['line']) ? (int) $finding['line'] : 0;
			$lines[] = sprintf(
				'- [%s] %s: %s%s',
				$severity,
				(string) ( $finding['code'] ?? 'unknown' ),
				(string) ( $finding['message'] ?? '' ),
				$line > 0
					? sprintf(
						/* translators: %d: line number in source file */
						__(' (line %d)', 'lolacore'),
						$line
					)
					: ''
			);
		}

		return __('I couldn\'t save this code: it did not pass the static safety checks. Fix these issues and save it again:', 'lolacore') . "\n"
			. implode("\n", $lines);
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function listPlugins( array $params ): array {
		$this->assertManageOptions();

		$limit = isset($params['limit']) ? max(1, min(100, (int) $params['limit'])) : 20;
		$page  = isset($params['page']) ? max(1, (int) $params['page']) : 1;

		$query = new \WP_Query(
			array(
				'post_type'      => PluginPostType::POST_TYPE,
				'post_status'    => array('publish', 'draft', 'private'),
				'posts_per_page' => $limit,
				'paged'          => $page,
				'orderby'        => 'modified',
				'order'          => 'DESC',
			)
		);

		$plugins = array();
		foreach ($query->posts as $post) {
			if (! $post instanceof \WP_Post) {
				continue;
			}

			$plugins[] = array(
				'post_id'    => (int) $post->ID,
				'name'       => $post->post_title,
				'slug'       => (string) get_post_meta($post->ID, PluginPostType::META_SLUG, true),
				'version'    => max(1, (int) get_post_meta($post->ID, PluginPostType::META_VERSION, true)),
				'confidence' => (int) get_post_meta($post->ID, PluginPostType::META_CONFIDENCE, true),
				'modified'   => $post->post_modified_gmt,
			);
		}

		return array(
			'plugins' => $plugins,
			'total'   => (int) $query->found_posts,
			'page'    => $page,
			'limit'   => $limit,
		);
	}

	/**
	 * Load the full code and reviewer assessment of a built plugin so Lola can
	 * audit it and propose improvements before iterating via update_plugin_code.
	 *
	 * Read-only. Returns the original intent (description), declared dependencies,
	 * the reviewer verdict (confidence + note + suggestions), and the php/js/css
	 * code in a single cohesive payload. The code is bounded by
	 * MAX_DETAILS_CODE_CHARS; on the rare overflow it truncates in cohesion order
	 * (css → js → php) and adds an audit_warning so the assessment is not blind.
	 *
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function getPluginDetails( array $params ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		$post = $this->getPluginPost($post_id);

		$dependencies = get_post_meta($post_id, PluginPostType::META_DEPENDENCIES, true);
		$suggestions  = get_post_meta($post_id, PluginPostType::META_SUGGESTIONS, true);

		$code = $this->capCodeForAudit(
			(string) get_post_meta($post_id, PluginPostType::META_PHP, true),
			(string) get_post_meta($post_id, PluginPostType::META_JS, true),
			(string) get_post_meta($post_id, PluginPostType::META_CSS, true)
		);

		$result = array(
			'post_id'      => $post_id,
			'name'         => $post->post_title,
			'version'      => max(1, (int) get_post_meta($post_id, PluginPostType::META_VERSION, true)),
			'description'  => (string) get_post_meta($post_id, PluginPostType::META_DESCRIPTION, true),
			'prd'          => (string) get_post_meta($post_id, PluginPostType::META_PRD, true),
			'dependencies' => is_array($dependencies) ? array_values($dependencies) : array(),
			'review'       => array(
				'confidence'  => (int) get_post_meta($post_id, PluginPostType::META_CONFIDENCE, true),
				'note'        => (string) get_post_meta($post_id, PluginPostType::META_CONFIDENCE_NOTE, true),
				'suggestions' => is_array($suggestions) ? array_values($suggestions) : array(),
			),
			'code'         => $code['files'],
		);

		if ($code['warning'] !== '') {
			$result['audit_warning'] = $code['warning'];
		}

		return $result;
	}

	/**
	 * Bound the combined size of code returned for inline audit.
	 *
	 * Truncates in cohesion order — css first, then js, php last — so the
	 * interrelated php/js logic survives as long as possible. The cap is a guard
	 * against a pathological payload, not a context-window limit, so it rarely
	 * fires for real (small, single-file) generated plugins.
	 *
	 * @return array{files: array<string, string>, warning: string}
	 */
	private function capCodeForAudit( string $php, string $js, string $css ): array {
		$total = mb_strlen($php) + mb_strlen($js) + mb_strlen($css);

		if ($total <= self::MAX_DETAILS_CODE_CHARS) {
			return array(
				'files'   => array('php' => $php, 'js' => $js, 'css' => $css),
				'warning' => '',
			);
		}

		$files     = array('css' => $css, 'js' => $js, 'php' => $php);
		$overflow  = $total - self::MAX_DETAILS_CODE_CHARS;
		$truncated = array();

		foreach (array('css', 'js', 'php') as $key) {
			if ($overflow <= 0) {
				break;
			}
			$len = mb_strlen($files[$key]);
			if ($len === 0) {
				continue;
			}
			$cut         = min($len, $overflow);
			$files[$key] = mb_substr($files[$key], 0, $len - $cut) . sprintf(
				/* translators: %d: number of omitted characters */
				__("\n... [truncated: %d chars omitted]", 'lolacore'),
				$cut
			);
			$overflow   -= $cut;
			$truncated[] = $key;
		}

		return array(
			'files'   => array('php' => $files['php'], 'js' => $files['js'], 'css' => $files['css']),
			'warning' => sprintf(
				/* translators: 1: character budget, 2: comma-separated list of truncated file keys */
				__('Code exceeds the inline audit budget (%1$d chars). Truncated to fit: %2$s. This audit reflects partial code — say so if it affects your assessment.', 'lolacore'),
				self::MAX_DETAILS_CODE_CHARS,
				implode(', ', $truncated)
			),
		);
	}

	/**
	 * Read the plugin actually installed on disk and compare it to the latest
	 * library version, so the agent can verify what is live instead of inferring
	 * it from behavior. This is the structural introspection the iterate-on-site
	 * flow depends on: without it Lola cannot tell whether a fix is even installed.
	 *
	 * Read-only. Returns install/active state, the on-disk header Version, a drift
	 * verdict against the latest library version (compared post-normalization, so
	 * the verbatim-vs-packaged asset rewrite is not reported as false drift), and
	 * the actual on-disk code (bounded like getPluginDetails).
	 *
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function getInstalledPluginState( array $params ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		$post    = $this->getPluginPost($post_id);
		$slug    = (string) get_post_meta($post_id, PluginPostType::META_SLUG, true);
		$version = max(1, (int) get_post_meta($post_id, PluginPostType::META_VERSION, true));

		$disk = $this->localInstaller->readInstalled($post_id);

		if (! $disk['installed']) {
			return array(
				'post_id'         => $post_id,
				'name'            => $post->post_title,
				'slug'            => $slug,
				'installed'       => false,
				'active'          => false,
				'library_version' => $version,
				'in_sync'         => false,
				'drift'           => array('php', 'js', 'css'),
				'message'         => __('This plugin is not installed on the site yet. Install it to make it live.', 'lolacore'),
			);
		}

		// Compare disk against what install would write NOW (the latest library
		// version, post-normalization) so the diff reflects real version drift,
		// not the verbatim-vs-packaged asset/header rewrite that always differs.
		$expected_php = PluginFileNormalizer::normalizePhp(
			(string) get_post_meta($post_id, PluginPostType::META_PHP, true),
			$slug,
			$version
		);
		$expected_js  = (string) get_post_meta($post_id, PluginPostType::META_JS, true);
		$expected_css = (string) get_post_meta($post_id, PluginPostType::META_CSS, true);

		$drift = array();
		if ($disk['php'] !== $expected_php) {
			$drift[] = 'php';
		}
		if ($disk['js'] !== $expected_js) {
			$drift[] = 'js';
		}
		if ($disk['css'] !== $expected_css) {
			$drift[] = 'css';
		}
		$in_sync = $drift === array();

		$code = $this->capCodeForAudit($disk['php'], $disk['js'], $disk['css']);

		$result = array(
			'post_id'             => $post_id,
			'name'                => $post->post_title,
			'slug'                => $slug,
			'installed'           => true,
			'active'              => $disk['active'],
			'plugin_file'         => $disk['plugin_file'],
			'disk_version_header' => $disk['version_header'],
			'library_version'     => $version,
			'in_sync'             => $in_sync,
			'drift'               => $drift,
			'code'                => $code['files'],
			'message'             => $in_sync
				? __('The installed plugin matches the latest library version.', 'lolacore')
				: sprintf(
					/* translators: 1: library version number, 2: comma-separated list of out-of-date files */
					__('The installed plugin differs from the latest library version (v%1$d). Out-of-date files: %2$s. Reinstall to make the latest version live.', 'lolacore'),
					$version,
					implode(', ', $drift)
				),
		);

		if ($code['warning'] !== '') {
			$result['audit_warning'] = $code['warning'];
		}

		return $result;
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function testInPlayground( array $params ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		$post = $this->getPluginPost($post_id);

		// Playground is the safe test surface — do not gate the URL behind a loopback
		// scrape on this host (often unreliable on production). Load-time checks run
		// at save time for review metadata and again before local install.
		$url = $this->blueprintGenerator->build($post_id);

		$version = max(1, (int) get_post_meta($post_id, PluginPostType::META_VERSION, true));

		return array(
			'post_id'        => $post_id,
			'name'           => $post->post_title,
			'slug'           => (string) get_post_meta($post_id, PluginPostType::META_SLUG, true),
			'version'        => $version,
			'playground_url' => $url,
		);
	}

	/**
	 * Compact tool result stored in chat history (omits long Playground URLs).
	 *
	 * @param array<string, mixed> $result Handler result payload.
	 */
	public static function slimToolResultForHistory( string $actionId, array $result ): string {
		$name = (string) ( $result['name'] ?? 'plugin' );

		if ( $actionId === 'test_plugin_playground' ) {
			$version = isset( $result['version'] ) ? max( 1, (int) $result['version'] ) : 1;

			return sprintf(
				/* translators: 1: plugin name, 2: version number */
				__('Playground URL generated for %1$s (v%2$d).', 'lolacore'),
				$name,
				$version
			);
		}

		if ( $actionId === 'create_plugin' || $actionId === 'update_plugin_code' ) {
			$version = max( 1, (int) ( $result['version'] ?? 1 ) );

			return sprintf(
				/* translators: 1: plugin name, 2: version number */
				__('Plugin "%1$s" saved (v%2$d).', 'lolacore'),
				$name,
				$version
			);
		}

		return (string) wp_json_encode( self::slimResultPayload( $result ) );
	}

	/**
	 * User-facing intro line for a chat artifact card.
	 */
	public static function artifactIntroMessage( string $artifactType ): string {
		return match ( $artifactType ) {
			'plugin_playground' => __( 'Your plugin is ready to test in Playground.', 'lolacore' ),
			'plugin_created'    => __( 'Your plugin is saved to the library.', 'lolacore' ),
			'plugin_updated'    => __( 'Your plugin has been updated.', 'lolacore' ),
			default             => __( 'Action completed.', 'lolacore' ),
		};
	}

	/**
	 * Payload sent to the chat UI (playground_url allowed here, not in LLM history).
	 *
	 * @param array<string, mixed> $result Handler result payload.
	 * @return array<string, mixed>
	 */
	public static function artifactDataForUi( string $artifactType, array $result ): array {
		$data = self::slimResultPayload( $result );

		if ( $artifactType === 'plugin_playground' && ! empty( $result['playground_url'] ) ) {
			$data['playground_url'] = (string) $result['playground_url'];
		}

		if ( ! empty( $data['post_id'] ) ) {
			$data = array_merge( $data, PlaygroundGate::uiFlags( (int) $data['post_id'] ) );
		}

		return $data;
	}

	/**
	 * @param array<string, mixed> $result
	 * @return array<string, mixed>
	 */
	private static function slimResultPayload( array $result ): array {
		$keys = array( 'post_id', 'name', 'slug', 'version', 'confidence', 'confidence_note' );

		$slim = array();
		foreach ( $keys as $key ) {
			if ( array_key_exists( $key, $result ) ) {
				$slim[ $key ] = $result[ $key ];
			}
		}

		return $slim;
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function installLocal( array $params, bool $dryRun ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		$post = $this->getPluginPost($post_id);
		$slug = (string) get_post_meta($post_id, PluginPostType::META_SLUG, true);
		$already_installed = $this->localInstaller->isAlreadyInstalled($post_id);

		PlaygroundGate::assertInstallAllowed($post_id);

		if ($dryRun) {
			return array(
				'preview'           => true,
				'post_id'           => $post_id,
				'name'              => $post->post_title,
				'slug'              => $slug,
				'already_installed' => $already_installed,
				'backup_advisory'   => $this->buildBackupAdvisory(),
				'message'           => $already_installed
					? __('A version is already installed. This will overwrite the existing plugin files. You can activate it afterward from Plugins.', 'lolacore')
					: __('Will install this built plugin on this site without activating it. You can activate it afterward from Plugins.', 'lolacore'),
			);
		}

		// Sandbox-load the plugin before writing it to wp-content/plugins, so a
		// load-time fatal surfaces as a clear error instead of a broken install.
		PlaygroundGate::assertLoadSafe($post_id, $this->runtimeValidator);

		$overwrite = ! empty($params['overwrite']) || $already_installed;
		$result    = $this->localInstaller->install($post_id, $overwrite);

		$logger = new ActionLogger();
		$logger->log(
			'install_plugin_local',
			sprintf(
				'Plugin Builder installed "%s" on this site (%s).',
				$post->post_title,
				$result['plugin_file']
			),
			'success',
			'chat',
			array(
				'post_id'     => $post_id,
				'plugin_file' => $result['plugin_file'],
				'overwritten' => $result['overwritten'],
			)
		);

		$memory = new MemoryManager();
		$memory->saveFact(
			'site_state',
			sprintf(
				"Plugin Builder installed '%s' (%s) on this site.",
				$post->post_title,
				$result['plugin_file']
			),
			6,
			'action'
		);

		return $result;
	}

	/**
	 * Known backup/migration plugins, keyed by slug → human label.
	 *
	 * Used only to advise the user before installing on the live site. Detection
	 * is best-effort and NEVER blocks the install: the gate is a strong
	 * recommendation plus user attestation (handled by the SOP), not a hard
	 * technical block — a user may rely on host or staging snapshots Lola cannot see.
	 *
	 * @var array<string, string>
	 */
	private const KNOWN_BACKUP_PLUGINS = array(
		'updraftplus'             => 'UpdraftPlus',
		'duplicator'              => 'Duplicator',
		'backwpup'                => 'BackWPup',
		'all-in-one-wp-migration' => 'All-in-One WP Migration',
		'wpvivid-backuprestore'   => 'WPvivid Backup',
		'backupbuddy'             => 'BackupBuddy',
		'wp-migrate-db'           => 'WP Migrate',
	);

	/**
	 * Build the backup advisory surfaced in the install dry-run preview.
	 *
	 * Detects which known backup plugins are ACTIVE (an installed-but-inactive
	 * backup plugin offers no protection, so active is the actionable signal) and
	 * returns an honest recommendation: the built plugin itself is reversible by
	 * Lola (each version is a saved revision), while a full-site backup protects
	 * everything else against a regeneration that could break the admin. Advisory
	 * only — the install is never blocked here, and host/staging snapshots Lola
	 * cannot see still count.
	 *
	 * @return array{active_backup_plugins: array<int, array{slug: string, name: string}>, has_active_backup_plugin: bool, recommendation: string}
	 */
	private function buildBackupAdvisory(): array {
		if (! function_exists('is_plugin_active') && file_exists(ABSPATH . 'wp-admin/includes/plugin.php')) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$active = array();
		if (function_exists('is_plugin_active')) {
			foreach (self::KNOWN_BACKUP_PLUGINS as $slug => $name) {
				// These backup plugins all ship their bootstrap as slug/slug.php.
				if (is_plugin_active($slug . '/' . $slug . '.php')) {
					$active[] = array('slug' => $slug, 'name' => $name);
				}
			}
		}

		$has_active = $active !== array();

		$recommendation = $has_active
			? __('A backup plugin is active on this site. Make sure you have a recent backup before installing. Your built plugin is reversible by Lola (each version is a saved revision); the backup is your safety net for everything else.', 'lolacore')
			: __('No active backup plugin was detected on this site. Make a recent backup before installing on your live site — a host or staging snapshot also counts. Your built plugin is reversible by Lola (each version is a saved revision); a full-site backup protects everything else. Offer to install a backup plugin (e.g. UpdraftPlus) if the user has none, but do not install it without their go-ahead.', 'lolacore');

		return array(
			'active_backup_plugins'    => $active,
			'has_active_backup_plugin' => $has_active,
			'recommendation'           => $recommendation,
		);
	}

	/**
	 * @param array<string, mixed> $params
	 * @return array<string, mixed>
	 */
	private function deletePlugin( array $params, bool $dryRun ): array {
		$this->assertManageOptions();

		$post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0;
		if ($post_id <= 0) {
			throw new \Exception(__('post_id is required.', 'lolacore'));
		}

		$post = $this->getPluginPost($post_id);

		if ($dryRun) {
			return array(
				'preview' => true,
				'action'  => __('Will move built plugin to trash', 'lolacore'),
				'post_id' => $post_id,
				'name'    => $post->post_title,
			);
		}

		$result = wp_trash_post($post_id);
		if ($result === false || $result === null) {
			throw new \Exception(__('Could not move the built plugin to trash.', 'lolacore'));
		}

		$logger = new ActionLogger();
		$logger->log(
			'delete_built_plugin',
			sprintf('Plugin Builder moved "%s" to trash.', $post->post_title),
			'success',
			'chat',
			array('post_id' => $post_id)
		);

		return array(
			'success' => true,
			'post_id' => $post_id,
			'name'    => $post->post_title,
			'message' => __('Built plugin moved to trash.', 'lolacore'),
		);
	}

	private function assertManageOptions(): void {
		if (! current_user_can('manage_options')) {
			throw new \Exception(__('Insufficient permissions to manage built plugins.', 'lolacore'));
		}
	}

	private function getPluginPost( int $post_id ): \WP_Post {
		$post = get_post($post_id);
		if (! $post instanceof \WP_Post || $post->post_type !== PluginPostType::POST_TYPE) {
			throw new \Exception(__('Built plugin not found.', 'lolacore'));
		}

		return $post;
	}

	/**
	 * Load the stored plugin in a WordPress sandbox to catch a fatal that the
	 * syntax lint cannot see, before the user opens Playground or installs it.
	 *
	 * Runs in the user's request context (where the sandbox scrape can execute);
	 * a skipped or clean result never blocks — only a confirmed fatal throws, with
	 * the message and line so a follow-up update_plugin_code can target the fix.
	 *
	 * @throws \Exception When the generated plugin fatals on load.
	 */
	private function assertGeneratedCodeLoads( int $post_id ): void {
		PlaygroundGate::assertLoadSafe($post_id, $this->runtimeValidator);
	}

	/**
	 * @param array{php: string, js: string, css: string, slug: string, dependencies: array<int, string>} $generated
	 * @param array{confidence: int, note: string, suggestions: array<int, string>, load_safe?: array{status: string, message: string, line: int}, safety_findings?: array<int, array{severity: string, code: string, message: string, line?: int}>} $review
	 */
	private function persistPluginMeta(
		int $post_id,
		array $generated,
		string $description,
		array $review,
		int $version,
		?string $prd = null
	): void {
		// The Metadata API expects SLASHED input — update_metadata() runs wp_unslash()
		// on the value before storing it (it is built for pre-slashed $_POST data).
		// The LLM-generated code is NOT slashed, so passing it raw makes WordPress
		// strip one backslash level, which silently corrupts the plugin: namespaced
		// class references (\Automattic\WooCommerce\...) collapse into a non-existent
		// class and escaped quotes ('You\'ve') break into a PHP parse error — both
		// fatal at runtime. wp_slash() pre-slashes so WP's wp_unslash() restores the
		// code verbatim. Applied to every free-text field that may contain backslashes.
		update_post_meta($post_id, PluginPostType::META_SLUG, sanitize_title($generated['slug']));
		// PRD is optional and only set on create. On update we pass null so the
		// original design contract is preserved (and re-snapshotted into the new
		// revision, since META_PRD is a revisioned key). wp_slash for the same
		// reason as the code/description fields above.
		if (is_string($prd) && $prd !== '') {
			update_post_meta($post_id, PluginPostType::META_PRD, wp_slash($prd));
		}
		update_post_meta($post_id, PluginPostType::META_DESCRIPTION, wp_slash($description));
		update_post_meta($post_id, PluginPostType::META_PHP, wp_slash($generated['php']));
		update_post_meta($post_id, PluginPostType::META_JS, wp_slash($generated['js']));
		update_post_meta($post_id, PluginPostType::META_CSS, wp_slash($generated['css']));
		update_post_meta($post_id, PluginPostType::META_DEPENDENCIES, $generated['dependencies']);
		update_post_meta($post_id, PluginPostType::META_CONFIDENCE, $review['confidence']);
		update_post_meta($post_id, PluginPostType::META_CONFIDENCE_NOTE, wp_slash($review['note']));
		update_post_meta($post_id, PluginPostType::META_SUGGESTIONS, wp_slash($review['suggestions']));
		update_post_meta($post_id, PluginPostType::META_VERSION, $version);

		if (isset($review['load_safe']) && is_array($review['load_safe'])) {
			update_post_meta(
				$post_id,
				PluginPostType::META_LOAD_SAFE_STATUS,
				sanitize_key((string) ( $review['load_safe']['status'] ?? '' ))
			);
		}

		if (isset($review['safety_findings']) && is_array($review['safety_findings'])) {
			update_post_meta($post_id, PluginPostType::META_SAFETY_FINDINGS, $review['safety_findings']);
		}

		PlaygroundGate::reset($post_id);

		if (isset($review['load_safe']) && is_array($review['load_safe'])) {
			PlaygroundGate::seedLoadSafeCache(
				$post_id,
				$version,
				$generated['php'],
				$review['load_safe']
			);
		}
	}

	private function deriveTitleFromCode( string $php, string $slug ): string {
		if (preg_match('/Plugin\s+Name:\s*(.+)/i', $php, $matches) === 1) {
			$title = trim($matches[1]);
			if ($title !== '') {
				return sanitize_text_field($title);
			}
		}

		if ($slug !== '') {
			return ucwords(str_replace(array('-', '_'), ' ', $slug));
		}

		return __('Built Plugin', 'lolacore');
	}

	/**
	 * Build the regeneration prompt for an iteration on an existing plugin.
	 *
	 * Public and static so the async BuilderWorker shares one source of truth
	 * (the prompt was previously duplicated there). When a previous reviewer
	 * verdict is supplied, its note and suggestions are folded in so the
	 * regeneration also resolves known issues — not only the user's request.
	 * This closes the gap where stored review findings were never reused.
	 *
	 * @param array{confidence?: int, note?: string, suggestions?: array<int, string>} $previousReview
	 * @param string $prd Original approved design contract (PRD), re-fed so the iteration honors it.
	 */
	public static function buildUpdatePrompt(
		string $description,
		string $php,
		string $js,
		string $css,
		string $instruction,
		array $previousReview = array(),
		string $prd = ''
	): string {
		$prompt = "Update an existing WordPress plugin.\n\n";

		$prd = trim($prd);
		if ($prd !== '') {
			// Re-feed the original design contract so the iteration stays true to
			// the approved scope, site facts, and integration approach.
			$prompt .= "Approved design contract (PRD) — keep honoring this:\n" . $prd . "\n\n";
		}

		$prompt .= "Original requirements:\n"
			. $description
			. "\n\nCurrent PHP:\n```php\n"
			. $php
			. "\n```\n\nCurrent JS:\n```js\n"
			. $js
			. "\n```\n\nCurrent CSS:\n```css\n"
			. $css
			. "\n```\n";

		$reviewBlock = self::formatPreviousReviewForPrompt($previousReview);
		if ($reviewBlock !== '') {
			$prompt .= "\n" . $reviewBlock;
		}

		return $prompt
			. "\nChange request:\n"
			. $instruction
			. "\n\nReturn the full updated plugin (PHP, JS, CSS). Keep the same plugin slug unless the change request explicitly requires renaming.";
	}

	/**
	 * Format the previous reviewer verdict (note + suggestions) for the update
	 * prompt. Returns an empty string when there is nothing actionable to carry.
	 *
	 * @param array{confidence?: int, note?: string, suggestions?: array<int, string>} $review
	 */
	public static function formatPreviousReviewForPrompt( array $review ): string {
		$note        = isset($review['note']) ? trim((string) $review['note']) : '';
		$suggestions = array();
		if (isset($review['suggestions']) && is_array($review['suggestions'])) {
			foreach ($review['suggestions'] as $item) {
				$item = trim((string) $item);
				if ($item !== '') {
					$suggestions[] = $item;
				}
			}
		}

		if ($note === '' && empty($suggestions)) {
			return '';
		}

		$confidence = isset($review['confidence']) ? (int) $review['confidence'] : 0;

		$block = 'Known issues from the last automated review';
		if ($confidence > 0) {
			$block .= sprintf(' (code review %d%%)', $confidence);
		}
		$block .= " — also resolve these unless the change request makes them obsolete:\n";

		if ($note !== '') {
			$block .= 'Reviewer note: ' . $note . "\n";
		}
		if (! empty($suggestions)) {
			$block .= "- " . implode("\n- ", $suggestions) . "\n";
		}

		return $block;
	}

	/**
	 * Re-generate the plugin if the reviewer scored it below the confidence threshold.
	 *
	 * The reviewer's suggestions are appended to the description so the LLM
	 * can correct the exact issues identified. At most MAX_AUTO_RETRIES
	 * attempts are made; if the retry still scores below threshold the result
	 * is returned as-is (with a quality_warning flag added by the caller).
	 *
	 * @param array{php: string, js: string, css: string, slug: string, dependencies: array<int, string>} $generated
	 * @param array{confidence: int, note: string, suggestions: array<int, string>} $review
	 * @return array{0: array, 1: array, 2: bool} [generated, review, retried]
	 */
	private function maybeRetryGeneration(
		string $description,
		array $generated,
		array $review,
		bool $includeSiteContext = true,
		string $prd = '',
		int $libraryVersion = 1
	): array {
		$orchestrator = new GenerationRetryOrchestrator($this->generator, $this->reviewer);
		list($generated, $review, $retried, $stuck) = $orchestrator->run(
			$description,
			$generated,
			$review,
			$includeSiteContext,
			$prd,
			null,
			$libraryVersion
		);

		if ($stuck) {
			$review['_retry_stuck'] = true;
		}

		return array($generated, $review, $retried);
	}

	/**
	 * Attach separated quality signals for delivery cards (QG-3).
	 *
	 * @param array<string, mixed> $previewData
	 * @param array<string, mixed> $review
	 * @return array<string, mixed>
	 */
	public static function enrichPreviewQuality( array $previewData, array $review ): array {
		$previewData['code_review_score'] = (int) ( $review['confidence'] ?? 0 );

		if (isset($review['load_safe']) && is_array($review['load_safe'])) {
			$previewData['load_safe'] = $review['load_safe'];
		}

		return $previewData;
	}

	/**
	 * Apply disk-write PHP normalization before review so the gate evaluates
	 * the same artifact that install and Playground will package.
	 *
	 * @param array{php: string, js?: string, css?: string, slug: string, dependencies?: array<int, string>} $generated
	 * @return array{php: string, js?: string, css?: string, slug: string, dependencies?: array<int, string>}
	 * @deprecated Review normalizes internally for runtime only; install/Playground normalize at packaging.
	 */
	public static function normalizeGeneratedForReview( array $generated, int $libraryVersion = 1 ): array {
		$slug = isset($generated['slug']) ? (string) $generated['slug'] : '';
		$php  = isset($generated['php']) ? (string) $generated['php'] : '';

		if ($slug === '' || $php === '') {
			return $generated;
		}

		$generated['php'] = PluginFileNormalizer::normalizePhp(
			$php,
			$slug,
			max(1, $libraryVersion)
		);

		return $generated;
	}

	/**
	 * Whether the review should trigger an automatic regeneration attempt.
	 *
	 * @param array{confidence?: int, blocking?: bool} $review
	 */
	public static function reviewNeedsRetry( array $review ): bool {
		if (! empty($review['blocking'])) {
			return true;
		}

		return (int) ( $review['confidence'] ?? 0 ) < self::MIN_CONFIDENCE_THRESHOLD;
	}

	/**
	 * User-facing failure message after retries are exhausted.
	 *
	 * @param array{confidence?: int, blocking?: bool} $review
	 */
	public static function qualityGateFailMessage( array $review ): string {
		if (! empty($review['_retry_stuck'])) {
			return self::retryStuckFailMessage();
		}

		if (! empty($review['blocking'])) {
			return self::safetyCheckFailMessage();
		}

		return __('I generated code, but it did not pass my internal quality checks. Try breaking your request into smaller steps or providing simpler instructions.', 'lolacore');
	}

	/**
	 * Append reviewer suggestions to the description for the retry attempt.
	 *
	 * @param array{
	 *   confidence: int,
	 *   note: string,
	 *   suggestions: array<int, string>,
	 *   blocking?: bool,
	 *   safety_findings?: array<int, array{severity?: string, code?: string, message?: string}>
	 * } $review
	 */
	/**
	 * @param array{
	 *   confidence: int,
	 *   note: string,
	 *   suggestions: array<int, string>,
	 *   blocking?: bool,
	 *   safety_findings?: array<int, array{severity?: string, code?: string, message?: string}>
	 * } $review
	 */
	public static function buildRetryDescription(
		string $description,
		array $review,
		?RetryLedger $ledger = null
	): string {
		$parts = array($description);

		$ledgerBlock = $ledger !== null ? $ledger->formatForPrompt() : '';
		if ($ledgerBlock !== '') {
			$parts[] = "\n\n" . $ledgerBlock;
		} else {
			$blockingLines = self::formatBlockingSafetyFindings($review);
			if ($blockingLines !== '') {
				$parts[] = "\n\nBLOCKING SAFETY FINDINGS (must fix):\n" . $blockingLines;
			}
		}

		if (! empty($review['suggestions'])) {
			$parts[] = "\n\nIMPORTANT — The previous attempt scored " . $review['confidence']
				. '% confidence. Fix these issues:\n- '
				. implode("\n- ", $review['suggestions']);
		}

		if ($review['note'] !== '') {
			$parts[] = "\nReviewer note: " . $review['note'];
		}

		return implode('', $parts);
	}

	/**
	 * @param array{php?: string, js?: string, css?: string, slug?: string} $generated
	 */
	public static function buildRepairPrompt(
		string $description,
		array $generated,
		RetryLedger $ledger,
		string $prd = ''
	): string {
		$php = (string) ( $generated['php'] ?? '' );
		$js  = (string) ( $generated['js'] ?? '' );
		$css = (string) ( $generated['css'] ?? '' );

		$prompt = "Repair a WordPress plugin that failed internal safety checks.\n\n";

		$prd = trim($prd);
		if ($prd !== '') {
			$prompt .= "Approved design contract (PRD) — keep honoring this:\n" . $prd . "\n\n";
		}

		$prompt .= "Original functional requirements:\n"
			. $description
			. "\n\nPrevious attempt (FAILED safety gate):\n"
			. "Current PHP:\n```php\n" . $php . "\n```\n\n"
			. "Current JS:\n```js\n" . $js . "\n```\n\n"
			. "Current CSS:\n```css\n" . $css . "\n```\n";

		$ledgerBlock = $ledger->formatForPrompt();
		if ($ledgerBlock !== '') {
			$prompt .= "\n" . $ledgerBlock . "\n";
		}

		$hints = self::formatRepairRuleHints($ledger->getUniqueFatalCodes());
		if ($hints !== '') {
			$prompt .= "\n" . $hints . "\n";
		}

		$prompt .= "\nREPAIR INSTRUCTION:\n"
			. "The previous attempt failed internal safety checks. Fix ONLY the blocking issues listed above.\n"
			. "Do not rewrite unrelated code. Do not change the plugin slug unless required to fix a fatal.\n"
			. "Return the complete plugin (PHP, JS, CSS) and the metadata JSON block.\n";

		return $prompt;
	}

	/**
	 * @param array<int, string> $fatalCodes
	 */
	public static function formatRepairRuleHints( array $fatalCodes ): string {
		$hints = array();
		foreach (self::normalizeRepairCodes($fatalCodes) as $code) {
			$hint = self::repairHintForCode($code);
			if ($hint !== null) {
				$hints[] = '- ' . $code . ': ' . $hint;
			}
		}

		/**
		 * Filters canonical repair hints keyed by safety rule code.
		 *
		 * @param array<int, string> $hints Lines already resolved for known codes.
		 * @param array<int, string> $fatalCodes Normalized fatal codes from the ledger.
		 */
		$filtered = apply_filters('lolacore_pb_repair_hints', $hints, $fatalCodes);

		if (! is_array($filtered) || $filtered === array()) {
			return '';
		}

		return "Canonical fixes for detected rule codes:\n" . implode("\n", $filtered);
	}

	/**
	 * @param array{blocking?: bool, confidence?: int} $review
	 */
	public static function formatRetryTriggerReason( array $review ): string {
		if (! empty($review['blocking'])) {
			return 'blocking safety';
		}

		return 'low confidence';
	}

	/**
	 * @param array<int, string> $codes
	 * @return array<int, string>
	 */
	private static function normalizeRepairCodes( array $codes ): array {
		$codes = array_values(array_unique($codes));
		sort($codes);

		return $codes;
	}

	private static function repairHintForCode( string $code ): ?string {
		switch ($code) {
			case 'early_woocommerce_exit':
				return 'Move WooCommerce dependency checks into add_action(\'plugins_loaded\', ...). Never use return/exit at file scope after class_exists(\'WooCommerce\').';
			case 'unknown_parent_class_at_file_scope':
				return 'If extending WC_Settings_Page, require the base class immediately before the declaration: if (defined(\'WC_ABSPATH\') && ! class_exists(\'WC_Settings_Page\', false)) { require_once WC_ABSPATH . \'includes/admin/settings/class-wc-settings-page.php\'; } — or declare the class inside a hook callback guarded by class_exists().';
			case 'stripped_vendor_namespace':
				return 'Restore namespace backslashes in fully-qualified class names (e.g. \\Automattic\\WooCommerce\\Utilities\\FeaturesUtil).';
			default:
				return null;
		}
	}

	/**
	 * @param array{safety_findings?: array<int, array{severity?: string, code?: string, message?: string}>} $review
	 */
	public static function formatBlockingSafetyFindings( array $review ): string {
		if (empty($review['safety_findings']) || ! is_array($review['safety_findings'])) {
			return '';
		}

		$lines = array();
		foreach ($review['safety_findings'] as $finding) {
			if (($finding['severity'] ?? '') !== 'fatal') {
				continue;
			}

			$lines[] = sprintf(
				'- [fatal] %s: %s',
				(string) ( $finding['code'] ?? 'unknown' ),
				(string) ( $finding['message'] ?? '' )
			);
		}

		return implode("\n", $lines);
	}

	/**
	 * Allow sequential developer + reviewer HTTP calls without PHP killing the request.
	 */
	private function ensureGenerationRuntime(): void {
		if (function_exists('set_time_limit')) {
			// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Long-running Plugin Builder generation needs extended PHP time.
			@set_time_limit(300);
		}
	}

	/**
	 * Attempt to dispatch the generation work to a background worker.
	 *
	 * Returns an async_job payload if dispatch succeeds, or null if it fails
	 * (caller should fall back to synchronous execution).
	 *
	 * @param string               $action 'create_plugin' or 'update_plugin_code'.
	 * @param array<string, mixed> $params Generation parameters for the worker.
	 * @return array<string, mixed>|null
	 */
	private function tryAsyncDispatch( string $action, array $params ): ?array {
		$jobId      = \wp_generate_uuid4();
		$jobManager = new JobManager();
		$jobManager->createJob( $jobId, $action, $params, (int) \get_current_user_id() );

		$dispatched = BuilderWorker::dispatch( $jobId );

		if ( ! $dispatched ) {
			// Loopback failed (firewall, DNS, etc.) — clean up and let the
			// caller fall through to the existing synchronous path.
			$jobManager->cleanup( $jobId );
			return null;
		}

		return array(
			'async_job' => true,
			'job_id'    => $jobId,
			'message'   => __('The engineering team is drafting the plugin…', 'lolacore'),
		);
	}
}

