<?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://agenistrator.com/
 *
 * 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' ) ) {
	exit;
}

/**
 * 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;
	}

	/**
	 * Get directories that tools are allowed to access. These are mainly paths that belong to
	 * the current WordPress instance and the temporary system directory.
	 *
	 * WP_PLUGIN_DIR is included separately because it can be configured outside WP_CONTENT_DIR.
	 *
	 * @return array<string, string> Map of friendly name => absolute directory path.
	 * @since     1.0.1
	 */
	public static function get_allowed_paths() {
		return array(
			'WordPress root'              => ABSPATH,
			'WordPress content directory' => WP_CONTENT_DIR,
			'WordPress plugins directory' => WP_PLUGIN_DIR,
			'System temporary directory'  => get_temp_dir(),
		);
	}

	/**
	 * Get directories that tools are allowed to save files to.
	 *
	 * Restricted per WordPress.org plugin guidelines to the uploads directory
	 * and the system temp directory.
	 *
	 * @return array<string, string> Map of friendly name => absolute directory path.
	 * @since     1.0.1
	 */
	public static function get_allowed_save_paths() {
		$uploads = wp_upload_dir( null, false );
		return array(
			'WordPress uploads directory' => $uploads['basedir'],
			'System temporary directory'  => get_temp_dir(),
		);
	}

	/**
	 * Check if a path is within allowed directories and resolve it.
	 *
	 * 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 ) {
		return self::resolve_within( $path, self::get_allowed_paths() );
	}

	/**
	 * Check if a path is within allowed save-friendly directories and resolve it.
	 *
	 * 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.1
	 */
	public static function is_allowed_save_path( $path ) {
		return self::resolve_within( $path, self::get_allowed_save_paths() );
	}

	/**
	 * Resolve a path and check if it's within any of the given root directories.
	 *
	 * @param string   $path          The file path to check.
	 * @param string[] $allowed_roots List of absolute root directory paths.
	 * @return string|false The resolved absolute path if allowed, or false otherwise.
	 * @since     1.0.1
	 */
	private static function resolve_within( $path, array $allowed_roots ) {
		// 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
		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;
	}

	/**
	 * Check whether a file path resolves inside the current site's uploads directory.
	 *
	 * wp_upload_dir() is per-site in multisite, so this is automatically scoped
	 * to the active blog. Uses PHP_Utils::imaginary_path() to support paths
	 * whose leaf may not yet exist.
	 *
	 * @param string $path Absolute or relative file path.
	 * @return bool True when the resolved path sits inside the uploads basedir.
	 * @since     1.0.1
	 */
	public static function is_in_uploads( $path ) {
		$uploads = wp_upload_dir( null, false );
		if ( ! empty( $uploads['error'] ) ) {
			return false;
		}
		$real_base = realpath( $uploads['basedir'] );
		if ( false === $real_base ) {
			return false;
		}
		$resolved = PHP_Utils::imaginary_path( $path );
		if ( false === $resolved ) {
			return false;
		}
		return PHP_Utils::is_path_within( $resolved, $real_base );
	}

	/**
	 * Get Media Library attachment IDs that intersect a given file or directory path.
	 *
	 * For a file path, matches the main attached file (stored in `_wp_attached_file`
	 * post meta as a uploads-basedir-relative path), sub-sizes that strip off the
	 * `-WxH` suffix (e.g. `image-150x150.jpg` -> `image.jpg`), and scaled originals
	 * whose attached copy is `<name>-scaled.<ext>`; resolution uses a single IN()
	 * query across all candidates and yields at most one ID.
	 *
	 * For a directory path, matches every attachment whose uploads-relative path
	 * begins with the directory (left-anchored LIKE on `_wp_attached_file`).
	 *
	 * @param string $path Absolute file or directory path to look up.
	 * @return int[] Attachment IDs, or empty array if the path is outside uploads
	 *               or does not intersect any attachment.
	 * @since     1.0.1
	 */
	public static function get_attachment_ids_for_path( $path ) {
		$uploads = wp_upload_dir( null, false );
		if ( ! empty( $uploads['error'] ) ) {
			return array();
		}

		$basedir = realpath( $uploads['basedir'] );
		$real    = realpath( $path );
		if ( false === $basedir || false === $real || ! PHP_Utils::is_path_within( $real, $basedir ) ) {
			return array();
		}

		// Meta stores uploads-relative paths with forward slashes on every OS.
		$relative = ltrim( substr( $real, strlen( $basedir ) ), DIRECTORY_SEPARATOR );
		$relative = str_replace( DIRECTORY_SEPARATOR, '/', $relative );

		global $wpdb;

		// Directory: prefix-match every attachment path under it.
		if ( is_dir( $real ) ) {
			$prefix = '' === $relative ? '' : $relative . '/';
			$like   = $wpdb->esc_like( $prefix ) . '%';
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query against postmeta with fixed meta_key; value is passed through wpdb->prepare.
			$ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s",
					$like
				)
			);
			return array_map( 'intval', $ids );
		}

		// File: match main, sub-size (strip -WxH), or -scaled original. Operate on
		// the basename stem so the sub-size regex cannot match in a directory name;
		// mirrors the basename-first idiom used by core in wp_calculate_image_srcset().
		$candidates = array( $relative );
		$ext        = pathinfo( wp_basename( $relative ), PATHINFO_EXTENSION );
		if ( '' !== $ext ) {
			$stem    = pathinfo( wp_basename( $relative ), PATHINFO_FILENAME );
			$dirname = dirname( $relative );
			$dir     = ( '.' === $dirname || '' === $dirname ) ? '' : $dirname . '/';
			$dot_ext = '.' . $ext;

			$without_size = preg_replace( '/-\d+x\d+$/', '', $stem );
			if ( $without_size !== $stem ) {
				$candidates[] = $dir . $without_size . $dot_ext;
			}
			if ( '-scaled' !== substr( $without_size, -7 ) ) {
				$candidates[] = $dir . $without_size . '-scaled' . $dot_ext;
			}
		}

		$placeholders = implode( ',', array_fill( 0, count( $candidates ), '%s' ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query against postmeta with hardcoded meta_key; caching not applicable.
		$id = (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- IN-list placeholders built from fixed-size array of '%s' literals; values passed through prepare() via splat.
				"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attached_file' AND meta_value IN ($placeholders) LIMIT 1",
				...$candidates
			)
		);

		return $id > 0 ? array( $id ) : array();
	}

	/**
	 * Format a map of named paths for display in LLM tool descriptions.
	 *
	 * @param array<string, string> $paths Map of friendly name => absolute directory path.
	 * @return string Comma-separated "Friendly Name (absolute path)" entries.
	 * @since     1.0.1
	 */
	public static function format_paths_description( array $paths ) {
		$parts = array();
		foreach ( $paths as $name => $path ) {
			$parts[] = $name . ' (' . rtrim( $path, DIRECTORY_SEPARATOR ) . ')';
		}

		return implode( ', ', $parts );
	}

	/**
	 * 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() ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output
			throw new \RuntimeException( __( 'Failed to initialize WP_Filesystem', 'agenistrator' ) );
		}

		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;
	}
}
