<?php
/**
 * Class CallbackReflection.
 *
 * @package AmpProject\AmpWP
 */

namespace AmpProject\AmpWP\DevTools;

use AMP_Validation_Callback_Wrapper;
use AmpProject\AmpWP\Infrastructure\Service;
use Exception;
use ReflectionFunction;
use ReflectionMethod;
use Reflector;

/**
 * Reflect on a file to deduce its type of source (plugin, theme, core).
 *
 * @package AmpProject\AmpWP
 * @since 2.0.2
 * @internal
 */
final class CallbackReflection implements Service {

	/**
	 * File reflection instance to use.
	 *
	 * @var FileReflection
	 */
	private $file_reflection;

	/**
	 * CallbackReflection constructor.
	 *
	 * @param FileReflection $file_reflection File reflector to use.
	 */
	public function __construct( FileReflection $file_reflection ) {
		$this->file_reflection = $file_reflection;
	}

	/**
	 * Get the underlying callback in case it was wrapped by AMP_Validation_Callback_Wrapper.
	 *
	 * @since 2.2.1
	 *
	 * @param callable $callback Callback.
	 * @return callable Original callback.
	 */
	public function get_unwrapped_callback( $callback ) {
		while ( $callback ) {
			if ( $callback instanceof AMP_Validation_Callback_Wrapper ) {
				$callback = $callback->get_callback_function();
			} elseif (
				is_array( $callback )
				&&
				is_callable( $callback )
				&&
				isset( $callback[0], $callback[1] )
				&&
				$callback[0] instanceof AMP_Validation_Callback_Wrapper
				&&
				'invoke_with_first_ref_arg' === $callback[1]
			) {
				$callback = $callback[0]->get_callback_function();
			} else {
				break;
			}
		}
		return $callback;
	}

	/**
	 * Gets the plugin or theme of the callback, if one exists.
	 *
	 * @param string|array|callable $callback The callback for which to get the
	 *                                        plugin.
	 * @return array|null {
	 *     The source data.
	 *
	 *     @type string    $type       Source type (core, plugin, mu-plugin, or theme).
	 *     @type string    $name       Source name.
	 *     @type string    $file       Relative file path based on the type.
	 *     @type string    $function   Normalized function name.
	 *     @type Reflector $reflection Reflection.
	 * }
	 */
	public function get_source( $callback ) {
		$callback = $this->get_unwrapped_callback( $callback );

		$reflection = $this->get_reflection( $callback );

		if ( ! $reflection ) {
			return null;
		}

		$source = [ 'reflection' => $reflection ];

		$file   = wp_normalize_path( $reflection->getFileName() );
		$source = array_merge(
			$source,
			$this->file_reflection->get_file_source( $file )
		);

		// If a file was identified, then also supply the line number.
		if ( isset( $source['file'] ) ) {
			$source['line'] = $reflection->getStartLine();
		}

		if ( $reflection instanceof ReflectionMethod ) {
			$source['function'] = $reflection->getDeclaringClass()->getName() . '::' . $reflection->getName();
		} else {
			$function_name = $reflection->getName();

			/*
			 * In PHP 8.4, a closure's string representation changes from {closure} to include the function that contained
			 * the closure, like {closure:Test_AMP_Validation_Manager::test_decorate_shortcode_and_filter_source():1831}.
			 * It can even indicate closure nesting like {closure:{closure:Test_AMP_Validation_Manager::get_locate_sources_data():883}:886}.
			 * So these are normalized here.
			 */
			$function_name = preg_replace(
				'/\{closure.*}/',
				'{closure}',
				$function_name
			);

			/*
			 * Additionally, in PHP 8.4 the namespace no longer prefixes the closure. So this is now removed
			 * for consistency across all PHP versions, replacing AmpProject\AmpWP\Tests\DevTools\{closure}
			 * with just {closure}.
			 */
			$function_name = preg_replace(
				'/(\w+\\\\)+(?=\{)/',
				'',
				$function_name
			);

			$source['function'] = $function_name;
		}

		return $source;
	}

	/**
	 * Get the reflection object for the callback.
	 *
	 * @param string|array|callable $callback The callback for which to get the
	 *                                        plugin.
	 * @return ReflectionMethod|ReflectionFunction|null
	 */
	private function get_reflection( $callback ) {
		try {
			if ( is_string( $callback ) && is_callable( $callback ) ) {
				// The $callback is a function or static method.
				$exploded_callback = explode( '::', $callback, 2 );

				if ( 2 !== count( $exploded_callback ) ) {
					return new ReflectionFunction( $callback );
				}

				// Since identified as method, handle as ReflectionMethod below.
				$callback = $exploded_callback;
			}

			if (
				is_array( $callback )
				&&
				isset( $callback[0], $callback[1] )
				&&
				method_exists( $callback[0], $callback[1] )
			) {
				$reflection = new ReflectionMethod( $callback[0], $callback[1] );

				// Handle the special case of the class being a widget, in which
				// case the display_callback method should actually map to the
				// underling widget method. It is the display_callback in the
				// end that is wrapped.
				if (
					'display_callback' === $reflection->getName()
					&&
					'WP_Widget' === $reflection->getDeclaringClass()->getName()
				) {
					return new ReflectionMethod( $callback[0], 'widget' );
				}

				return $reflection;
			}

			if (
				is_object( $callback )
				&&
				'Closure' === get_class( $callback )
			) {
				return new ReflectionFunction( $callback );
			}
		} catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Don't let exceptions through here.
		}

		return null;
	}
}
