<?PHP
#
#   FILE:  PluginManager.php
#
#   Part of the ScoutLib application support library
#   Copyright 2009-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu
#

/**
* Manager to load and invoke plugins.
*/
class PluginManager
{

    # ---- PUBLIC INTERFACE --------------------------------------------------

    /**
    * PluginManager class constructor.
    * @param ApplicationFramework $AppFramework ApplicationFramework within
    *       which plugins should run.
    * @param array $PluginDirectories Array of names of directories
    *       containing plugins, in the order they should be searched.
    */
    public function __construct($AppFramework, $PluginDirectories)
    {
        # save framework and directory list for later use
        $this->AF = $AppFramework;
        Plugin::SetApplicationFramework($AppFramework);
        $this->DirsToSearch = $PluginDirectories;

        # get our own database handle
        $this->DB = new Database();

        # hook into events to load plugin PHP and HTML files
        $this->AF->HookEvent("EVENT_PHP_FILE_LOAD", array($this, "FindPluginPhpFile"),
                ApplicationFramework::ORDER_LAST);
        $this->AF->HookEvent("EVENT_HTML_FILE_LOAD", array($this, "FindPluginHtmlFile"),
                ApplicationFramework::ORDER_LAST);

        # tell PluginCaller helper object how to get to us
        PluginCaller::$Manager = $this;
    }

    /**
    * Load and initialize plugins.
    * @return TRUE if load was successful (no problems encountered), otherwise FALSE.
    */
    public function LoadPlugins()
    {
        $ErrMsgs = array();

        # look for plugin files
        $PluginFiles = $this->FindPlugins($this->DirsToSearch);

        # for each plugin found
        foreach ($PluginFiles as $PluginName => $PluginFileName)
        {
            # attempt to load plugin
            $Result = $this->LoadPlugin($PluginName, $PluginFileName);

            # if errors were encountered during loading
            if (is_array($Result))
            {
                # save errors
                $ErrMsgs[$PluginName][] = $Result;
            }
            else
            {
                # add plugin to list of loaded plugins
                $this->Plugins[$PluginName] = $Result;
            }
        }

        # check dependencies and drop any plugins with failed dependencies
        $DepErrMsgs = $this->CheckDependencies($this->Plugins);
        $DisabledPlugins = array();
        foreach ($DepErrMsgs as $PluginName => $Msgs)
        {
            $DisabledPlugins[] = $PluginName;
            foreach ($Msgs as $Msg)
            {
                $ErrMsgs[$PluginName][] = $Msg;
            }
        }

        # sort plugins according to any loading order requests
        $this->Plugins = $this->SortPluginsByInitializationPrecedence(
                $this->Plugins);

        # for each plugin
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            # if plugin is loaded and enabled
            if (!in_array($PluginName, $DisabledPlugins)
                    && $Plugin->IsEnabled())
            {
                # attempt to make plugin ready
                $Result = $this->ReadyPlugin($Plugin);

                # if making plugin ready failed
                if ($Result !== NULL)
                {
                    # save error messages
                    foreach ($Result as $Msg)
                    {
                        $ErrMsgs[$PluginName][] = $Msg;
                    }
                }
                else
                {
                    # mark plugin as ready
                    $Plugin->IsReady(TRUE);
                }
            }
        }

        # check plugin dependencies again in case an install or upgrade failed
        $DepErrMsgs = $this->CheckDependencies($this->Plugins, TRUE);

        # for any plugins that were disabled because of dependencies
        foreach ($DepErrMsgs as $PluginName => $Msgs)
        {
            # make sure all plugin hooks are undone
            $this->UnhookPlugin($this->Plugins[$PluginName]);

            # mark the plugin as unready
            $this->Plugins[$PluginName]->IsReady(FALSE);

            # record any errors that were reported
            foreach ($Msgs as $Msg)
            {
                $ErrMsgs[$PluginName][] = $Msg;
            }
        }

        # save plugin files names and any error messages for later use
        $this->PluginFiles = $PluginFiles;
        $this->ErrMsgs = $ErrMsgs;

        # report to caller whether any problems were encountered
        return count($ErrMsgs) ? FALSE : TRUE;
    }

    /**
    * Retrieve any error messages generated during plugin loading.
    * @return Array of arrays of error messages, indexed by plugin base
    *       (class) name.
    */
    public function GetErrorMessages()
    {
        return $this->ErrMsgs;
    }

    /**
    * Retrieve specified plugin.
    * @param string $PluginName Base name of plugin.
    * @param bool $EvenIfNotReady Return the plugin even if it's not
    *       marked as ready for use.  (OPTIONAL, defaults to FALSE)
    * @return Plugin object or NULL if no plugin found with specified name.
    * @throws Exception If plugin is not initialized and ready.
    */
    public function GetPlugin($PluginName, $EvenIfNotReady = FALSE)
    {
        if (!$EvenIfNotReady && array_key_exists($PluginName, $this->Plugins)
                && !$this->Plugins[$PluginName]->IsReady())
        {
            $Trace = debug_backtrace();
            $Caller = basename($Trace[0]["file"]).":".$Trace[0]["line"];
            throw new Exception("Attempt to access uninitialized plugin "
                    .$PluginName." from ".$Caller);
        }
        return isset($this->Plugins[$PluginName])
                ? $this->Plugins[$PluginName] : NULL;
    }

    /**
    * Retrieve all loaded plugins.
    * @return array Plugin objects, with base names for the index.
    */
    public function GetPlugins()
    {
        return $this->Plugins;
    }

    /**
    * Retrieve plugin for current page (if any).  This method relies on the
    * current page having been found within the plugin directory (usually via a
    * "P_" prefix on the page name) via a call to the hooked FindPluginPhpFile()
    * or FindPluginHtmlFile() methods..
    * @return Plugin object or NULL if no plugin associated with current page.
    */
    public function GetPluginForCurrentPage()
    {
        return $this->GetPlugin($this->PageFilePlugin);
    }

    /**
    * Retrieve info about currently loaded plugins.
    * @return Array of arrays of plugin info, indexed by plugin base (class) name
    *       and sorted by case-insensitive plugin name.
    */
    public function GetPluginAttributes()
    {
        # for each loaded plugin
        $Info = array();
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            # retrieve plugin attributes
            $Info[$PluginName] = $Plugin->GetAttributes();

            # add in other values to attributes
            $Info[$PluginName]["Enabled"] = $Plugin->IsEnabled();
            $Info[$PluginName]["Installed"] = $Plugin->IsInstalled();
            $Info[$PluginName]["ClassFile"] = $this->PluginFiles[$PluginName];
        }

        # sort plugins by name
        uasort($Info, function ($A, $B) {
                $AName = strtoupper($A["Name"]);
                $BName = strtoupper($B["Name"]);
                return ($AName == $BName) ? 0
                        : ($AName < $BName) ? -1 : 1;
        });

        # return plugin info to caller
        return $Info;
    }

    /**
    * Returns a list of plugins dependent on the specified plugin.
    * @param string $PluginName Base name of plugin.
    * @return Array of base names of dependent plugins.
    */
    public function GetDependents($PluginName)
    {
        $Dependents = array();
        $AllAttribs = $this->GetPluginAttributes();
        foreach ($AllAttribs as $Name => $Attribs)
        {
            if (array_key_exists($PluginName, $Attribs["Requires"]))
            {
                $Dependents[] = $Name;
                $SubDependents = $this->GetDependents($Name);
                $Dependents = array_merge($Dependents, $SubDependents);
            }
        }
        return $Dependents;
    }

    /**
    * Get list of active (i.e. enabled and ready) plugins.
    * @return Array of base names of active plugins.
    */
    public function GetActivePluginList()
    {
        $ActivePluginNames = array();
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($Plugin->IsReady())
            {
                $ActivePluginNames[] = $PluginName;
            }
        }
        return $ActivePluginNames;
    }

    /**
    * Get/set whether specified plugin is enabled.
    * @param string $PluginName Base name of plugin.
    * @param bool $NewValue TRUE to enable, FALSE to disable.  (OPTIONAL)
    * @return TRUE if plugin is enabled, otherwise FALSE.
    */
    public function PluginEnabled($PluginName, $NewValue = NULL)
    {
        return !isset($this->Plugins[$PluginName]) ? FALSE
                : $this->Plugins[$PluginName]->IsEnabled($NewValue);
    }

    /**
    * Uninstall plugin and (optionally) delete any associated data.
    * @param string $PluginName Base name of plugin.
    * @return Error message or NULL if uninstall succeeded.
    */
    public function UninstallPlugin($PluginName)
    {
        # assume success
        $Result = NULL;

        # if plugin is installed
        if ($this->Plugins[$PluginName]->IsInstalled())
        {
            # call uninstall method for plugin
            $Result = $this->Plugins[$PluginName]->Uninstall();

            # if plugin uninstall method succeeded
            if ($Result === NULL)
            {
                # remove plugin info from database
                $this->DB->Query("DELETE FROM PluginInfo"
                        ." WHERE BaseName = '".addslashes($PluginName)."'");

                # drop our data for the plugin
                unset($this->Plugins[$PluginName]);
                unset($this->PluginFiles[$PluginName]);
            }
        }

        # report results (if any) to caller
        return $Result;
    }


    # ---- PRIVATE INTERFACE -------------------------------------------------

    private $AF;
    private $DB;
    private $DirsToSearch;
    private $ErrMsgs = array();
    private $PageFilePlugin = NULL;
    private $Plugins = array();
    private $PluginFiles = array();
    private $PluginHasDir = array();

    /**
    * Search for available plugins.  If both a single file version and a
    * directory-based version of the same plugin are present, the directory
    * version will be returned.
    * @param array $DirsToSearch Array of strings containing names of
    *       directories in which to look for plugin files.
    * @return array Array of plugin base file names, with base plugin names
    *       for the index.
    */
    private function FindPlugins($DirsToSearch)
    {
        # for each directory
        $PluginFiles = array();
        foreach ($DirsToSearch as $Dir)
        {
            # if directory exists
            if (is_dir($Dir))
            {
                # for each file in directory
                $FileNames = scandir($Dir);
                foreach ($FileNames as $FileName)
                {
                    # if file looks like base plugin file
                    if (preg_match("/^[a-zA-Z_][a-zA-Z0-9_]*\.php$/", $FileName))
                    {
                        # if we do not already have a plugin with that name
                        $PluginName = substr($FileName, 0, -4);
                        if (!isset($PluginFiles[$PluginName]))
                        {
                            # add file to list
                            $PluginFiles[$PluginName] = $Dir."/".$FileName;
                        }
                    }
                    # else if file looks like plugin directory
                    elseif (is_dir($Dir."/".$FileName)
                            && preg_match("/^[a-zA-Z_][a-zA-Z0-9_]*/", $FileName))
                    {
                        # if there is a base plugin file in the directory
                        $PluginName = $FileName;
                        $PluginFile = $Dir."/".$PluginName."/".$PluginName.".php";
                        if (file_exists($PluginFile))
                        {
                            # add file to list
                            $PluginFiles[$PluginName] = $PluginFile;
                        }
                        else
                        {
                            # record error
                            $this->ErrMsgs[$PluginName][] =
                                    "Expected plugin file <i>".$PluginName.".php</i> not"
                                    ." found in plugin subdirectory <i>"
                                    .$Dir."/".$PluginName."</i>";
                        }
                    }
                }
            }
        }

        # return info about found plugins to caller
        return $PluginFiles;
    }

    /**
    * Attempt to load plugin.
    * @param string $PluginName Base name (class) of plugin.
    * @param string $PluginFileName Full path to plugin class file.
    * @return mixed Plugin object if loading succeeds, or array of error
    *       messages if loading fails.
    */
    private function LoadPlugin($PluginName, $PluginFileName)
    {
        # bring in plugin class file
        include_once($PluginFileName);

        # if plugin class was not defined by file
        if (!class_exists($PluginName))
        {
            $ErrMsgs[] = "Expected class <i>".$PluginName
                    ."</i> not found in plugin file <i>"
                    .$PluginFileName."</i>";
        }
        else
        {
            # if plugin class is not a valid descendant of base plugin class
            if (!is_subclass_of($PluginName, "Plugin"))
            {
                $ErrMsgs[] = "Plugin <b>".$PluginName."</b>"
                        ." could not be loaded because <i>".$PluginName."</i> class"
                        ." was not a subclass of base <i>Plugin</i> class";
            }
            else
            {
                # instantiate and register the plugin
                $Plugin = new $PluginName($PluginName);

                # check required plugin attributes
                $RequiredAttribs = array("Name", "Version");
                $Attribs = $Plugin->GetAttributes();
                foreach ($RequiredAttribs as $AttribName)
                {
                    if (!strlen($Attribs[$AttribName]))
                    {
                        $ErrMsgs[] = "Plugin <b>".$PluginName."</b>"
                                ." could not be loaded because it"
                                ." did not have a <i>"
                                .$AttribName."</i> attribute set.";
                    }
                }

                # if all required attributes were found
                if (!isset($ErrMsgs))
                {
                    # if plugin has its own subdirectory
                    $this->PluginHasDir[$PluginName] = preg_match(
                            "%/".$PluginName."/".$PluginName.".php\$%",
                            $PluginFileName) ? TRUE : FALSE;
                    if ($this->PluginHasDir[$PluginName])
                    {
                        # if plugin has its own object directory
                        $Dir = dirname($PluginFileName);
                        if (is_dir($Dir."/objects"))
                        {
                            # add object directory to class autoloading list
                            ApplicationFramework::AddObjectDirectory($Dir."/objects");
                        }
                        else
                        {
                            # add plugin directory to class autoloading list
                            ApplicationFramework::AddObjectDirectory($Dir);
                        }

                        # if plugin has its own interface directory
                        $InterfaceDir = $Dir."/interface";
                        if (is_dir($InterfaceDir))
                        {
                            # scan contents of interface directory for
                            #       entries other than the default default
                            $InterfaceSubdirsFound = FALSE;
                            foreach (scandir($InterfaceDir) as $Entry)
                            {
                                if (($Entry != "default") && ($Entry[0] != "."))
                                {
                                    $InterfaceSubdirsFound = TRUE;
                                    break;
                                }
                            }

                            # if entries found other than the default default
                            if ($InterfaceSubdirsFound)
                            {
                                # add directory to those scanned for interfaces
                                $this->AF->AddInterfaceDirectories(
                                        array($InterfaceDir."/%ACTIVEUI%/",
                                                $InterfaceDir."/%DEFAULTUI%/"));
                            }

                            # add plugin interface object directories if present
                            $ActiveUI = $this->AF->ActiveUserInterface();
                            $DefaultUI = $this->AF->DefaultUserInterface();
                            if (is_dir($InterfaceDir."/".$ActiveUI."/objects"))
                            {
                                ApplicationFramework::AddObjectDirectory(
                                    $InterfaceDir."/%ACTIVEUI%/objects");
                            }
                            if (is_dir($InterfaceDir."/".$DefaultUI."/objects"))
                            {
                                ApplicationFramework::AddObjectDirectory(
                                    $InterfaceDir."/%DEFAULTUI%/objects");
                            }

                            # add plugin interface include directories if present
                            if (is_dir($InterfaceDir."/".$DefaultUI."/include"))
                            {
                                $this->AF->AddIncludeDirectories(
                                    $InterfaceDir."/%DEFAULTUI%/include");
                            }
                            if (is_dir($InterfaceDir."/".$ActiveUI."/include"))
                            {
                                $this->AF->AddIncludeDirectories(
                                    $InterfaceDir."/%ACTIVEUI%/include");
                            }
                        }
                    }
                }
            }
        }

        # return loaded plugin or error messages, as appropriate
        return isset($ErrMsgs) ? $ErrMsgs : $Plugin;
    }

    /**
    * Attempt to bring loaded plugin to ready state.
    * @param object $Plugin Plugin to ready.
    * @return array Error messages or NULL if no errors.
    */
    private function ReadyPlugin(&$Plugin)
    {
        # install or upgrade plugin if needed
        $PluginInstalled = $this->InstallPlugin($Plugin);

        # if install/upgrade failed
        if (is_string($PluginInstalled))
        {
            # report errors to caller
            return array($PluginInstalled);
        }

        # set up plugin configuration options
        $ErrMsgs = $Plugin->SetUpConfigOptions();

        # if plugin configuration setup failed
        if ($ErrMsgs !== NULL)
        {
            # report errors to caller
            return is_array($ErrMsgs) ? $ErrMsgs : array($ErrMsgs);
        }

        # set default configuration values if necessary
        if ($PluginInstalled)
        {
            $this->SetPluginDefaultConfigValues($Plugin);
        }

        # initialize the plugin
        $ErrMsgs = $Plugin->Initialize();

        # if initialization failed
        if ($ErrMsgs !== NULL)
        {
            # report errors to caller
            return is_array($ErrMsgs) ? $ErrMsgs : array($ErrMsgs);
        }

        # register and hook any events for plugin
        $ErrMsgs = $this->HookPlugin($Plugin);

        # make sure all hooks are undone if hooking failed
        if ($ErrMsgs !== NULL)
        {
            $this->UnhookPlugin($Plugin);
        }

        # report result to caller
        return $ErrMsgs;
    }

    /**
    * Register and hook any events for plugin.
    * @param object $Plugin Plugin to hook.
    * @return array Error messages or NULL if no errors.
    */
    private function HookPlugin(&$Plugin)
    {
        # register any events declared by plugin
        $Events = $Plugin->DeclareEvents();
        if (count($Events)) {  $this->AF->RegisterEvent($Events);  }

        # if plugin has events that need to be hooked
        $EventsToHook = $Plugin->HookEvents();
        if (count($EventsToHook))
        {
            # for each event
            $ErrMsgs = array();
            foreach ($EventsToHook as $EventName => $PluginMethods)
            {
                # for each method to hook for the event
                if (!is_array($PluginMethods))
                        {  $PluginMethods = array($PluginMethods);  }
                foreach ($PluginMethods as $PluginMethod)
                {
                    # if the event only allows static callbacks
                    if ($this->AF->IsStaticOnlyEvent($EventName))
                    {
                        # hook event with shell for static callback
                        $Caller = new PluginCaller(
                                $Plugin->GetBaseName(), $PluginMethod);
                        $Result = $this->AF->HookEvent(
                                $EventName,
                                array($Caller, "CallPluginMethod"));
                    }
                    else
                    {
                        # hook event
                        $Result = $this->AF->HookEvent(
                                $EventName, array($Plugin, $PluginMethod));
                    }

                    # record any errors
                    if ($Result === FALSE)
                    {
                        $ErrMsgs[] = "Unable to hook requested event <i>"
                                .$EventName."</i> for plugin <b>"
                                .$Plugin->GetBaseName()."</b>";
                    }
                }
            }

            # if event hook setup failed
            if (count($ErrMsgs))
            {
                # report errors to caller
                return $ErrMsgs;
            }
        }

        # report success to caller
        return NULL;
    }

    /**
    * Unhook any events for plugin.
    * @param object $Plugin Plugin to unhook.
    */
    private function UnhookPlugin(&$Plugin)
    {
        # if plugin had events to hook
        $EventsToHook = $Plugin->HookEvents();
        if (count($EventsToHook))
        {
            # for each event
            $ErrMsgs = array();
            foreach ($EventsToHook as $EventName => $PluginMethods)
            {
                # for each method to hook for the event
                if (!is_array($PluginMethods))
                        {  $PluginMethods = array($PluginMethods);  }
                foreach ($PluginMethods as $PluginMethod)
                {
                    # if the event only allows static callbacks
                    if ($this->AF->IsStaticOnlyEvent($EventName))
                    {
                        # unhook event with shell for static callback
                        $Caller = new PluginCaller(
                                $Plugin->GetBaseName(), $PluginMethod);
                        $this->AF->UnhookEvent($EventName,
                                array($Caller, "CallPluginMethod"));
                    }
                    else
                    {
                        # unhook event
                        $this->AF->UnhookEvent(
                                $EventName, array($Plugin, $PluginMethod));
                    }
                }
            }
        }
    }

    /**
    * Install or upgrade specified plugin if needed.  Any errors encountered
    * cause entries to be added to the $this->ErrMsgs array.
    * @param object $Plugin Plugin to install.
    * @return mixed TRUE if plugin was upgraded/installed, FALSE if no upgrade
    *       or install was needed, or error message if install/upgrade failed.
    */
    private function InstallPlugin(&$Plugin)
    {
        # if plugin has not been installed
        $InstallOrUpgradePerformed = FALSE;
        $PluginName = $Plugin->GetBaseName();
        $Attribs = $Plugin->GetAttributes();
        $LockName = __CLASS__.":Install:".$PluginName;
        if (!$Plugin->IsInstalled())
        {
            # set default values if present
            $this->SetPluginDefaultConfigValues($Plugin, TRUE);

            # try to get lock to prevent anyone else from trying to run
            #       install or upgrade at the same time
            $GotLock = $this->AF->GetLock($LockName, FALSE);

            # if could not get lock
            if (!$GotLock)
            {
                # return error
                return "Installation of plugin <b>"
                        .$PluginName."</b> in progress.";
            }

            # install plugin
            $ErrMsg = $Plugin->Install();
            $InstallOrUpgradePerformed = TRUE;

            # if install succeeded
            if ($ErrMsg == NULL)
            {
                # mark plugin as installed
                $Plugin->IsInstalled(TRUE);

                # release lock
                $this->AF->ReleaseLock($LockName);
            }
            else
            {
                # release lock
                $this->AF->ReleaseLock($LockName);

                # return error message about installation failure
                return "Installation of plugin <b>"
                        .$PluginName."</b> failed: <i>".$ErrMsg."</i>";
            }
        }
        else
        {
            # if plugin version is newer than version in database
            if (version_compare($Attribs["Version"],
                    $Plugin->InstalledVersion()) == 1)
            {
                # set default values for any new configuration settings
                $this->SetPluginDefaultConfigValues($Plugin);

                # try to get lock to prevent anyone else from trying to run
                #       upgrade or install at the same time
                $GotLock = $this->AF->GetLock($LockName, FALSE);

                # if could not get lock
                if (!$GotLock)
                {
                    # return error
                    return "Upgrade of plugin <b>"
                            .$PluginName."</b> in progress.";
                }

                # upgrade plugin
                $ErrMsg = $Plugin->Upgrade($Plugin->InstalledVersion());
                $InstallOrUpgradePerformed = TRUE;

                # if upgrade succeeded
                if ($ErrMsg === NULL)
                {
                    # update plugin version in database
                    $Plugin->InstalledVersion($Attribs["Version"]);

                    # release lock
                    $this->AF->ReleaseLock($LockName);
                }
                else
                {
                    # release lock
                    $this->AF->ReleaseLock($LockName);

                    # report error message about upgrade failure
                    return "Upgrade of plugin <b>"
                            .$PluginName."</b> from version <i>"
                            .addslashes($Plugin->InstalledVersion())
                            ."</i> to version <i>"
                            .addslashes($Attribs["Version"])."</i> failed: <i>"
                            .$ErrMsg."</i>";
                }
            }
            # else if plugin version is older than version in database
            elseif (version_compare($Attribs["Version"],
                    $Plugin->InstalledVersion()) == -1)
            {
                # return error message about version conflict
                return "Plugin <b>"
                        .$PluginName."</b> is older (<i>"
                        .addslashes($Attribs["Version"])
                        ."</i>) than previously-installed version (<i>"
                        .addslashes($Plugin->InstalledVersion())
                        ."</i>).";
            }
        }

        # report result to caller
        return $InstallOrUpgradePerformed;
    }

    /**
    * Set any specified default configuration values for plugin.
    * @param object $Plugin Plugin for which to set configuration values.
    * @param bool $Overwrite If TRUE, for those parameters that have
    *       default values specified, any existing values will be
    *       overwritten.  (OPTIONAL, default to FALSE)
    */
    private function SetPluginDefaultConfigValues($Plugin, $Overwrite = FALSE)
    {
        # if plugin has configuration info
        $Attribs = $Plugin->GetAttributes();
        if (isset($Attribs["CfgSetup"]))
        {
            foreach ($Attribs["CfgSetup"] as $CfgValName => $CfgSetup)
            {
                if (isset($CfgSetup["Default"]) && ($Overwrite
                        || ($Plugin->ConfigSetting($CfgValName) === NULL)))
                {
                    $Plugin->ConfigSetting($CfgValName, $CfgSetup["Default"]);
                }
            }
        }
    }

    /**
    * Check plugin dependencies.
    * @param array $Plugins Plugins to check, with plugin names for the index.
    * @param bool $CheckReady If TRUE, plugin ready state will be considered.
    * @return array Array of messages about any plugins that had failed
    *       dependencies, with base plugin name for the index.
    */
    private function CheckDependencies($Plugins, $CheckReady = FALSE)
    {
        # look until all enabled plugins check out okay
        $ErrMsgs = array();
        do
        {
            # start out assuming all plugins are okay
            $AllOkay = TRUE;

            # for each plugin
            foreach ($Plugins as $PluginName => $Plugin)
            {
                # if plugin is enabled and not checking for ready
                #       or plugin is ready
                if ($Plugin->IsEnabled() && (!$CheckReady || $Plugin->IsReady()))
                {
                    # load plugin attributes
                    if (!isset($Attribs[$PluginName]))
                    {
                        $Attribs[$PluginName] = $Plugin->GetAttributes();
                    }

                    # for each dependency for this plugin
                    foreach ($Attribs[$PluginName]["Requires"]
                            as $ReqName => $ReqVersion)
                    {
                        # handle PHP version requirements
                        if ($ReqName ==  "PHP")
                        {
                            if (version_compare($ReqVersion, phpversion(), ">"))
                            {
                                $ErrMsgs[$PluginName][] = "PHP version "
                                    ."<i>".$ReqVersion."</i>"
                                    ." required by <b>".$PluginName."</b>"
                                    ." was not available.  (Current PHP version"
                                    ." is <i>".phpversion()."</i>.)";
                            }
                        }
                        # handle PHP extension requirements
                        elseif (preg_match("/^PHPX_/", $ReqName))
                        {
                            list($Dummy, $ExtensionName) = explode("_", $ReqName, 2);
                            if (!extension_loaded($ExtensionName))
                            {
                                $ErrMsgs[$PluginName][] = "PHP extension "
                                    ."<i>".$ExtensionName."</i>"
                                    ." required by <b>".$PluginName."</b>"
                                    ." was not available.";
                            }
                            elseif (($ReqVersion !== TRUE)
                                    && (phpversion($ExtensionName) !== FALSE)
                                    && version_compare($ReqVersion,
                                            phpversion($ExtensionName), ">"))
                            {
                                $ErrMsgs[$PluginName][] = "PHP extension "
                                    ."<i>".$ExtensionName."</i>"
                                    ." version <i>".$ReqVersion."</i>"
                                    ." required by <b>".$PluginName."</b>"
                                    ." was not available.  (Current version"
                                    ." of extension <i>".$ExtensionName."</i>"
                                    ." is <i>".phpversion($ExtensionName)."</i>.)";
                            }
                        }
                        # handle dependencies on other plugins
                        else
                        {
                            # load plugin attributes if not already loaded
                            if (isset($Plugins[$ReqName])
                                    && !isset($Attribs[$ReqName]))
                            {
                                $Attribs[$ReqName] =
                                        $Plugins[$ReqName]->GetAttributes();
                            }

                            # if target plugin is not present or is too old
                            #       or is not enabled
                            #       or (if appropriate) is not ready
                            if (!isset($Plugins[$ReqName])
                                    || version_compare($ReqVersion,
                                            $Attribs[$ReqName]["Version"], ">")
                                    || !$Plugins[$ReqName]->IsEnabled()
                                    || ($CheckReady
                                            && !$Plugins[$ReqName]->IsReady()))
                            {
                                # add error message
                                $ErrMsgs[$PluginName][] = "Plugin <i>"
                                        .$ReqName." ".$ReqVersion."</i>"
                                        ." required by <b>".$PluginName."</b>"
                                        ." was not available.";
                            }
                        }

                        # if problem was found with plugin
                        if (isset($ErrMsgs[$PluginName]))
                        {
                            # remove plugin from our list
                            unset($Plugins[$PluginName]);

                            # set flag to indicate a plugin had to be dropped
                            $AllOkay = FALSE;
                        }
                    }
                }
            }
        } while ($AllOkay == FALSE);

        # return messages about any dropped plugins back to caller
        return $ErrMsgs;
    }

    /**
    * Sort the given array of plugins according to their initialization
    * preferences.
    * @param array $Plugins Array of Plugin objects with plugin base name
    *       for the array index.
    * @return array Sorted array of Plugin objects with plugin base name
    *       for the array index.
    */
    private function SortPluginsByInitializationPrecedence($Plugins)
    {
        # load plugin attributes
        foreach ($Plugins as $PluginName => $Plugin)
        {
            $PluginAttribs[$PluginName] = $Plugin->GetAttributes();
        }

        # determine initialization order
        $PluginsAfterUs = array();
        foreach ($PluginAttribs as $PluginName => $Attribs)
        {
            foreach ($Attribs["InitializeBefore"] as $OtherPluginName)
            {
                $PluginsAfterUs[$PluginName][] = $OtherPluginName;
            }
            foreach ($Attribs["InitializeAfter"] as $OtherPluginName)
            {
                $PluginsAfterUs[$OtherPluginName][] = $PluginName;
            }
        }

        # infer other initialization order cues from lists of required plugins
        foreach ($PluginAttribs as $PluginName => $Attribs)
        {
            # for each required plugin
            foreach ($Attribs["Requires"]
                    as $RequiredPluginName => $RequiredPluginVersion)
            {
                # skip the requirement if it it not for another known plugin
                if (!isset($PluginAttribs[$RequiredPluginName]))
                {
                    continue;
                }

                # if there is not a requirement in the opposite direction
                if (!array_key_exists($PluginName,
                        $PluginAttribs[$RequiredPluginName]["Requires"]))
                {
                    # if the required plugin is not scheduled to be after us
                    if (!array_key_exists($PluginName, $PluginsAfterUs)
                            || !in_array($RequiredPluginName,
                                    $PluginsAfterUs[$PluginName]))
                    {
                        # if we are not already scheduled to be after the required plugin
                        if (!array_key_exists($PluginName, $PluginsAfterUs)
                                || !in_array($RequiredPluginName,
                                        $PluginsAfterUs[$PluginName]))
                        {
                            # schedule us to be after the required plugin
                            $PluginsAfterUs[$RequiredPluginName][] =
                                    $PluginName;
                        }
                    }
                }
            }
        }

        # keep track of those plugins we have yet to do and those that are done
        $UnsortedPlugins = array_keys($Plugins);
        $PluginsProcessed = array();

        # limit the number of iterations of the plugin ordering loop
        # to 10 times the number of plugins we have
        $MaxIterations = 10 * count($UnsortedPlugins);
        $IterationCount = 0;

        # iterate through all the plugins that need processing
        while (($NextPlugin = array_shift($UnsortedPlugins)) !== NULL)
        {
            # check to be sure that we're not looping forever
            $IterationCount++;
            if ($IterationCount > $MaxIterations)
            {
                throw new Exception(
                        "Max iteration count exceeded trying to determine plugin"
                        ." loading order.  Is there a dependency loop?");
            }

            # if no plugins require this one, it can go last
            if (!isset($PluginsAfterUs[$NextPlugin]))
            {
                $PluginsProcessed[$NextPlugin] = $MaxIterations;
            }
            else
            {
                # for plugins that are required by others
                $Index = $MaxIterations;
                foreach ($PluginsAfterUs[$NextPlugin] as $GoBefore)
                {
                    if (!isset($PluginsProcessed[$GoBefore]))
                    {
                        # if there is something that requires us which hasn't
                        # yet been assigned an order, then we can't determine
                        # our own place on this iteration
                        array_push($UnsortedPlugins, $NextPlugin);
                        continue 2;
                    }
                    else
                    {
                        # otherwise, make sure that we're loaded
                        # before the earliest of the things that require us
                        $Index = min($Index, $PluginsProcessed[$GoBefore] - 1);
                    }
                }
                $PluginsProcessed[$NextPlugin] = $Index;
            }
        }

        # arrange plugins according to our ordering
        asort($PluginsProcessed, SORT_NUMERIC);
        $SortedPlugins = array();
        foreach ($PluginsProcessed as $PluginName => $SortOrder)
        {
            $SortedPlugins[$PluginName] = $Plugins[$PluginName];
        }

        # return sorted list to caller
        return $SortedPlugins;
    }

    /** @cond */
    /**
    * Method hooked to EVENT_PHP_FILE_LOAD to find the appropriate PHP file
    * when a plugin page is to be loaded.  (This method is not meant to be
    * called directly.)
    * @param string $PageName Current page name.
    * @return string Parameter array with updated page name (if appropriate).
    */
    public function FindPluginPhpFile($PageName)
    {
        # build list of possible locations for file
        $Locations = array(
                "local/plugins/%PLUGIN%/pages/%PAGE%.php",
                "plugins/%PLUGIN%/pages/%PAGE%.php",
                "local/plugins/%PLUGIN%/%PAGE%.php",
                "plugins/%PLUGIN%/%PAGE%.php",
                );

        # look for file and return (possibly) updated page to caller
        return $this->FindPluginPageFile($PageName, $Locations);
    }
    /** @endcond */

    /** @cond */
    /**
    * Method hooked to EVENT_HTML_FILE_LOAD to find the appropriate HTML file
    * when a plugin page is to be loaded.  (This method is not meant to be
    * called directly.)
    * @param string $PageName Current page name.
    * @return string Parameter array with updated page name (if appropriate).
    */
    public function FindPluginHtmlFile($PageName)
    {
        # build list of possible locations for file
        $Locations = array(
                "local/plugins/%PLUGIN%/interface/%ACTIVEUI%/%PAGE%.html",
                "plugins/%PLUGIN%/interface/%ACTIVEUI%/%PAGE%.html",
                "local/plugins/%PLUGIN%/interface/%DEFAULTUI%/%PAGE%.html",
                "plugins/%PLUGIN%/interface/%DEFAULTUI%/%PAGE%.html",
                "local/plugins/%PLUGIN%/%PAGE%.html",
                "plugins/%PLUGIN%/%PAGE%.html",
                );

        # find HTML file
        $Params = $this->FindPluginPageFile($PageName, $Locations);

        # if plugin HTML file was found
        if ($Params["PageName"] != $PageName)
        {
            # add subdirectories for plugin to search paths
            $Dir = preg_replace("%^local/%", "", dirname($Params["PageName"]));
            $this->AF->AddImageDirectories(array(
                    "local/".$Dir."/images",
                    $Dir."/images",
                    ));
            $this->AF->AddIncludeDirectories(array(
                    "local/".$Dir."/include",
                    $Dir."/include",
                    ));
            $this->AF->AddFunctionDirectories(array(
                    "local/".$Dir."/include",
                    $Dir."/include",
                    ));
        }

        # return possibly revised HTML file name to caller
        return $Params;
    }
    /** @endcond */

    /**
    * Find the plugin page file with the specified suffix, based on the
    * "P_PluginName_" convention for indicating plugin pages.  If a plugin
    * page file is found, the global variable $G_Plugin is set to the plugin
    * associated with that page.
    * @param string $PageName Current page name.
    * @param array $Locations Array of strings giving possible locations for
    *       file, with %ACTIVEUI%, %PLUGIN%, and %PAGE% used as appropriate.
    * @return string Parameter array with page name (updated if appropriate).
    */
    private function FindPluginPageFile($PageName, $Locations)
    {
        # set up return value assuming we will not find plugin page file
        $ReturnValue["PageName"] = $PageName;

        # look for plugin name and plugin page name in base page name
        preg_match("/P_([A-Za-z].[A-Za-z0-9]*)_([A-Za-z0-9_-]+)/", $PageName, $Matches);

        # if plugin name and plugin page name were found and plugin name is valid
        if (count($Matches) == 3)
        {
            # if plugin is valid and enabled and has its own subdirectory
            $PluginName = $Matches[1];
            if (isset($this->Plugins[$PluginName])
                    && $this->PluginHasDir[$PluginName]
                    && $this->Plugins[$PluginName]->IsEnabled())
            {
                # for each possible location
                $PageName = $Matches[2];
                $ActiveUI = $this->AF->ActiveUserInterface();
                $DefaultUI = $this->AF->DefaultUserInterface();
                foreach ($Locations as $Loc)
                {
                    # make any needed substitutions into path
                    $FileName = str_replace(
                            array("%DEFAULTUI%", "%ACTIVEUI%", "%PLUGIN%", "%PAGE%"),
                            array($DefaultUI, $ActiveUI, $PluginName, $PageName),
                            $Loc);

                    # if file exists in this location
                    if (file_exists($FileName))
                    {
                        # set return value to contain full plugin page file name
                        $ReturnValue["PageName"] = $FileName;

                        # save plugin name as home of current page
                        $this->PageFilePlugin = $PluginName;

                        # set G_Plugin to plugin associated with current page
                        $GLOBALS["G_Plugin"] = $this->GetPluginForCurrentPage();

                        # stop looking
                        break;
                    }
                }
            }
        }

        # return array containing page name or page file name to caller
        return $ReturnValue;
    }
}

/** @cond */
/**
* Helper class for internal use by PluginManager.  This class is used to
* allow plugin methods to be triggered by events that only allow serialized
* callbacks (e.g. periodic events).
* The plugin name and the method to be called are set and then the
* PluginCaller object is serialized out.  When the PluginCaller object is
* unserialized, it retrieves the appropriate plugin object from the
* PluginManager (pointer to PluginManager is set in PluginManager
* constructor) and calls the specified method.
*/
class PluginCaller
{

    /**
    * Class constructor, which stores the plugin name and the name of the
    * method to be called.
    * @param string $PluginName Name of plugin.
    * @param string $MethodName Name of method to be called.
    */
    public function __construct($PluginName, $MethodName)
    {
        $this->PluginName = $PluginName;
        $this->MethodName = $MethodName;
    }

    /**
    * Call the method that was specified in our constructor.  This method
    * accept whatever arguments are appropriate for the specified method
    * and returns values as appropriate for the specified method.
    */
    public function CallPluginMethod()
    {
        $Args = func_get_args();
        $Plugin = self::$Manager->GetPlugin($this->PluginName);
        return call_user_func_array(array($Plugin, $this->MethodName), $Args);
    }

    /**
    * Get full method name as a text string.
    * @return string Method name, including plugin class name.
    */
    public function GetCallbackAsText()
    {
        return $this->PluginName."::".$this->MethodName;
    }

    /**
    * Sleep method, specifying which values are to be saved when we are
    * serialized.
    * @return array Array of names of variables to be saved.
    */
    public function __sleep()
    {
        return array("PluginName", "MethodName");
    }

    /** PluginManager to use to retrieve appropriate plugins. */
    static public $Manager;

    private $PluginName;
    private $MethodName;
}
/** @endcond */

?>
