<?php
/**
 * PHP Utilities.
 *
 * Provides utility methods for PHP runtime configuration, environment,
 * and code introspection.
 *
 * @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;
}

/**
 * PHP_Utils class.
 */
class PHP_Utils {

	/**
	 * Timestamp when the current request started.
	 *
	 * @var int|null
	 */
	private static $request_start_time = null;

	/**
	 * Initialize the request start time.
	 *
	 * Should be called as early as possible in the plugin lifecycle,
	 * typically right after the autoloader is registered.
	 * @since     1.0.0
	 */
	public static function init_request_start_time() {
		if ( null === self::$request_start_time ) {
			self::$request_start_time = time();
		}
	}

	/**
	 * Get the elapsed time since the request started.
	 *
	 * @return int Elapsed time in seconds.
	 */
	private static function get_elapsed_time() {
		if ( null === self::$request_start_time ) {
			self::init_request_start_time();
			return 0;
		}
		return time() - self::$request_start_time;
	}

	/**
	 * Get available execution time for the current request.
	 *
	 * Calculates a portion of the configured max_execution_time, clamped
	 * between minimum and maximum bounds, minus time already elapsed in the
	 * current request. Useful for long-running operations that need to exit
	 * gracefully before PHP's hard timeout.
	 *
	 * @param float $duty_cycle Fraction of max_execution_time to use (0.0-1.0).
	 * @param int   $min        Minimum time limit in seconds.
	 * @param int   $max        Maximum time limit in seconds.
	 * @return int Available execution time in seconds.
	 * @since     1.0.0
	 */
	public static function get_available_execution_time( $duty_cycle = 0.8, $min = 15, $max = 300 ) {
		$max_execution_time = (int) ini_get( 'max_execution_time' );

		// If unlimited (0), assume a reasonable default
		if ( 0 === $max_execution_time ) {
			$max_execution_time = 300;
		}

		$elapsed   = self::get_elapsed_time();
		$safe_time = (int) ( $max_execution_time * $duty_cycle ) - $elapsed;

		return max( $min, min( $max, $safe_time ) );
	}

	/**
	 * Get a human-readable name for a callback.
	 *
	 * @param mixed $callback The callback to analyze.
	 * @return string The callback name (e.g., "function_name", "Class::method", or "Closure").
	 * @since     1.0.0
	 */
	public static function get_callback_name( $callback ) {
		if ( is_string( $callback ) ) {
			return $callback;
		}

		if ( is_array( $callback ) && count( $callback ) === 2 ) {
			if ( is_object( $callback[0] ) ) {
				return get_class( $callback[0] ) . '::' . $callback[1];
			} elseif ( is_string( $callback[0] ) ) {
				return $callback[0] . '::' . $callback[1];
			}
		}

		if ( $callback instanceof \Closure ) {
			return 'Closure';
		}

		return 'Unknown';
	}

	/**
	 * Get a reflection object for a callback.
	 *
	 * @param mixed $callback The callback to reflect on.
	 * @return \ReflectionFunctionAbstract|null The reflection object, or null if reflection failed.
	 * @since     1.0.0
	 */
	public static function get_callback_reflection( $callback ) {
		try {
			if ( is_string( $callback ) && function_exists( $callback ) ) {
				return new \ReflectionFunction( $callback );
			}

			if ( is_array( $callback ) && count( $callback ) === 2 ) {
				if ( is_object( $callback[0] ) ) {
					return new \ReflectionMethod( $callback[0], $callback[1] );
				} elseif ( is_string( $callback[0] ) && method_exists( $callback[0], $callback[1] ) ) {
					return new \ReflectionMethod( $callback[0], $callback[1] );
				}
			}

			if ( $callback instanceof \Closure ) {
				return new \ReflectionFunction( $callback );
			}
		} catch ( \ReflectionException $e ) {
			return null;
		}

		return null;
	}

	/**
	 * Validate PHP syntax without executing the code.
	 *
	 * Uses token_get_all() to parse PHP code and detect syntax errors.
	 * This is safer than eval() as it does not execute the code.
	 *
	 * @param string $code The PHP code to validate.
	 * @return true|string Returns true if syntax is valid, or error message string if invalid.
	 * @since     1.0.0
	 */
	public static function validate_php_syntax( $code ) {
		try {
			// Attempt to tokenize the PHP code
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: suppresses warnings for unclosed comments during syntax validation
			$tokens = @token_get_all( $code );

			// Empty token array indicates parsing failure
			if ( empty( $tokens ) ) {
				return 'Failed to parse PHP code';
			}

			return true;
		} catch ( \ParseError $e ) {
			// PHP 7+ throws ParseError for syntax errors
			return $e->getMessage();
		}
	}

	/**
	 * Canonicalize a path without filesystem access.
	 *
	 * Performs string-based path normalization:
	 * - Uses DIRECTORY_SEPARATOR consistently
	 * - On Windows: normalizes forward slashes to backslashes (both are valid separators)
	 * - On Linux/Unix: only treats forward slash as separator (backslash is a valid filename character)
	 * - Resolves . and .. segments
	 * - Removes redundant separators
	 * - Preserves leading .. for relative paths
	 *
	 * @param string $path The path to canonicalize.
	 * @return string The canonicalized path.
	 * @since     1.0.0
	 */
	public static function canonicalize_path( $path ) {
		if ( '' === $path ) {
			return '';
		}

		// On Windows, normalize forward slashes to backslashes (both are valid separators)
		// On Linux/Unix, leave backslashes alone (they're valid filename characters)
		if ( '\\' === DIRECTORY_SEPARATOR ) {
			$path = str_replace( '/', '\\', $path );
		}

		// Check for Windows drive letter (e.g., C:\)
		$prefix = '';
		if ( preg_match( '/^([a-zA-Z]):/', $path, $matches ) ) {
			$prefix = strtoupper( $matches[1] ) . ':';
			$path   = substr( $path, 2 );
		}

		// Check if path is absolute
		$is_absolute = ( DIRECTORY_SEPARATOR === ( $path[0] ?? '' ) );

		// Split into segments using DIRECTORY_SEPARATOR and process
		$segments = explode( DIRECTORY_SEPARATOR, $path );
		$result   = array();

		foreach ( $segments as $segment ) {
			if ( '' === $segment || '.' === $segment ) {
				// Skip empty segments and current directory references
				continue;
			}

			if ( '..' === $segment ) {
				// Go up one directory
				if ( count( $result ) > 0 && '..' !== end( $result ) ) {
					array_pop( $result );
				} elseif ( ! $is_absolute && '' === $prefix ) {
					// Preserve leading .. for relative paths (only if no drive letter)
					$result[] = '..';
				}
				// For absolute paths, .. at root is silently ignored
			} else {
				$result[] = $segment;
			}
		}

		// Reconstruct the path using DIRECTORY_SEPARATOR
		$canonical = implode( DIRECTORY_SEPARATOR, $result );

		// Add prefix for absolute paths or drive letters
		if ( '' !== $prefix ) {
			// Only add separator after drive letter if path was rooted (e.g., C:\path vs C:path)
			$canonical = $is_absolute
				? $prefix . DIRECTORY_SEPARATOR . $canonical
				: $prefix . $canonical;
		} elseif ( $is_absolute ) {
			$canonical = DIRECTORY_SEPARATOR . $canonical;
		}

		// Handle edge case: empty result for root or drive-only paths
		if ( '' === $canonical && '' !== $prefix ) {
			// C:\ -> C:\, but C: -> C:
			$canonical = $is_absolute ? $prefix . DIRECTORY_SEPARATOR : $prefix;
		} elseif ( '' === $canonical && $is_absolute ) {
			$canonical = DIRECTORY_SEPARATOR;
		}

		return $canonical;
	}

	/**
	 * Resolve a path that may not fully exist on the filesystem.
	 *
	 * Like realpath(), but works with paths where only a prefix exists.
	 * Finds the longest existing prefix, resolves it with realpath(),
	 * then appends the canonicalized non-existent suffix.
	 *
	 * @param string $path The path to resolve.
	 * @return string|false The resolved absolute path, or false if no existing prefix found.
	 * @since     1.0.0
	 */
	public static function imaginary_path( $path ) {
		// Canonicalize the input path first (string-based)
		$canonical = self::canonicalize_path( $path );

		if ( '' === $canonical ) {
			return false;
		}

		// If the full path exists, use realpath directly
		$real = realpath( $canonical );
		if ( false !== $real ) {
			return $real;
		}

		// Find the longest existing prefix
		$check_path       = $canonical;
		$non_existent     = array();
		$existing_checked = false;

		while ( '' !== $check_path && DIRECTORY_SEPARATOR !== $check_path ) {
			$real = realpath( $check_path );
			if ( false !== $real ) {
				$existing_checked = true;
				break;
			}

			// Move the last segment to non-existent parts
			$non_existent[] = basename( $check_path );
			$parent         = dirname( $check_path );

			// Prevent infinite loop
			if ( $parent === $check_path ) {
				break;
			}
			$check_path = $parent;
		}

		// If no existing prefix found, path is invalid
		if ( ! $existing_checked || false === $real ) {
			return false;
		}

		// Rebuild the non-existent suffix (reversed because we collected bottom-up)
		$non_existent = array_reverse( $non_existent );

		// Build the final path
		$suffix = implode( DIRECTORY_SEPARATOR, $non_existent );
		$result = '' !== $suffix ? $real . DIRECTORY_SEPARATOR . $suffix : $real;

		return $result;
	}

	/**
	 * Check if a path is within a root directory.
	 *
	 * Both paths should be resolved absolute paths (via realpath() or imaginary_path()).
	 *
	 * @param string $path The resolved absolute path to check.
	 * @param string $root The resolved absolute root directory path.
	 * @return bool True if path is within root (or is root itself), false otherwise.
	 * @since     1.0.0
	 */
	public static function is_path_within( $path, $root ) {
		$root_prefix = rtrim( $root, DIRECTORY_SEPARATOR ) . DIRECTORY_SEPARATOR;

		return $path === $root || strncmp( $path, $root_prefix, strlen( $root_prefix ) ) === 0;
	}
}
