<?php
namespace Adminer;

class Plugins {
	// these hooks expect the value to be appended to the result
	/** @var true[] */ private static array $append = array('dumpFormat' => true, 'dumpOutput' => true, 'editRowPrint' => true, 'editFunctions' => true, 'config' => true);

	/** @var list<object> @visibility protected(set) */ public array $plugins;
	/** @var string[] @visibility protected(set) */ public array $drivers = array(); // [id => name] of drivers registered by plugins
	/** @var string[] @visibility protected(set) */ public array $driverFiles = array(); // [id => filename] of files which registered a driver
	/** @visibility protected(set) */ public string $error = ''; // HTML
	/** @var list<object>[] */ private array $hooks = array();

	/** Register plugins
	* @param ?mixed[] $plugins object instances or null to autoload plugins from adminer-plugins/, non-objects are reported and skipped
	*/
	function __construct(?array $plugins) {
		$drivers = SqlDriver::$drivers; // bundled drivers, plugin drivers are registered below
		$help = " href='https://www.adminer.org/plugins/#use'" . target_blank();
		if ($plugins === null) {
			$plugins = array();
			$basename = "adminer-plugins";
			if (is_dir($basename)) {
				foreach (glob("$basename/*.php") as $filename) {
					$file_drivers = SqlDriver::$drivers;
					$this->includeOnce($filename);
					foreach (array_diff_key(SqlDriver::$drivers, $file_drivers) as $id => $name) {
						$this->driverFiles[$id] = $filename;
					}
				}
			}
			if (file_exists("$basename.php")) {
				$include = $this->includeOnce("$basename.php"); // example: return array(new AdminerLoginOtp($secret));
				if (is_array($include)) {
					foreach ($include as $key => $plugin) {
						$plugins[is_object($plugin) ? get_class($plugin) : $key] = $plugin;
					}
				} else {
					$this->error .= lang('%s must <a%s>return an array</a>.', "<b>$basename.php</b>", $help) . "<br>";
				}
			}
			foreach (get_declared_classes() as $class) {
				if (!$plugins[$class] && (preg_match('~^Adminer\w~i', $class) || is_subclass_of($class, 'Adminer\Plugin'))) {
					// we need to use reflection because PHP 7.1 throws ArgumentCountError for missing arguments but older versions issue a warning
					$reflection = new \ReflectionClass($class);
					$constructor = $reflection->getConstructor();
					if ($constructor && $constructor->getNumberOfRequiredParameters()) {
						$this->error .= lang('<a%s>Configure</a> %s in %s.', $help, "<b>$class</b>", "<b>$basename.php</b>") . "<br>";
					} else {
						$plugins[$class] = new $class;
					}
				}
			}
		}
		$invalid = array_filter($plugins, function ($plugin) {
			return !is_object($plugin); // e.g. include of a file without return statement evaluates to 1
		});
		if ($invalid) {
			$this->error .= lang('Every plugin must <a%s>be an object</a>.', $help) . "<br>";
			$plugins = array_diff_key($plugins, $invalid);
		}

		$this->drivers = array_diff_key(SqlDriver::$drivers, $drivers);
		$this->plugins = $plugins;

		$adminer = new Adminer;
		$plugins[] = $adminer;
		$reflection = new \ReflectionObject($adminer);
		foreach ($reflection->getMethods() as $method) {
			foreach ($plugins as $plugin) {
				$name = $method->getName();
				if (method_exists($plugin, $name)) {
					$this->hooks[$name][] = $plugin;
				}
			}
		}
	}

	/** Separate function to not overwrite local variables
	* @return mixed whatever the included file returns, 1 if it doesn't return anything
	*/
	function includeOnce(string $filename) {
		return include_once "./$filename";
	}

	/** Get checksum of a plugin or design file, ignoring line ends and translations
	* @return string hex crc32
	*/
	static function checksum(string $filename): string {
		$file = str_replace("\r", "", file_get_contents($filename));
		$file = preg_replace('~\n\tprotected \$translations = array\(.*?\n\t\);~s', '', $file);
		return dechex(crc32($file));
	}

	/** Get checksums of files with loaded plugins
	* @return string[] plugin name in key, hex crc32 in value
	*/
	function checksums(): array {
		$filenames = array_values($this->driverFiles);
		foreach ($this->plugins as $plugin) {
			$reflection = new \ReflectionObject($plugin);
			$filenames[] = $reflection->getFileName();
		}
		$return = array();
		foreach ($filenames as $filename) {
			$return[basename($filename, '.php')] = self::checksum($filename);
		}
		return $return;
	}

	/** Get checksums of official plugins shipped with this Adminer version
	* @return string[] plugin name in key, hex crc32
	*/
	static function officialChecksums(): array {
		// inlined by compile.php
		$return = array();
		foreach (array("../plugins", "../plugins/drivers") as $dir) {
			foreach (glob("$dir/*.php") as $filename) {
				$return[basename($filename, '.php')] = self::checksum($filename);
			}
		}
		return $return;
	}

	/**
	* @param literal-string $name
	* @param mixed[] $params
	* @return mixed
	*/
	function __call(string $name, array $params) {
		$args = array();
		foreach ($params as $key => $val) {
			// some plugins accept params by reference - we don't need to propagate it outside, just to the other plugins
			$args[] = &$params[$key];
		}
		$return = null;
		foreach ($this->hooks[$name] as $plugin) {
			$value = call_user_func_array(array($plugin, $name), $args);
			if ($value !== null) {
				if (!self::$append[$name]) { // non-null value from non-appending method short-circuits the other plugins
					return $value;
				}
				$return = $value + (array) $return;
			}
		}
		return $return;
	}
}
