<?php

/**
 * Loads generated plugin code in a WordPress sandbox to catch fatal errors that
 * a syntax-only lint cannot see (e.g. an undefined class on a top-level `extends`,
 * or a load-time fatal). Mirrors the proven approach of using the core plugin
 * editor's sandboxed scrape, which returns the fatal message and line WITHOUT
 * taking the site down.
 *
 * @package LolaCore\Builder\Runtime
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Builder\Runtime;

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

/**
 * Best-effort runtime validation of generated plugin PHP.
 *
 * Degrades to STATUS_SKIPPED whenever it cannot run safely — outside an admin
 * context, without the edit_plugins capability, on multisite, or when the core
 * loopback scrape itself fails (common under cgi-fcgi on local environments).
 * A skip never blocks a build; only a confirmed fatal does.
 */
class RuntimeValidator {

	public const STATUS_OK      = 'ok';
	public const STATUS_FATAL   = 'fatal';
	public const STATUS_SKIPPED = 'skipped';

	private const SANDBOX_PREFIX = 'lolacore-validate-';

	/**
	 * @param array<int, string> $dependencies Declared plugin dependencies (e.g. woocommerce).
	 * @return array{status: string, message?: string, line?: int}
	 */
	public function validate( string $php, string $slug, array $dependencies = array() ): array {
		if (! $this->canRun()) {
			return array( 'status' => self::STATUS_SKIPPED );
		}

		$this->bootstrapDependencies($dependencies);

		global $wp_filesystem;
		if (! $wp_filesystem instanceof \WP_Filesystem_Base) {
			if (! function_exists('WP_Filesystem') || ! WP_Filesystem() || ! $wp_filesystem instanceof \WP_Filesystem_Base) {
				return array( 'status' => self::STATUS_SKIPPED );
			}
		}

		$this->purgeStaleSandboxes();

		$folderName = $this->sandboxFolderName($slug);
		$phpRel     = $folderName . '/' . $folderName . '.php';
		$folderPath = WP_PLUGIN_DIR . '/' . $folderName;
		$phpPath    = WP_PLUGIN_DIR . '/' . $phpRel;

		// Never touch a directory that already exists — only operate on our own
		// throwaway sandbox so a real plugin can never be clobbered.
		if ($wp_filesystem->is_dir($folderPath) || $wp_filesystem->exists($phpPath)) {
			return array( 'status' => self::STATUS_SKIPPED );
		}

		$header = "<?php\n/**\n * Plugin Name: LolaCore Validation Sandbox\n"
			. " * Description: Temporary file used to validate generated code. Safe to delete.\n"
			. " * Version: 1.0\n */\n";

		try {
			if (! $wp_filesystem->mkdir($folderPath, FS_CHMOD_DIR)) {
				return array( 'status' => self::STATUS_SKIPPED );
			}
			if (! $wp_filesystem->put_contents($phpPath, $header, FS_CHMOD_FILE)) {
				return array( 'status' => self::STATUS_SKIPPED );
			}

			// The core scrape only runs for an active plugin.
			if (! is_plugin_active($phpRel)) {
				activate_plugin($phpRel);
			}

			$result = wp_edit_theme_plugin_file(array(
				'file'       => $phpRel,
				'plugin'     => $phpRel,
				'newcontent' => $php,
				'nonce'      => wp_create_nonce('edit-plugin_' . $phpRel),
			));

			if (! is_wp_error($result)) {
				return array( 'status' => self::STATUS_OK );
			}

			return $this->interpretError($result);
		} finally {
			$this->cleanup($folderPath, $phpRel);
		}
	}

	/**
	 * Map a plugin-editor WP_Error to our result. The editor reports a fatal as a
	 * `php_error` data block carrying the message, line, and an inner code that is
	 * `loopback_request_failed` when the scrape could not complete (treat as skip).
	 *
	 * @param \WP_Error $error
	 * @return array{status: string, message?: string, line?: int}
	 */
	private function interpretError( $error ): array {
		if (self::isInfrastructureScrapeFailure($error)) {
			return array( 'status' => self::STATUS_SKIPPED );
		}

		$phpError = self::extractPhpErrorData($error);

		$message = is_array($phpError) && isset($phpError['message'])
			? wp_strip_all_tags((string) $phpError['message'])
			: $error->get_error_message();

		$line = is_array($phpError) && isset($phpError['line']) ? (int) $phpError['line'] : 0;

		$message = trim($message) !== '' ? trim($message) : __('The plugin failed to load.', 'lolacore');

		return array(
			'status'  => self::STATUS_FATAL,
			'message' => $message,
			'line'    => $line,
		);
	}

	/**
	 * @return array<string, mixed>|null
	 */
	private static function extractPhpErrorData( \WP_Error $error ): ?array {
		$data = $error->get_error_data('php_error');
		if (! is_array($data)) {
			$data = $error->get_error_data();
		}
		if (! is_array($data)) {
			return null;
		}
		if (isset($data['php_error']) && is_array($data['php_error'])) {
			return $data['php_error'];
		}
		if (isset($data['line']) || isset($data['code']) || isset($data['message'])) {
			return $data;
		}

		return null;
	}

	/**
	 * WordPress plugin-editor scrape failures (loopback, nonce, filesystem) are not
	 * plugin fatals. Only errors with a line-numbered php_error block from the scrape
	 * are treated as confirmed load-time failures.
	 */
	public static function isInfrastructureScrapeFailure( \WP_Error $error ): bool {
		$phpError = self::extractPhpErrorData($error);

		if (is_array($phpError) && ($phpError['code'] ?? '') === 'loopback_request_failed') {
			return true;
		}

		$infrastructureCodes = array(
			'loopback_request_failed',
			'http_failure',
			'http_request_failed',
			'http_request_not_executed',
			'wp_http_valid_response',
			'could_not_remove_old_file',
			'unable_to_create_lock',
			'unable_to_connect_to_filesystem',
			'fs_unavailable',
			'unauthorized',
			'invalid_nonce',
			'rest_cookie_invalid_nonce',
			'plugins_disabled',
		);

		if (in_array($error->get_error_code(), $infrastructureCodes, true)) {
			return true;
		}

		if (is_array($phpError) && isset($phpError['message'], $phpError['line']) && (int) $phpError['line'] > 0) {
			$message = wp_strip_all_tags((string) $phpError['message']);

			return self::isClassRedeclareMessage($message);
		}

		$topMessage = trim(wp_strip_all_tags($error->get_error_message()));
		if (self::isClassRedeclareMessage($topMessage)) {
			return true;
		}

		if ($error->get_error_code() === 'php_error') {
			return ! is_array($phpError) || (int) ( $phpError['line'] ?? 0 ) === 0;
		}

		return true;
	}

	/**
	 * Legacy misclassified infra failures stored in the load-safe transient.
	 */
	public static function isStaleMisclassifiedFatalMessage( string $message ): bool {
		$message = trim($message);

		return $message === '' || $message === __('The plugin failed to load.', 'lolacore');
	}

	/**
	 * Class redeclare fatals from repeated sandbox scrapes are environment noise, not plugin bugs.
	 */
	public static function isClassRedeclareMessage( string $message ): bool {
		$lower = strtolower($message);

		return str_contains($lower, 'cannot declare class')
			|| str_contains($lower, 'already in use');
	}

	/**
	 * Whether the current context can safely run the sandbox scrape.
	 */
	private function canRun(): bool {
		// Only inside a genuine WordPress runtime. WPINC is defined early by core on
		// every real request but never by the unit-test stubs, so this keeps the
		// sandbox scrape (filesystem writes + loopback) out of the test suite.
		if (! defined('WPINC')) {
			return false;
		}
		if (! function_exists('current_user_can') || ! function_exists('is_multisite')) {
			return false;
		}
		if (is_multisite()) {
			return false;
		}

		$this->loadAdminDependencies();

		if (! function_exists('wp_edit_theme_plugin_file')
			|| ! function_exists('activate_plugin')
			|| ! function_exists('is_plugin_active')
			|| ! defined('WP_PLUGIN_DIR')
		) {
			return false;
		}

		// The editor scrape requires this capability; without it the call would
		// just return an "unauthorized" error we would have to special-case.
		return current_user_can('edit_plugins');
	}

	private function loadAdminDependencies(): void {
		if (! defined('ABSPATH')) {
			return;
		}
		$fileApi   = ABSPATH . 'wp-admin/includes/file.php';
		$pluginApi = ABSPATH . 'wp-admin/includes/plugin.php';

		if (! function_exists('wp_edit_theme_plugin_file') && is_readable($fileApi)) {
			require_once $fileApi;
		}
		if (! function_exists('activate_plugin') && is_readable($pluginApi)) {
			require_once $pluginApi;
		}
	}

	/**
	 * Remove leftover validation sandboxes so repeated scrapes do not redeclare classes.
	 */
	private function purgeStaleSandboxes(): void {
		if (! defined('WP_PLUGIN_DIR') || ! is_dir(WP_PLUGIN_DIR)) {
			return;
		}

		$entries = scandir(WP_PLUGIN_DIR);
		if ($entries === false) {
			return;
		}

		foreach ($entries as $entry) {
			if ($entry === '.' || $entry === '..') {
				continue;
			}
			if (! str_starts_with($entry, self::SANDBOX_PREFIX)) {
				continue;
			}

			$folderPath = WP_PLUGIN_DIR . '/' . $entry;
			if (! is_dir($folderPath)) {
				continue;
			}

			$phpRel = $entry . '/' . $entry . '.php';
			$this->cleanup($folderPath, $phpRel);
		}
	}

	private function cleanup( string $folderPath, string $phpRel ): void {
		if (function_exists('is_plugin_active') && function_exists('deactivate_plugins') && is_plugin_active($phpRel)) {
			deactivate_plugins($phpRel, true);
		}

		global $wp_filesystem;
		if ($wp_filesystem instanceof \WP_Filesystem_Base && $wp_filesystem->is_dir($folderPath)) {
			$wp_filesystem->delete($folderPath, true);
		}
	}

	/**
	 * Load dependency bases that are not autoloaded during a sandbox scrape.
	 *
	 * @param array<int, string> $dependencies
	 */
	private function bootstrapDependencies( array $dependencies ): void {
		$normalized = array();
		foreach ($dependencies as $dependency) {
			if (! is_scalar($dependency)) {
				continue;
			}
			$slug = function_exists('sanitize_title')
				? sanitize_title((string) $dependency)
				: strtolower(trim((string) $dependency));
			if ($slug !== '') {
				$normalized[] = $slug;
			}
		}

		if (! in_array('woocommerce', $normalized, true)) {
			return;
		}

		if (! defined('WC_ABSPATH')) {
			return;
		}

		$settingsPage = WC_ABSPATH . 'includes/admin/settings/class-wc-settings-page.php';
		if (is_readable($settingsPage) && ! class_exists('WC_Settings_Page', false)) {
			require_once $settingsPage;
		}
	}

	private function sandboxFolderName( string $slug ): string {
		$slug = sanitize_title($slug);
		if ($slug === '') {
			$slug = 'plugin';
		}

		// A throwaway, collision-resistant name kept out of the real plugin space.
		return 'lolacore-validate-' . substr($slug, 0, 32) . '-' . wp_generate_password(8, false, false);
	}
}
