<?php
/**
 * @brief		Scanner Class (Matt F)
 * @since		Aug 2022
 */

namespace IPS\Application;

/* To prevent PHP errors (extending class does not exist) revealing path */

use Jose\Component\Console\P12CertificateLoaderCommand;

if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * @brief	Class for scanning apps and plugins to make sure they won't break everything (specifically on PHP8).
 */
class _Scanner extends \stdClass
{
	/**
	 * @brief static cache for getMethods
	 */
	private static $methodInfoCache = array();

	/**
	 * @brief static cache for getClassDetails
	 */
	private static $classDetailsCache = array();

	/**
	 * @brief   The default $options parameter for the scan methods
	 */
	private static $defaultScannerOptions = array(
		"enabledOnly" => true,
		"dirsToCheck" => null
	);

	/**
	 * Scan all the methods in hooks installed on a site to make sure they're declared in a php8 compatible way
	 *
	 * @param   bool        $getDetails     Return the details of every hook and base class loaded? This will make the result SIGNIFICANTLY larger so leave false unless you need it.
	 * @param   bool        $shallowCheck   Return a bool instead that indicates whether discrepancies were found. Potentially faster since it returns at the first discrepancy
	 * @param   int         $limit          The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
	 * @param   int         $hardLimit      A hard limit for the parent class search and filesystem search... those are self-propagating and can go for quite a while. Pass -1 at your own risk to make them boundless
	 * @param   array       $options        Options;
	 * @example
	 * array(
	 *      "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
	 *      "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
	 * )
	 *
	 *
	 * @return  bool|array[]     By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
	 */
	final public static function scanExtendedClasses( bool $getDetails=false, bool$shallowCheck=false, int $limit=500, int $hardLimit=5000, ?array $options=NULL )
	{
		$details = array();
		$issues = array();
		if ( empty( $options ) OR !\is_array( $options ) )
		{
			$options = static::$defaultScannerOptions;
		}

		$appDirsToCheck = isset( $options['dirsToCheck']['apps'] ) ? $options['dirsToCheck']['apps'] : ( isset( $options['dirsToCheck']['plugins'] ) ? array() : null );
		$pluginDirsToCheck = isset( $options['dirsToCheck']['plugins'] ) ? $options['dirsToCheck']['plugins'] : ( isset( $options['dirsToCheck']['apps'] ) ? array() : null );
		$enabledOnly = $options['enabledOnly'] ?? static::$defaultScannerOptions['enabledOnly'];


		/* Iterate through all app & plugin directories */
		$excludeDirs = [ 'hooks', 'setup', 'dev', 'sources\/vendor' ];
		$classesToLoad = [];
		$nodes = array();
		$appAndPluginClasses = array();
		foreach ( ( $enabledOnly ? \IPS\Application::enabledApplications() : \IPS\Application::applications() ) as $key => $app )
		{
			/* Skip it if we don't care */
			if (
				( !\is_array( $appDirsToCheck ) AND \in_array( $app->directory, \IPS\IPS::$ipsApps ) ) OR
				( \is_array( $appDirsToCheck ) AND !\in_array( $app->directory, $appDirsToCheck ) )
			)
			{
				continue;
			}

			/* Create a tree like array of class inheritance; each node is indexed by its name and contains. Note this is not a node object tree but a relational tree
			array(
				"root" => "\IPS\<ipsApp|SystemClass>\...",
				"parent" => "\IPS\<customApp|plugins>\...",
				"method_details" => array(...)
			)
			 */

			/* For each customization, get all the dirs recursively; find any php file that could break things */
			$dir = ( ( \defined( 'IPS\CIC2' ) and \IPS\CIC2 ) ? \IPS\SITE_FILES_PATH : \IPS\ROOT_PATH ) . '/applications/' . $app->directory;
			if ( !\is_dir( $dir ) )
			{
				static::log( "During the customization compatibility check, the app {$app->directory} did not have a valid directory and was skipped" );
				continue;
			}

			if ( file_exists( $dir . '/Application.php' ) )
			{
				$contents = file_get_contents( $dir . '/Application.php' );
				$methods = static::getMethods( $contents );
				$classDetails = static::getClassDetails( $contents );
				if ( $classDetails['globalClassName'] )
				{
					$classDetails['isApp'] = true;
					$classDetails['appOrPlugin'] = $key;
					$classDetails['filepath'] = $dir . '/Application.php';
					$classDetails['isIps'] = false;
					$classDetails['priority'] = $enabledOnly ? true : $app->enabled;
					$nodes[$classDetails['globalClassName']] = $classDetails;
					if ( $getDetails )
					{
						$details[$dir . '/Application.php'] = $classDetails['methods'];
					}

					if ( $classDetails['parentClass'] )
					{
						$appAndPluginClasses[$classDetails['globalClassName']] = 1;
						$classesToLoad[$classDetails['parentClass']] = 1;
					}
				}
			}

			$directory = new \RecursiveDirectoryIterator($dir);
			$iterator = new \RecursiveIteratorIterator($directory);
			$skips =  implode( '|', $excludeDirs );
			$skipPattern = '/' . preg_quote( $dir, '/' ) . '\\/(?:' . $skips . ')/';

			$i = 0;
			foreach ( $iterator as $info )
			{
				if ( !( $info instanceof \SplFileInfo and $info->isFile() ) )
				{
					continue;
				}
				$pathname = $info->getPathname();

				/* We did this already */
				if ( $pathname === ($dir . '/Application.php') )
				{
					continue;
				}

				/* Don't care about non-php files */
				if ( \mb_strtolower( $info->getExtension() ) !== 'php' )
				{
					continue;
				}


				if ( $hardLimit !== -1 and $i++ >= $hardLimit )
				{
					static::log( "Some PHP classes may not have been scanned for the app {$app->directory} since the filesystem search's hard limit of {$hardLimit} was reached." );
					break;
				}

				/* Don't care about these directories */
				if ( preg_match( $skipPattern, $pathname ) )
				{
					continue;
				}

				$contents = \file_get_contents( $pathname );
				$classDetails = static::getClassDetails( $contents );
				if ( $classDetails['globalClassName'] )
				{
					$classDetails['isApp'] = true;
					$classDetails['appOrPlugin'] = $key;
					$classDetails['filepath'] = $pathname;
					$classDetails['isIps'] = false;
					$classDetails['priority'] = $enabledOnly ? true : $app->enabled;
					$nodes[$classDetails['globalClassName']] = $classDetails;

					if ( $getDetails )
					{
						$details[$pathname] = $classDetails['methods'];
					}

					if ( $classDetails['parentClass'] )
					{
						$appAndPluginClasses[$classDetails['globalClassName']] = 1;
						$classesToLoad[$classDetails['parentClass']] = 1;
					}
				}
			}
		}

		/* Now for the plugins */
		foreach ( ( $enabledOnly ? \IPS\Plugin::enabledPlugins() : \IPS\Plugin::plugins() ) as $key => $plugin )
		{
			if ( \is_array( $pluginDirsToCheck ) AND !\in_array( $plugin->location, $pluginDirsToCheck ) )
			{
				continue;
			}

			$dir = ( ( \defined('\IPS\IN_DEV' ) AND \IPS\IN_DEV ) ? \IPS\ROOT_PATH : \IPS\SITE_FILES_PATH ) . '/plugins/' . $plugin->location;

			if ( !( @is_dir( $dir ) ) )
			{
				$message = "Couldn't load the files for the plugin '{$plugin->location}'. '{$dir}' is not an accessible directory";
				static::log( $message );
				continue;
			}

			$dirs = [ $dir . '/tasks', $dir . '/widgets' ];

			$pluginLimit = 200; /* We'll stop at 200 php files... more than that is suspicious */
			$i = 0;
			foreach ( $dirs as $dirToCheck )
			{
				if ( !( \file_exists( $dirToCheck ) AND \is_dir( $dirToCheck ) ) )
				{
					continue;
				}

				$directory = new \RecursiveDirectoryIterator( $dirToCheck );
				foreach ( new \RecursiveIteratorIterator( $directory ) as $info )
				{
					if ( !( $info instanceof \SplFileInfo AND $info->isFile() ) )
					{
						continue;
					}

					$pathname = $info->getPathname();
					if ( \strtolower( $info->getExtension() ) !== 'php' )
					{
						continue;
					}

					if ( $i >= $pluginLimit or ( $hardLimit !== -1 and $i >= $hardLimit ) )
					{
						$actualLimit = $hardLimit === -1 ? $pluginLimit : min( $pluginLimit, $hardLimit );
						static::log( "Some PHP Files may not have been checked for the plugin {$plugin->location}. It contains at least {$actualLimit} PHP files in the widgets and tasks subdirectories." );
						break;
					}
					$i++;

					$contents = \file_get_contents( $pathname );
					$classDetails = static::getClassDetails( $contents );
					if ( $classDetails['globalClassName'] )
					{
						$classDetails['isApp'] = false;
						$classDetails['appOrPlugin'] = $key;
						$classDetails['filepath'] = $pathname;
						$classDetails['isIps'] = false;
						$classDetails['priority'] = $enabledOnly ? true : ( $plugin->enabled );
						$nodes[$classDetails['globalClassName']] = $classDetails;

						if ( $getDetails )
						{
							$details[$pathname] = $classDetails['methods'];
						}

						if ( $classDetails['parentClass'] )
						{
							$appAndPluginClasses[$classDetails['globalClassName']] = 1;
							$classesToLoad[$classDetails['parentClass']] = 1;
						}
					}
				}

				if ( $limit <= 0 )
				{
					break;
				}
			}
		}

		if ( !empty( $appAndPluginClasses ) )
		{
			/* Now load until we've traced back to oob IPS classes (ips apps and system) */
			$i = 0;
			$classesToLoad = array_keys( $classesToLoad );
			while ( $classToLoad = array_shift( $classesToLoad ) )
			{
				if ( isset( $nodes[$classToLoad] ) )
				{
					continue;
				}

				/* Determine the path */
				$filename = '';
				$appOrPlugin = null;
				$isApp = false;
				$isIps = false;
				$bits = explode( '\\', ltrim( $classToLoad, '\\' ) );

				/* If this doesn't belong to us, try a PSR-0 loader or ignore it */
				$vendorName = array_shift( $bits );
				if( $vendorName !== 'IPS' )
				{
					continue;
				}

				$baseClassName = array_pop( $bits );

				/* Locate file */
				$sourcesDirSet = FALSE;
				foreach ( array_merge( $bits, array( $baseClassName ) ) as $i => $bit )
				{
					if( preg_match( "/^[a-z0-9]/", $bit ) )
					{
						if( $i === 0 )
						{
							if ( \in_array( $bit, \IPS\IPS::$ipsApps ) )
							{
								$isIps = true;
							}

							if ( \IPS\CIC2 AND !\in_array( $bit, \IPS\IPS::$ipsApps ) )
							{
								$filename .= \IPS\SITE_FILES_PATH . '/applications/'; // Applications are in the root on Cloud2
							}
							else
							{
								$filename .= \IPS\ROOT_PATH . '/applications/';
							}
						}
						else
						{
							$sourcesDirSet = TRUE;
						}
					}
					elseif ( $i === 3 and $bit === 'Upgrade' )
					{
						$bit = mb_strtolower( $bit );
					}
					elseif( $sourcesDirSet === FALSE )
					{
						if( $i === 0 )
						{
							$isIps = true;
							$filename .= \IPS\ROOT_PATH . '/system/';
						}
						elseif ( $i === 1 and $bit === 'Application' )
						{
							// do nothing
						}
						else
						{
							$filename .= 'sources/';
						}
						$sourcesDirSet = TRUE;
					}

					$filename .= "{$bit}/";
				}

				/* Load it */
				$filename = \substr( $filename, 0, -1 ) . '.php';

				if ( !\file_exists( $filename ) )
				{
					$filename = \substr( $filename, 0, -4 ) . \substr( $filename, \strrpos( $filename, '/' ) );
					if ( !file_exists( $filename ) )
					{
						static::log( "A class in an app extends the class {$classToLoad} for which no file {$filename} exists! The method scanner skipped comparing that file to any of its subclasses" );
						continue;
					}
				}

				$contents = \file_get_contents( $filename );
				$classDetails = static::getClassDetails( $contents );
				if ( !$classDetails['globalClassName'] )
				{
					static::log( "A class in an app extends the class {$classToLoad} for which no valid class file can be found (checked {$filename}). The method scanner skipped comparing that file to any of its subclasses" );
					continue;
				}

				$classDetails['isApp'] = $isApp;
				$classDetails['appOrPlugin'] = $appOrPlugin;
				$classDetails['filepath'] = $filename;
				$classDetails['isIps'] = $isIps;
				$classDetails['priority'] = false;
				$nodes[$classDetails['globalClassName']] = $classDetails;


				/* Add the parent to the classes to load if we still haven't reached our own IPS code; we're not going to trudge through IPS code */
				if ( !$isIps AND $classDetails['parentClass'] )
				{
					$classesToLoad[] = $classDetails['parentClass'];
				}

				if ( $hardLimit !== -1 AND $i++ >= $hardLimit )
				{
					static::log( "The Extended Class Compatibility scanner ran but may have been inaccurate. Not all parent classes could be loaded since the hard limit of {$hardLimit} was reached." );
					break;
				}
			}

			/* Now we have the inheritance tree, compare extended classes from the app classes */
			foreach ( $appAndPluginClasses as $appOrPluginClass => $var )
			{
				$classDetails = $nodes[$appOrPluginClass];
				$appOrPlugin = '';
				if ( @$classDetails['appOrPlugin'] )
				{
					$appOrPlugin = ( @$classDetails['isApp'] ) ? 'App - ' : 'Plugin - ';
					$appOrPlugin .= $classDetails['appOrPlugin'];
				}
				else
				{
					static::log( "Something went wrong when evaluating the class {$classDetails['globalClassName']}." );
					continue;
				}
				if ( $parentClass = @$classDetails['parentClass'] )
				{
					if ( $parentClassDetails = @$nodes[$parentClass] )
					{
						$priority = $classDetails['priority'] ?? 1;
						$methodIssues = static::compareMethodsForIssues(
							$parentClassDetails['methods'],
							$classDetails['methods'],
							$parentClassDetails['filepath'],
							$classDetails['filepath'],
							$parentClassDetails['globalClassName'],
							$classDetails['globalClassName'],
							$shallowCheck,
							$limit,
							$priority
						);

						if ( !empty( $methodIssues ) )
						{

							if ( $shallowCheck )
							{
								if ( $priority or !( $options['shallowCheckPriorityOnly'] ?? 1 ) )
								{
									return true;
								}
							}

							if ( $limit !== -1 and \is_countable( $methodIssues ) )
							{
								$limit -= \count( $methodIssues );
							}

							$issues[ $appOrPlugin ] = $issues[$appOrPlugin] ?? [];
							$issues[ $appOrPlugin ][ $classDetails['filepath'] ] = is_array( $methodIssues ) ? array_merge( $issues[ $appOrPlugin ][ $classDetails['filepath'] ] ?? [], $methodIssues ): $issues[ $appOrPlugin ][ $classDetails['filepath'] ];
						}
					}
					else
					{
						static::log( "The class {$appOrPluginClass} extends {$parentClass}, but the parent class couldn't be loaded." );
					}
				}
				else
				{
					static::log( "The class {$appOrPluginClass} was queued to be compared to its parent class, but no parent class was found" );
				}
			}
		}

		/* return issues */
		if ( $shallowCheck )
		{
			return empty( $issues ) ? null : false; // if we got an issue but haven't returned with shallow check, none of the issues are priority
		}

		return $getDetails ? [ $issues, $details ] : [ $issues ];
	}


	/**
	 * Compare 2 sets of methods from a subclass and its parent, and returns all the issues it could find with how the methods are declared
	 *
	 * @see PHP8 uses the Liskov Substitution Principle (LSP) to determine method compatibility https://www.php.net/manual/en/language.oop5.basic.php#language.oop.lsp
	 *
	 * @param   array[]         $baseMethods        The Methods from the parent/base class
	 * @param   array[]         $subclassMethods    The Methods from the child class
	 * @param   string          $baseFile           The path to the base/parent file; will trim out \IPS\ROOT_PATH and SITE_FILES_PATH if it starts with it
	 * @param   string          $subclassFile       The path to the subclass file; will trim out \IPS\ROOT_PATH and SITE_FILES_PATH if it starts with it
	 * @param   string          $baseClassName      The name of the base class being evaluated; necessary to render all the context details about the issue
	 * @param   string          $subclassName       The name of the extending subclass being evaluated; necessary to render all the context details about the issue
	 * @param   bool            $shallowCheck       Perform a lightweight shallow check? If this is true, the method returns a bool, and will return TRUE as soon as it comes across the first issue
	 * @param   int             $limit              The limit of the issues; set -1 to run without a limit. This is important when scanning all files across entire scaled communities
	 * @param   bool            $isHook             Is the subclass a hook? If so, will set the 'class' property of the output to the base class instead of the subclass
	 * @param   bool            $isPriority         Are these issues going to be considered priority? If so that's added here to prevent memory issues adding manually later
	 *
	 * @return bool|array
	 */
	final public static function compareMethodsForIssues( $baseMethods, $subclassMethods, $baseFile, $subclassFile, $baseClassName, $subclassName, $shallowCheck=false, $limit=500, $isHook=false, $isPriority=false )
	{
		/* For the file paths, remove the root or site files path for security and readability; anyone who can do something about these files knows what those are */
		$searches = [\IPS\ROOT_PATH];
		if ( \defined( '\IPS\SITE_FILES_PATH' ) )
		{
			$searches[] = \IPS\SITE_FILES_PATH;
		}
		foreach ( ['baseFile', 'subclassFile'] as $arg )
		{
			foreach ( $searches as $const )
			{
				if ( \mb_substr( $$arg, 0, \mb_strlen( $const ) ) === $const )
				{
					$$arg = \mb_substr( $$arg, \mb_strlen( $const ) );
				}
			}
		}

		/* This is what we're looking for */
		$issues = array();
		$methodFieldMap = array(
			'security'      => 'method_issue_security',
			'static'        => 'method_issue_static',
			'returnType'    => 'method_issue_return_type'
		);

		$parameterFieldMap = array(
			"type"                  => "method_issue_parameter_type",
			"nullable"              => "method_issue_nullable",
			"passedByReference"     => 'method_issue_reference',
			"packed"                => "method_issue_packed"
		);

		/* Only compare overlapping methods */
		$overloaded = array_intersect( array_keys( $baseMethods ), array_keys( $subclassMethods ) );
		$count = 0;
		foreach ( $overloaded as $method )
		{
			$baseMethod = $baseMethods[$method];
			$subclassMethod = $subclassMethods[$method];

			/* According to the Liskov Substitution Principle, constructors and private methods are exempt */
			if ( $method === '__construct' OR ( $baseMethod['security'] === 'private' and $subclassMethod['security'] === 'private' ) )
			{
				continue;
			}

			foreach ( $methodFieldMap as $field => $reason )
			{
				/* A protected base class method can be made public in a subclass */
				if ( $field === 'security' and ( $baseMethod['security'] === 'protected' and $subclassMethod['security'] === 'public' ) )
				{
					continue;
				}

				/* A subclass can add a return type hint if the base class does not have one */
				if ( $field === 'returnType' and ( ! $baseMethod[$field] and $subclassMethod[$field] ) )
				{
					continue;
				}

				if ( $baseMethod[$field] !== $subclassMethod[$field] )
				{
					if ( $shallowCheck )
					{
						return true;
					}

					$issues[] = array(
						"method"            => $method,
						"reason"            => $reason,
						"parameter"         => null,
						"subclassFile"      => $subclassFile,
						"baseFile"          => $baseFile,
						"baseClass"         => $baseClassName,
						"subclassMethod"    => $subclassMethod,
						"subclassName"      => $subclassName,
						"baseMethod"        => $baseMethod,
						"class"             => $isHook ? $baseClassName : $subclassName,
						'priority'          => $isPriority
					);

					if ( $limit !== -1 AND ++$count >= $limit )
					{
						break;
					}
				}
			}

			if ( $baseMethod['final'] )
			{
				if ( $shallowCheck )
				{
					return true;
				}

				$issues[] = array(
					"method"            => $method,
					"reason"            => "method_issue_final",
					"parameter"         => null,
					"subclassFile"      => $subclassFile,
					"baseFile"          => $baseFile,
					"baseClass"         => $baseClassName,
					"subclassMethod"    => $subclassMethod,
					"subclassName"      => $subclassName,
					"baseMethod"        => $baseMethod,
					"class"             => $isHook ? $baseClassName : $subclassName,
					'priority'          => $isPriority
				);

				if ( $limit !== -1 AND ++$count >= $limit )
				{
					break;
				}
			}

			/* What are our parameters */
			if ( \count( $baseMethod['parameters'] ) or \count( $subclassMethod['parameters'] ) )
			{
				/* The subclass param names can be different from the base class, but must be the same type, nullable, etc otherwise */
				$pass = true;
				$baseParamsByIndex = array_values( $baseMethod['parameters'] );
				$subclassParamsByIndex = array_values( $subclassMethod['parameters'] );

				/* The extended class has fewer params than the base class */
				if ( \count( $baseParamsByIndex ) > 0 and ( \count( $baseParamsByIndex ) > \count( $subclassParamsByIndex ) ) )
				{
					$pass = false;
				}
				else if ( \count( $baseParamsByIndex ) > 0 )
				{
					foreach ( range( 0, \count( $baseParamsByIndex ) - 1 ) as $i )
					{
						foreach( $baseParamsByIndex[$i] as $key => $value )
						{
							if ( $key !== 'name' )
							{
								if ( $baseParamsByIndex[$i][ $key ] != $subclassParamsByIndex[$i][ $key ] )
								{
									$pass = false;
									break;
								}
							}
						}
					}
				}

				if ( $pass === false )
				{
					if ( $shallowCheck )
					{
						return true;
					}

					$issues[] = array(
						"method"            => $method,
						"reason"            => "method_issue_parameters",
						"parameter"         => null,
						"subclassFile"      => $subclassFile,
						"baseFile"          => $baseFile,
						"baseClass"         => $baseClassName,
						"subclassMethod"    => $subclassMethod,
						"subclassName"      => $subclassName,
						"baseMethod"        => $baseMethod,
						"class"             => $isHook ? $baseClassName : $subclassName,
						'priority'          => $isPriority
					);

					if ( $limit !== -1 AND ++$count >= $limit )
					{
						break;
					}
				}

				if ( $limit !== -1 AND $count >= $limit )
				{
					break;
				}

				foreach ( array_intersect( array_keys( $baseMethod['parameters'] ), array_keys( $subclassMethod['parameters'] ) ) as $param )
				{
					foreach ( $parameterFieldMap as $field => $reason )
					{
						if ( $baseMethod['parameters'][$param][$field] !== $subclassMethod['parameters'][$param][$field] )
						{
							if ( $shallowCheck )
							{
								return true;
							}

							$issues[] = array(
								"method"            => $method,
								"reason"            => $reason,
								"parameter"         => $param,
								"subclassFile"      => $subclassFile,
								"baseFile"          => $baseFile,
								"baseClass"         => $baseClassName,
								"subclassMethod"    => $subclassMethod,
								"subclassName"      => $subclassName,
								"baseMethod"        => $baseMethod,
								"class"             => $isHook ? $baseClassName : $subclassName,
								'priority'          => $isPriority
							);

							if ( $limit !== -1 AND ++$count >= $limit )
							{
								break;
							}
						}
					}

					if ( $limit !== -1 AND $count >= $limit )
					{
						break;
					}
				}
			}
		}

		if ( $shallowCheck )
		{
			return !empty( $issues );
		}

		return $issues;
	}

	/**
	 * @brief   Enable logging for this scan
	 */
	protected static bool $logging = TRUE;

	/**
	 * Scan a class and subclass installed on a site to make sure any methods are overloaded in a php8 compatible way
	 *
	 * @param   bool        $getDetails     Return the details of every class loaded? This will make the result SIGNIFICANTLY larger memory-wise so leave false unless you need it.
	 * @param   bool        $shallowCheck   Return a bool instead that indicates whether discrepancies were found. Fastest since it returns at the first discrepancy and lightweight since it doesn't store all that data in memory
	 * @param   int         $limit          The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
	 * @param   array       $options        Options;
	 * @example
	 * array(
	 *      "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
	 *      "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
	 *      "logging"     => true // Whether to disable logging for this scan, default is true
	 * )
	 *
	 * @return  bool|null|array[]     By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
	 */
	final public static function scanCustomizationIssues( $getDetails=false, $shallowCheck=false, $limit=500, $options=NULL )
	{
		if( isset( $options['logging'] ) )
		{
			static::$logging = $options['logging'];
		}

		$issues = array();
		$details = array();

		/* Get issues with hooks */
		$hookIssues = static::scanHooks( $getDetails, $shallowCheck, $limit, $options );
		if ( !empty( $hookIssues ) OR ( $shallowCheck AND $hookIssues !== null ) )
		{
			if ( $shallowCheck )
			{
				return $hookIssues;
			}

			foreach ( $hookIssues[0] as $appOrPlugin => $classes )
			{
				$issues[$appOrPlugin] = $issues[$appOrPlugin] ?? [];
				foreach ( $classes as $className => $classIssues )
				{
					$issues[$appOrPlugin][$className] = array_merge( $issues[$appOrPlugin][$className] ?? [], $classIssues );
				}
			}

			if ( $getDetails )
			{
				foreach ( $hookIssues[1] as $name => $methodData )
				{
					$details[$name] = $details[$name] ?? $methodData;
				}
			}

			if ( $limit !== -1 )
			{
				$limit -= \count( $hookIssues );
			}
		}

		/* Get issues with real classes inheriting IPS code */
		$extendingIssues = static::scanExtendedClasses( $getDetails, $shallowCheck, $limit, 5000, $options );
		if ( $extendingIssues OR ( $shallowCheck and $extendingIssues !== null ) )
		{
			if ( $shallowCheck )
			{
				return $extendingIssues;
			}

			foreach ( $extendingIssues[0] as $appOrPlugin => $classes )
			{
				$issues[$appOrPlugin] = $issues[$appOrPlugin] ?? [];
				foreach ( $classes as $className => $classIssues )
				{
					$issues[$appOrPlugin][$className] = array_merge( $issues[$appOrPlugin][$className] ?? [], $classIssues );
				}
			}

			if ( $getDetails )
			{
				foreach ( $extendingIssues[1] as $name => $methodData )
				{
					$details[$name] = $details[$name] ?? $methodData;
				}
			}

			if ( $limit !== -1 )
			{
				$limit -= \count( $hookIssues );
			}
		}

		if ( $shallowCheck )
		{
			if ( !empty( $issues ) )
			{
				foreach( $issues as $appOrPlugin => $classIssues )
				{
					foreach( $classIssues as $className => $issues )
					{
						foreach ( $issues as $issue )
						{
							if ( $issue['priority'] ?? 0 )
							{
								return false;
							}
						}
					}
				}
			}
			return null;
		}

		return $getDetails ? [ $issues, $details ] : [ $issues ];
	}

	/**
	 * Scan all the methods in hooks installed on a site to make sure they're declared in a php8 compatible way
	 *
	 * @param   bool        $getDetails     Return the details of every hook and base class loaded? This will make the result SIGNIFICANTLY larger so leave false unless you need it.
	 * @param   bool        $shallowCheck   Return a bool instead that indicates whether discrepancies were found. Potentially faster since it returns at the first discrepancy
	 * @param   int         $limit          The upper limit of issues to return. Set to -1 for no limit; Note this makes no difference for shallowChecks
	 * @param   array       $options        Options;
	 * @example
	 * array(
	 *      "enabledOnly" => true, // Whether to only check enabled apps & plugins only. default is true
	 *      "dirsToCheck" => [ "apps" => array(), "plugins" => array() ], // Filter for what apps & plugins to check. Empty/null checks all apps
	 * )
	 *
	 * @return  bool|null|array[]     By default, array of [$issues, $details], where both $issues and $details are arrays of issues and method details found respectively
	 */
	final public static function scanHooks( $getDetails=false, $shallowCheck=false, $limit=500, $options=NULL )
	{
		$details = array();
		$issues = array();
		if ( empty( $options ) OR !\is_array( $options ) )
		{
			$options = static::$defaultScannerOptions;
		}

		$appDirsToCheck = isset( $options['dirsToCheck']['apps'] ) ? $options['dirsToCheck']['apps'] : ( isset( $options['dirsToCheck']['plugins'] ) ? array() : null );
		$pluginDirsToCheck = isset( $options['dirsToCheck']['plugins'] ) ? $options['dirsToCheck']['plugins'] : ( isset( $options['dirsToCheck']['apps'] ) ? array() : null );
		$enabledOnly = $options['enabledOnly'] ?? static::$defaultScannerOptions['enabledOnly'];

		/* ITERATE through our hooks */
		if ( $enabledOnly )
		{
			if ( !empty( \IPS\IPS::$hooks ) OR file_exists( \IPS\SITE_FILES_PATH . "/plugins/hooks.php" ) )
			{
				if ( empty( \IPS\IPS::$hooks ) )
				{
					\IPS\IPS::$hooks = require( \IPS\SITE_FILES_PATH . '/plugins/hooks.php' );
				}
			}

			if ( empty( \IPS\IPS::$hooks ) )
			{
				return $shallowCheck ? false : ( $getDetails ? array( [], [] ) : array( [] ) );
			}
			$hooksToCheck = \IPS\IPS::$hooks;
		}
		else
		{
			$hooksToCheck = array();
			$where = array(
				[ 'type=?', 'C' ]
			);

			if ( $appDirsToCheck !== null OR $pluginDirsToCheck !== null )
			{
				$appAndPluginClauses = [];
				if ( !empty( $appDirsToCheck ) )
				{
					$appAndPluginClauses[] = \IPS\Db::i()->in( 'app', $appDirsToCheck );
				}

				if ( !empty( $pluginDirsToCheck ) )
				{
					$appAndPluginClauses[] = \IPS\Db::i()->in( 'plugin', $pluginDirsToCheck );
				}

				if ( !empty( $appAndPluginClauses ) )
				{
					$where[] = [ '( ' . implode( ' OR ', $appAndPluginClauses ) . ' )' ];
				}
			}
			$select = \IPS\Db::i()->select( 'core_hooks.*, core_plugins.plugin_location, core_plugins.plugin_enabled, core_applications.app_enabled', 'core_hooks', $where )
			                      ->join( 'core_plugins', 'core_plugins.plugin_id=core_hooks.plugin' )
			                      ->join( 'core_applications', 'core_applications.app_directory=core_hooks.app' );

			foreach ( $select as $row )
			{
				if ( empty( $row['app'] ?? $row['plugin_location'] ) )
				{
					continue;
				}

				$fileSource = ( $row['app'] === null ? 'plugins/' : 'applications/' ) . ( $row['app'] ?? $row['plugin_location'] ) . '/hooks/' . $row['filename'] . '.php';
				$enabled = \boolval( !empty( $row['app'] ) ? ( $row['app_enabled'] ?? true ) : ( $row['plugin_enabled'] ?? true ) );
				if ( !file_exists( \IPS\SITE_FILES_PATH . '/' . $fileSource ) )
				{
					continue;
				}

				$hooksToCheck[$row['class']][$row['id']] = array(
					'file' => $fileSource,
					'class' => ( $row['app'] === null ) ? "hook{$row['id']}" : "{$row['app']}_hook_{$row['filename']}",
					'isPriority' => $enabled
				);
			}
		}

		$count = 0;

		/* Teeny tiny optimization, but we'll save these for each app rather than compute on each and every hook */
		$hookBases = array( 'app' => array(), 'plugin' => array() );
		$shouldSkip = array( 'app' => array(), 'plugin' => array() );

		foreach ( $hooksToCheck as $baseClass => $hooks )
		{
			if ( \mb_substr( trim( $baseClass, '\\' ), 0, 9 ) === 'IPS\\Theme' )
			{
				continue;
			}
			$baseFile = null;

			/* Determine the path */
			$bits = explode( '\\', ltrim( $baseClass, '\\' ) );

			/* If this doesn't belong to us, try a PSR-0 loader or ignore it */
			$vendorName = array_shift( $bits );
			if( $vendorName !== 'IPS' )
			{
				continue;
			}

			/* Work out what namespace we're in */
			$baseClassName = array_pop( $bits );
			$baseClassNamespace = '\\IPS\\' . implode( '\\', $bits );

			/* Locate file */
			$path = '';
			$sourcesDirSet = FALSE;
			$baseFileLocation = \IPS\SITE_FILES_PATH;
			foreach ( array_merge( $bits, array( $baseClassName ) ) as $i => $bit )
			{
				if( preg_match( "/^[a-z0-9]/", $bit ) )
				{
					if( $i === 0 )
					{
						if ( \IPS\CIC2 AND !\in_array( $bit, \IPS\IPS::$ipsApps ) )
						{
							$path .= \IPS\SITE_FILES_PATH . '/applications/'; // Applications are in the root on Cloud2
						}
						else
						{
							$path .= \IPS\ROOT_PATH . '/applications/';
							$baseFileLocation = \IPS\ROOT_PATH;
						}
					}
					else
					{
						$sourcesDirSet = TRUE;
					}
				}
				elseif ( $i === 3 and $bit === 'Upgrade' )
				{
					$bit = \mb_strtolower( $bit );
				}
				elseif( $sourcesDirSet === FALSE )
				{
					if( $i === 0 )
					{
						$path .= \IPS\ROOT_PATH . '/system/';
						$baseFileLocation = \IPS\ROOT_PATH;
					}
					elseif ( $i === 1 and $bit === 'Application' )
					{
						// do nothing
					}
					else
					{
						$path .= 'sources/';
					}
					$sourcesDirSet = TRUE;
				}

				$path .= "{$bit}/";
			}



			/* Load it */
			$path = \substr( $path, 0, -1 ) . '.php';

			/* An app contains a hook that extends a class, but the class it extends cannot be found */
			if ( !file_exists( $path ) )
			{
				$path = \substr( $path, 0, -4 ) . \substr( $path, \strrpos( $path, '/' ) );
				if ( !file_exists( $path ) )
				{
					continue;
				}
			}

			/* Get the methods */
			$baseFile = $path;
			$baseMethods = static::getMethods( file_get_contents( $baseFile ) );

			if ( $getDetails )
			{
				$details[ $baseFile ] = $baseMethods;
			}

			foreach ( $hooks as $hookData )
			{
				$hookFileComponents = explode( '/', $hookData['file'] );
				$hookType = $hookFileComponents[0] === 'applications' ? 'app' : ( $hookFileComponents[0] === 'plugins' ? 'plugin' : null );
				$appOrPluginDir = $hookFileComponents[1];

				if ( $hookType === null )
				{
					continue;
				}

				/* Skip if we don't care about this app */
				if ( $shouldSkip[$hookType][$appOrPluginDir] ?? null )
				{
					continue;
				}
				elseif (
					!\array_key_exists( $appOrPluginDir, $shouldSkip[$hookType] ) AND (
					( ( $hookType === 'app' ) and \is_array( $appDirsToCheck ) and !\in_array( $appOrPluginDir, $appDirsToCheck ) ) OR
					( ( $hookType === 'app' ) and !\is_array( $appDirsToCheck ) and \in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) ) OR
					( \is_array( $pluginDirsToCheck ) and !\in_array( $appOrPluginDir, $pluginDirsToCheck ) ) )
				)
				{
					$shouldSkip[$hookType][$appOrPluginDir] = 1;
					continue;
				}
				$shouldSkip[$hookType][$appOrPluginDir] = 0;

				$appOrPlugin = ( $hookType === 'app' ) ? 'App - ' : 'Plugin - ';
				$issueFile = '/' . implode( '/', \array_slice( $hookFileComponents, 2 ) );
				array_pop( $hookFileComponents );
				$app = $appOrPlugin . $hookFileComponents[1];

				if ( $hookBases[$hookType][$appOrPluginDir] ?? null )
				{
					$hookBase = $hookBases[$hookType][$appOrPluginDir];
				}
				else
				{
					$hookBase = \IPS\SITE_FILES_PATH;
					if ( \IPS\CIC2 and $hookType === 'app' and \in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) )
					{
						$hookBase = \IPS\ROOT_PATH;
					}
					$hookBases[$hookType][$appOrPluginDir] = $hookBase;
				}
				$hookFile = $hookBase . '/' . $hookData['file'];

				if ( !file_exists( $hookFile ) )
				{
					static::log( "The file {$hookFile} does not exist, so the hook belonging to {$app} was not checked." );
					continue;
				}

				$hookMethods = static::getMethods( file_get_contents( $hookFile ) );
				if ( $getDetails )
				{
					$details[$hookFile] = $hookMethods;
				}
				$priority = $hookData['isPriority'] ?? ( $hookType === 'app' ? !\in_array( $appOrPluginDir, \IPS\IPS::$ipsApps ) : true ); // Built in apps are not priority

				/* Find any issues */
				$hookIssues = static::compareMethodsForIssues(
					$baseMethods,
					$hookMethods,
					$baseFile,
					$hookFile,
					$baseClass,
					$baseClassNamespace . '\\' . $hookData['class'], /* The hook class shares the same namespace as the base; see \IPS\IPS::monkeyPatch() to understand */
					$shallowCheck,
					$limit,
					true,
					$priority
				);

				if ( !empty( $hookIssues ) )
				{
					if ( $shallowCheck )
					{
						if ( $priority OR !( $options['shallowCheckPriorityOnly'] ?? 1 ) )
						{
							return true;
						}
						$issues[] = 1;
						continue;
					}

					$issues[$app] = $issues[$app] ?? [];
					$issues[$app][$issueFile] = array_merge( $issues[$app][$issueFile] ?? [], $hookIssues );

					if ( $limit !== -1 )
					{
						$limit -= \count( $hookIssues );
					}
					$count += \count( $hookIssues );
				}
			}

			if ( $limit !== -1 AND $count >= $limit )
			{
				break;
			}
		}

		if ( $shallowCheck )
		{
			return empty( $issues ) ? null : false; //only issues that could be here are not priority
		}

		return ( $getDetails ? [ $issues, $details ] : [ $issues ] );
	}

	/**
	 * Get Methods from a file's contents and details on each method
	 *
	 * @param   string      $scriptContents     The contents of the PHP file; should be the entire file's contents
	 *
	 * @return  array
	 *  @example with a file containing public function foo( ?array $item )
	 *      array(
	"foo" => array(
	"final" => false,
	"security" => "public",
	"static" => false,
	"name" => "foo",
	"parameters" => array(
	"item" => array(
	"name" => "item",
	"type" => "IPS\Content\Item",
	"nullable" => true,
	"passedByReference" => false,
	"packed" => false
	)
	),
	"returnType" => "bool",
	"lineNumber" => 384
	)
	)
	 */
	final protected static function getMethods( string $scriptContents ): array
	{
		$cacheKey = md5( $scriptContents );
		if ( isset( static::$methodInfoCache[ $cacheKey ] ) )
		{
			return static::$methodInfoCache[ $cacheKey ];
		}
		$methods = array();
		$pattern = '/(public\\s+|static\\s+|protected\\s+|private\\s+|final\\s+)*function\\s+[a-zA-Z0-9_]+\\s*\\(.*\\)[\\:\\sa-zA-Z0-9_\\\\]*\\{/';

		try
		{
			$matches = array();
			if ( preg_match_all( $pattern, $scriptContents, $matches, \PREG_OFFSET_CAPTURE ) and !empty( $matches[0] ) )
			{
				foreach ( $matches[0] as $methodDeclarationDetails )
				{
					$methodDeclaration = rtrim( $methodDeclarationDetails[0], '{' );
					$components = explode( 'function', $methodDeclaration, 2 );
					$prefix = preg_split( '/\s+/', $components[0] ) ?: array();
					$suffix = $components[1]; // we are certain this exists given our regex pattern

					$method = array(
						'final'      => \in_array( 'final', $prefix ),
						'security'   => \in_array( 'protected', $prefix ) ? 'protected' : ( \in_array( 'private', $prefix ) ? 'private' : 'public' ),
						'static'     => \in_array( 'static', $prefix ),
						'name'       => '',
						'parameters' => array(),
						'returnType' => null,
						'lineNumber' => \count( explode("\n", \mb_substr( $scriptContents, 0, $methodDeclarationDetails[1] ) ) )
					);
					preg_match( '/^\\s*([a-zA-Z0-9_]+)/', $suffix, $names );
					$method['name'] = $names[1];

					//did we get a return type?
					if ( $suffixComponents = explode( ':', $suffix ) and isset( $suffixComponents[1] ) )
					{
						$method['returnType'] = trim( $suffixComponents[1] ) ?: null;
					}

					/* Remove arrays from parameters since they will break things later Multidimensional arrays will still break things, but idc because this works on core/our MS code */
					$noArrays = preg_replace( ['/array\\(.*?\\)/', '/\\[[^\s]+?\\]/'], ['',''], trim( \mb_substr( $suffix, \strlen( $method['name'] ) - 1 ) ) );
					preg_match( '/\\(\\s*([^\\)]*)\\s*\\)/', $noArrays, $paramComponents );
					$method['parameters'] = static::getParameterDetails( $paramComponents[1] );
					$methods[$method['name']] = $method;
				}
			}
		}
		catch ( \Exception $e ) { }

		static::$methodInfoCache[$cacheKey] = $methods;
		return $methods;
	}
	
	/**
	 * Get Methods from a file's contents and details on each method
	 *
	 * @param   string      $scriptContents     The contents of the PHP file; should be the entire file's contents
	 *
	 * @return  array
	 */
	final public static function getClassDetails( string $scriptContents )
	{
		$cacheKey = md5( $scriptContents );
		if ( array_key_exists( $cacheKey, static::$classDetailsCache ) )
		{
			return static::$classDetailsCache[$cacheKey];
		}

		$details = array(
			'className'         => null,
			'fileNamespace'     => '\\',
			'classNamespace'    => '\\',
			'globalClassName'   => null,
			'parentClass'       => null,
			'abstract'          => false,
			'interfaces'        => array(),
			'methods'           => array(),
		);

		$pattern = '/(namespace\\s+[a-zA-Z\\\\_0-9]+?\\s*;(?:.|\\n)*?)?.*?(?:^|\\n)\\s*(abstract\\s+?)?\\s*\\bclass\\s+([a-zA-Z0-9_\\\\]+)(\\s+?extends\\s+[\\\\A-Za-z0-9_]+\\s+)?([^{]+?)?\\s+\\{/';
		$matches = array();
		if ( preg_match( $pattern, $scriptContents, $matches ) and !empty( $matches ) ) // we don't need preg_match_all since we're assuming 1 class per file
		{
			array_shift($matches);
			$component = trim( array_shift( $matches ) );

			/* Namespace found? */
			if ( \mb_substr( $component, 0, 9 ) === 'namespace' )
			{
				$namespaceDeclaration       = explode( ';', $component, 2 )[0];
				$details['fileNamespace']   = '\\' . trim( str_replace( 'namespace', '', $namespaceDeclaration ), "\\ \n\r\t\v\x00" );
				$component                  = trim( array_shift( $matches ) );
			}

			/* Is it abstract */
			if ( $component === 'abstract' )
			{
				$details['abstract'] = true;
				$component = trim( array_shift( $matches ) );
			}
			elseif ( empty( $component ) )
			{
				$component = trim( array_shift( $matches ) );
			}

			/* What's the name */
			$classComponents        = explode( '\\', trim( $component ) );
			$details['className']   = array_pop( $classComponents );

			/* If it's globally namespaced, don't use the file namespace */
			if ( @$classComponents[0] === 'IPS' OR \mb_substr( $component, 0, 1 ) === '\\' )
			{
				$imploded                   = implode( '\\', $classComponents );
				$details['classNamespace']  = '\\' . $imploded;
			}
			else
			{
				$details['classNamespace'] = '\\' . trim( $details['fileNamespace'], '\\' );
				if ( \count( $classComponents ) )
				{
					$details['classNamespace'] .= '\\' . implode( '\\', $classComponents );
				}
			}

			/* We can remove the leading underscore for IPS stuff */
			if ( \mb_substr( $details['classNamespace'], 0, 4 ) === '\\IPS' and \mb_substr( $details['className'], 0, 1 ) === '_' )
			{
				$details['className'] = \mb_substr( $details['className'], 1 );
			}

			$details['globalClassName'] = $details['classNamespace'] . '\\' . $details['className'];
			$component = trim( array_shift( $matches ) );
			if ( empty( $component ) )
			{
				$component = trim( array_shift( $matches ) );
			}

			/* Does it extend? */
			if ( \mb_substr( $component, 0, 7 ) === 'extends' )
			{
				$component = trim( str_replace( 'extends', '', $component ) );

				if ( \mb_substr( $component, 0, 1 ) !== '\\' AND \mb_substr( $component, 0, 3 ) !== 'IPS' )
				{
					$component = rtrim( $details['fileNamespace'], '\\' ) . '\\' . trim( $component, '\\' );
				}

				$details['parentClass'] = $component;
				$component              = trim( array_shift( $matches ) );
			}
			elseif ( empty( $component ) )
			{
				$component = trim( array_shift( $matches ) );
			}

			/* Interfaces? */
			$parts = explode( 'implements', $component );
			$interfacesComponent = array_pop( $parts );
			/* We got interfaces if removing the implements keyword changed the length */
			if ( \mb_strlen( $interfacesComponent ) !== \mb_strlen( $component ) )
			{
				$interfaceComponents = explode( ',', $interfacesComponent );
				$interfaceComponents = array_map( 'trim', $interfaceComponents );
				while ( \count( $interfaceComponents ) AND preg_match( "/^[\\\\_a-zA-Z0-9]+\$/", $interfaceComponents[0] ) )
				{
					$details['interfaces'][] = array_shift( $interfaceComponents );
				}
			}
		}

		if ( $details['globalClassName'] )
		{
			$details['methods'] = static::getMethods( $scriptContents );
		}

		static::$classDetailsCache[$cacheKey] = $details;
		return $details;
	}

	/**
	 * Get details for the parameter string (what's in the parenthesis following the method name)
	 * NOTE - This will break if you have a comma-delimited array as a default param; we don't care about that enough to fix it though
	 *
	 * @param   string  $parameterString        The raw string of parameters from a method declaration
	 *
	 * @return  array
	 */
	final protected static function getParameterDetails( string $parameterString ) : array
	{
		$parameters = array();
		if ( empty( trim( $parameterString ) ) )
		{
			return array();
		}

		$entities = explode( ',', $parameterString );

		foreach( $entities as $entity )
		{
			$parameter = array(
				"name" => "",
				"type" => null,
				"nullable" => false,
				"passedByReference" => false,
				"packed" => false
			);

			$entity = trim($entity);

			// is it packed?
			$unpacked = str_replace( '...', '', $entity );
			$parameter['packed'] = $entity !== $unpacked;
			$entity = $unpacked;

			// is it by reference?
			$unreferenced = str_replace( '&', '', $entity );
			$parameter['passedByReference'] = $unreferenced !== $entity;
			$entity = $unreferenced;

			// is it nullable?
			$nonNullable = str_replace( '?', '', $entity );
			$parameter['nullable'] = $entity !== $nonNullable;
			$entity = $nonNullable;

			// var name
			preg_match( '/\\$([a-zA-Z0-9_]+)\\s*=?\\s*(null|NULL)?/', $entity, $matches );
			$parameter['name'] = $matches[1];
			$defaultValue = isset( $matches[2] ) ? trim( $matches[2] ) : null;

			// to get the type, capture everything before the name
			$pattern = '/^(.*)\s+\\$' . $parameter['name'] . '/';
			preg_match( $pattern, $entity, $matches );
			if ( isset( $matches[1] ) )
			{
				$parameter['type'] = trim( $matches[1] );
			}

			/* If the default is null, it is still nullable. */
			if ( $parameter['type'] and $defaultValue and \mb_strtolower( $defaultValue ) === 'null' )
			{
				$parameter['nullable'] = true;
			}

			$parameters[$parameter['name']] = $parameter;
		}

		return $parameters;
	}

	/**
	 * Log any messages
	 *
	 * @param   string      $message
	 * @return  void
	 */
	protected static function log( string $message )
	{
		if( static::$logging === TRUE )
		{
			\IPS\Log::debug( $message, 'app_scanner' );
		}
	}
}