<?php
/**
 * WordPress Utilities.
 *
 * Static utility class for common WordPress operations. Provides helper
 * methods for media handling, authorization, and other functionality.
 *
 * @package   Agenistrator
 * @since     1.0.0
 * @author    Alex Ruban <alex@maximum.guru>
 * @author    Maximum Software Inc <contact@maximum.software>
 * @license   GPL-3.0-only
 * @link      https://maximum.software/
 *
 * SPDX-FileCopyrightText: 2025-2026 Alex Ruban <alex@maximum.guru>
 * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc <contact@maximum.software>
 * SPDX-License-Identifier: GPL-3.0-only
 */

namespace Agenistrator;

// Direct access prevention
if ( ! defined( 'ABSPATH' ) ) {
	return;
}

/**
 * WordPress_Utils class.
 */
class WordPress_Utils {

	/**
	 * Capabilities that require super admin to grant in multisite.
	 * These could allow privilege escalation or network-wide access.
	 */
	private static $super_admin_only_capabilities = array(
		// Network management
		'manage_network',
		'manage_sites',
		'manage_network_users',
		'manage_network_plugins',
		'manage_network_themes',
		'manage_network_options',
		'upgrade_network',
		// Core/plugin/theme file editing and updates
		'update_core',
		'update_plugins',
		'update_themes',
		'install_plugins',
		'install_themes',
		'delete_plugins',
		'delete_themes',
		'edit_files',
		'edit_plugins',
		'edit_themes',
		// Dangerous content capabilities
		'unfiltered_html',
		'unfiltered_upload',
	);

	/**
	 * Get list of capabilities that require super admin to grant in multisite
	 *
	 * @return array List of dangerous capability names
	 * @since     1.0.0
	 */
	public static function get_super_admin_only_capabilities() {
		return self::$super_admin_only_capabilities;
	}

	/**
	 * Check if a capability requires super admin to grant in multisite
	 *
	 * @param string $capability Capability name to check
	 * @return bool True if capability requires super admin
	 * @since     1.0.0
	 */
	public static function is_super_admin_only_capability( $capability ) {
		return in_array( $capability, self::$super_admin_only_capabilities, true );
	}

	/**
	 * Check if current user can grant a specific capability
	 *
	 * In multisite, non-super-admins cannot grant dangerous capabilities.
	 *
	 * @param string $capability Capability to check
	 * @return bool True if current user can grant this capability
	 * @since     1.0.0
	 */
	public static function can_grant_capability( $capability ) {
		return ! is_multisite() || is_super_admin() || ! self::is_super_admin_only_capability( $capability );
	}

	/**
	 * Filter capabilities to only those the current user can grant
	 *
	 * @param array $capabilities List of capabilities to filter
	 * @return array Filtered list of capabilities
	 * @since     1.0.0
	 */
	public static function filter_grantable_capabilities( $capabilities ) {
		if ( ! is_multisite() || is_super_admin() ) {
			return $capabilities;
		}
		return array_filter( $capabilities, array( self::class, 'can_grant_capability' ) );
	}

	/**
	 * Get roles the current user can assign
	 *
	 * Uses WordPress's get_editable_roles() which respects the 'editable_roles' filter.
	 * This is the same mechanism WordPress admin uses to restrict role assignment.
	 *
	 * @return array Associative array of role slug => role data
	 * @since     1.0.0
	 */
	public static function get_assignable_roles() {
		if ( ! function_exists( 'get_editable_roles' ) ) {
			require_once ABSPATH . 'wp-admin/includes/user.php';
		}
		return get_editable_roles();
	}

	/**
	 * Check if current user can assign a specific role
	 *
	 * @param string $role Role slug to check
	 * @return bool True if current user can assign this role
	 * @since     1.0.0
	 */
	public static function can_assign_role( $role ) {
		$editable_roles = self::get_assignable_roles();
		return isset( $editable_roles[ $role ] );
	}

	/**
	 * Check if current user can edit a specific user
	 *
	 * Wraps current_user_can('edit_user', $user_id) which uses map_meta_cap()
	 * to properly check multisite and user-specific permissions.
	 *
	 * @param int $user_id User ID to check
	 * @return bool True if current user can edit this user
	 * @since     1.0.0
	 */
	public static function can_edit_user( $user_id ) {
		return current_user_can( 'edit_user', $user_id );
	}

	/**
	 * Check if current user can promote/demote a specific user
	 *
	 * Uses 'promote_user' meta capability which WordPress maps appropriately.
	 *
	 * @param int $user_id User ID to check
	 * @return bool True if current user can change this user's role
	 * @since     1.0.0
	 */
	public static function can_promote_user( $user_id ) {
		return current_user_can( 'promote_users' ) && current_user_can( 'promote_user', $user_id );
	}

	/**
	 * Check if current user can delete a specific user
	 *
	 * @param int $user_id User ID to check
	 * @return bool True if current user can delete this user
	 * @since     1.0.0
	 */
	public static function can_delete_user( $user_id ) {
		if ( ! current_user_can( 'delete_users' ) ) {
			return false;
		}
		// Cannot delete yourself
		if ( get_current_user_id() === $user_id ) {
			return false;
		}
		// Cannot delete super admins in multisite
		if ( is_multisite() && is_super_admin( $user_id ) ) {
			return false;
		}
		return true;
	}

	/**
	 * Set a featured image from a URL
	 *
	 * Downloads the image and sets it as the featured image for the given post.
	 *
	 * @param int $post_id The post ID to set the featured image for
	 * @param string $image_url The URL of the image to download
	 * @return int|false The attachment ID on success, false on failure
	 * @since     1.0.0
	 */
	public static function set_featured_image_from_url( $post_id, $image_url ) {
		if ( ! function_exists( 'media_sideload_image' ) ) {
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$attachment_id = media_sideload_image( $image_url, $post_id, '', 'id' );

		if ( is_wp_error( $attachment_id ) ) {
			return false;
		}

		set_post_thumbnail( $post_id, $attachment_id );

		return $attachment_id;
	}
	/**
	 * Convert an absolute file path to a path relative to WordPress root.
	 *
	 * @param string $file The absolute file path.
	 * @return string The relative path, or original path if not within ABSPATH.
	 * @since     1.0.0
	 */
	public static function get_relative_path( $file ) {
		$abspath = ABSPATH;

		if ( strncmp( $file, $abspath, strlen( $abspath ) ) === 0 ) {
			return substr( $file, strlen( $abspath ) );
		}

		return $file;
	}

	/**
	 * Check if a path is within allowed directories and resolve it.
	 *
	 * Returns the resolved path if it's within ABSPATH, WP_CONTENT_DIR, WP_PLUGIN_DIR,
	 * or the system temp directory. WP_PLUGIN_DIR is included separately because it can
	 * be configured to be outside WP_CONTENT_DIR.
	 *
	 * At least some prefix of the path must exist for resolution to succeed.
	 *
	 * @param string $path The file path to check. Can be absolute or relative to ABSPATH.
	 * @return string|false The resolved absolute path if allowed, or false if outside allowed directories.
	 * @since     1.0.0
	 */
	public static function is_allowed_path( $path ) {
		// Resolve the path (works even if suffix doesn't exist)
		$resolved = PHP_Utils::imaginary_path( $path );
		if ( false === $resolved ) {
			return false;
		}

		// Check if within any allowed directory
		// WP_PLUGIN_DIR is included separately because it can be outside WP_CONTENT_DIR
		$allowed_roots = array(
			ABSPATH,
			WP_CONTENT_DIR,
			WP_PLUGIN_DIR,
			get_temp_dir(),
		);

		foreach ( $allowed_roots as $root ) {
			$real_root = realpath( $root );
			if ( false !== $real_root && PHP_Utils::is_path_within( $resolved, $real_root ) ) {
				return $resolved;
			}
		}

		return false;
	}

	/**
	 * Get the WordPress filesystem instance.
	 *
	 * Initializes WP_Filesystem if not already done and returns the global $wp_filesystem.
	 *
	 * @return \WP_Filesystem_Base The filesystem instance.
	 * @throws \RuntimeException If WP_Filesystem initialization fails.
	 * @since     1.0.0
	 */
	public static function get_wp_filesystem() {
		global $wp_filesystem;

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		if ( ! WP_Filesystem() ) {
			throw new \RuntimeException( 'Failed to initialize WP_Filesystem' );
		}

		return $wp_filesystem;
	}

	/**
	 * Send JSON error response for an exception
	 *
	 * Handles both Ajax_Exception (with HTTP status and error codes) and generic exceptions.
	 * This method does not return - it calls wp_send_json_error() which exits.
	 *
	 * @param \Throwable $e The exception to send as JSON error.
	 * @return never
	 * @since     1.0.0
	 */
	public static function send_json_exception( \Throwable $e ) {
		$data = array( 'message' => $e->getMessage() );

		if ( $e instanceof Ajax_Exception ) {
			if ( $e->get_error_code() ) {
				$data['code'] = $e->get_error_code();
			}
			wp_send_json_error( $data, $e->get_http_status() );
		}

		wp_send_json_error( $data );
	}

	/**
	 * Extract the plugin slug (directory name) from the plugin's main PHP file path.
	 *
	 * WordPress plugins are identified by paths like "my-plugin/my-plugin.php".
	 * This function extracts the slug (directory name) portion.
	 *
	 * Note: We intentionally avoid sanitization functions like sanitize_text_field()
	 * or sanitize_file_name() because they can modify the actual directory name
	 * (e.g., stripping %XX patterns, collapsing dashes), which would break path
	 * construction. Security is ensured by:
	 * - find_plugin() validates the plugin exists in WordPress's registry
	 * - realpath() + is_path_within() validates constructed paths
	 *
	 * @param string $plugin The plugin identifier (e.g., "my-plugin/my-plugin.php" or "my-plugin").
	 * @return string The plugin slug (directory name portion).
	 * @since     1.0.0
	 */
	public static function get_plugin_slug( $plugin ) {
		// Extract slug from plugin file path (e.g., "my-plugin/my-plugin.php" -> "my-plugin")
		// strtok() returns the first component before '/', which is the directory name
		return strtok( $plugin, '/' );
	}

	/**
	 * Find an installed plugin by various matching strategies.
	 *
	 * This method handles LLM input that could be in various formats:
	 * - Full plugin basename (e.g., "akismet/akismet.php")
	 * - Plugin slug/directory name (e.g., "akismet")
	 * - Plugin name (e.g., "Akismet Anti-spam")
	 * - Single-file plugin name (e.g., "hello" for "hello.php")
	 *
	 * Matching priority:
	 * 1. Direct match on plugin basename (exact file path)
	 * 2. Match against directory portion of plugin basename
	 * 3. Match single-file plugins (e.g., "hello" matches "hello.php")
	 * 4. Case-insensitive substring match on plugin name
	 *
	 * @param string $identifier The plugin identifier from LLM input.
	 * @param array  $all_plugins Optional. Array from get_plugins(). If not provided, will be fetched.
	 * @return array|null Array with 'file' and 'data' keys if found, null otherwise.
	 * @since     1.0.0
	 */
	public static function find_plugin( $identifier, $all_plugins = null ) {
		if ( null === $all_plugins ) {
			if ( ! function_exists( 'get_plugins' ) ) {
				require_once ABSPATH . 'wp-admin/includes/plugin.php';
			}
			$all_plugins = get_plugins();
		}

		// Sanitize the identifier to get a clean slug for comparisons
		$input_slug = self::get_plugin_slug( $identifier );

		// Strategy 1: Direct match on plugin basename (e.g., "akismet/akismet.php")
		if ( isset( $all_plugins[ $identifier ] ) ) {
			return array(
				'file' => $identifier,
				'data' => $all_plugins[ $identifier ],
			);
		}

		// Iterate through all plugins trying other matching strategies
		foreach ( $all_plugins as $file => $data ) {
			// Strategy 2: Match against directory portion of plugin basename
			$file_slug = self::get_plugin_slug( $file );
			if ( $file_slug === $input_slug ) {
				return array(
					'file' => $file,
					'data' => $data,
				);
			}

			// Strategy 3: Handle single-file plugins (no directory)
			// For plugins like "hello.php" in the plugins root directory
			// Input "hello" should match file "hello.php"
			if ( strpos( $file, '/' ) === false && $file === $input_slug . '.php' ) {
				return array(
					'file' => $file,
					'data' => $data,
				);
			}
		}

		// Strategy 4: Case-insensitive substring match on plugin name
		// This is the last resort as it's the least precise
		foreach ( $all_plugins as $file => $data ) {
			if ( stripos( $data['Name'], $identifier ) !== false ) {
				return array(
					'file' => $file,
					'data' => $data,
				);
			}
		}

		return null;
	}

	/**
	 * Discover available error log files.
	 *
	 * Checks multiple locations where PHP/WordPress error logs may exist.
	 * Priority: PHP error_log setting, WP_DEBUG_LOG path, default debug.log, common fallbacks.
	 *
	 * @return array Array of absolute paths to existing, readable log files.
	 * @since     1.0.0
	 */
	public static function discover_log_files() {
		$log_files = array();

		// 1. PHP's configured error log
		$php_error_log = ini_get( 'error_log' );
		if ( ! empty( $php_error_log ) && file_exists( $php_error_log ) ) {
			$log_files[] = $php_error_log;
		}

		// 2. WP_DEBUG_LOG custom path (if string)
		if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && file_exists( WP_DEBUG_LOG ) ) {
			$log_files[] = WP_DEBUG_LOG;
		}

		// 3. Default WordPress debug log
		$wp_debug_log = WP_CONTENT_DIR . '/debug.log';
		if ( file_exists( $wp_debug_log ) ) {
			$log_files[] = $wp_debug_log;
		}

		// 4. Common fallback locations
		$common_locations = array(
			ABSPATH . 'error_log',
			WP_CONTENT_DIR . '/error.log',
		);

		foreach ( $common_locations as $path ) {
			if ( file_exists( $path ) ) {
				$log_files[] = $path;
			}
		}

		return array_values( array_unique( $log_files ) );
	}

	/**
	 * Check if Action Scheduler is available and initialized
	 *
	 * @return bool True if Action Scheduler is ready to use
	 * @since     1.0.0
	 */
	public static function is_action_scheduler_available() {
		return function_exists( 'as_get_scheduled_actions' )
			&& class_exists( 'ActionScheduler' )
			&& \ActionScheduler::is_initialized();
	}

	/**
	 * Get Action Scheduler status and statistics
	 *
	 * @return array Action Scheduler status data
	 * @since     1.0.0
	 */
	public static function get_action_scheduler_status() {
		if ( ! self::is_action_scheduler_available() ) {
			return array( 'available' => false );
		}

		$data = array(
			'available'        => true,
			'version'          => \ActionScheduler_Versions::instance()->latest_version(),
			'counts_by_status' => array(),
		);

		try {
			$store    = \ActionScheduler::store();
			$statuses = array( 'pending', 'in-progress', 'complete', 'failed', 'canceled' );

			foreach ( $statuses as $status ) {
				$data['counts_by_status'][ $status ] = (int) $store->query_actions(
					array(
						'status'   => $status,
						'per_page' => 0,
					),
					'count'
				);
			}
		} catch ( \Throwable $e ) {
			$data['error'] = $e->getMessage();
		}

		return $data;
	}
}
