<?PHP

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

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

    /**
    * PluginManager class constructor.
    * @param AppFramework ApplicationFramework within which plugins should run.
    * @param PluginDirectories Array of names of directories containing plugins.
    */
    function __construct($AppFramework, $PluginDirectories)
    {
        # save framework and directory list for later use
        $this->AF = $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.
    */
    function LoadPlugins()
    {
        # clear any existing errors
        $this->ErrMsgs = array();

        # load list of all base plugin files
        $this->FindPlugins($this->DirsToSearch);

        # for each plugin found
        foreach ($this->PluginNames as $PluginName)
        {
            # bring in plugin class file
            include_once($this->PluginFiles[$PluginName]);

            # if plugin class was defined by file
            if (class_exists($PluginName))
            {
                # if plugin class is a valid descendant of base plugin class
                $Plugin = new $PluginName;
                if (is_subclass_of($Plugin, "Plugin"))
                {
                    # set hooks needed for plugin to access plugin manager services
                    $Plugin->SetCfgSaveCallback(array(__CLASS__, "CfgSaveCallback"));

                    # register the plugin
                    $this->Plugins[$PluginName] = $Plugin;
                    $this->PluginEnabled[$PluginName] = TRUE;
                    $this->Plugins[$PluginName]->Register();

                    # check required plugin attributes
                    $Attribs[$PluginName] = $this->Plugins[$PluginName]->GetAttributes();
                    if (!strlen($Attribs[$PluginName]["Name"]))
                    {
                        $this->ErrMsgs[$PluginName][] = "Plugin <b>".$PluginName."</b>"
                                ." could not be loaded because it"
                                ." did not have a <i>Name</i> attribute set.";
                        unset($this->PluginEnabled[$PluginName]);
                        unset($this->Plugins[$PluginName]);
                    }
                    if (!strlen($Attribs[$PluginName]["Version"]))
                    {
                        $this->ErrMsgs[$PluginName][] = "Plugin <b>".$PluginName."</b>"
                                ." could not be loaded because it"
                                ." did not have a <i>Version</i> attribute set.";
                        unset($this->PluginEnabled[$PluginName]);
                        unset($this->Plugins[$PluginName]);
                    }
                }
                else
                {
                    $this->ErrMsgs[$PluginName][] = "Plugin <b>".$PluginName."</b>"
                            ." could not be loaded because <i>".$PluginName."</i> was"
                            ." not a subclass of base <i>Plugin</i> class";
                }
            }
            else
            {
                $this->ErrMsgs[$PluginName][] = "Expected class <i>".$PluginName
                        ."</i> not found in plugin file <i>"
                        .$this->PluginFiles[$PluginName]."</i>";
            }
        }

        # check plugin dependencies
        $this->CheckDependencies();

        # load plugin configurations
        $this->DB->Query("SELECT BaseName,Cfg FROM PluginInfo");
        $Cfgs = $this->DB->FetchColumn("Cfg", "BaseName");

        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                # add plugin directory (if it exists) to class autoloading list
                if (array_key_exists($PluginName, $this->PluginDirs))
                {
                    ApplicationFramework::AddObjectDirectory(
                            $this->PluginDirs[$PluginName]);
                }

                # set configuration values if available
                if (isset($Cfgs[$PluginName]))
                {
                    $Plugin->SetAllCfg(unserialize($Cfgs[$PluginName]));
                }

                # install or upgrade plugins if needed
                $this->InstallPlugin($Plugin);
            }
        }

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

        # initialize enabled plugins
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $ErrMsg = $Plugin->Initialize();
                if ($ErrMsg !== NULL)
                {
                    $this->ErrMsgs[$PluginName][] = "Initialization failed for"
                            ." plugin <b>".$PluginName."</b>: <i>".$ErrMsg."</i>";
                    $this->PluginEnabled[$PluginName] = FALSE;
                }
            }
        }

        # register any events declared by enabled plugins
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $Events = $Plugin->DeclareEvents();
                if (count($Events)) {  $this->AF->RegisterEvent($Events);  }
            }
        }

        # hook enabled plugins to events
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $EventsToHook = $Plugin->HookEvents();
                if (count($EventsToHook))
                {
                    foreach ($EventsToHook as $EventName => $PluginMethod)
                    {
                        if ($this->AF->IsStaticOnlyEvent($EventName))
                        {
                            $Caller = new PluginCaller($PluginName, $PluginMethod);
                            $Result = $this->AF->HookEvent(
                                    $EventName, array($Caller, "CallPluginMethod"));
                        }
                        else
                        {
                            $Result = $this->AF->HookEvent(
                                    $EventName, array($Plugin, $PluginMethod));
                        }
                        if ($Result === FALSE)
                        {
                            $this->ErrMsgs[$PluginName][] =
                                    "Unable to hook requested event <i>"
                                    .$EventName."</i> for plugin <b>".$PluginName."</b>";
                        }
                    }
                }
            }
        }

        # limit plugin directory list to only active plugins
        foreach ($this->PluginEnabled as $PluginName => $Enabled)
        {
            if (isset($this->PluginDirs[$PluginName]) && !$Enabled)
            {
                unset($this->PluginDirs[$PluginName]);
            }
        }

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

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

    /**
    * Retrieve specified plugin.
    * @param PluginName Base name of plugin.
    * @return Plugin object or NULL if no plugin found with specified name.
    */
    function GetPlugin($PluginName)
    {
        return isset($this->Plugins[$PluginName])
                ? $this->Plugins[$PluginName] : NULL;
    }

    /**
    * 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.
    */
    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.
    */
    function GetPluginAttributes()
    {
        $Info = array();
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            $Info[$PluginName] = $Plugin->GetAttributes();
            $Info[$PluginName]["Enabled"] =
                    isset($this->PluginInfo[$PluginName]["Enabled"])
                    ? $this->PluginInfo[$PluginName]["Enabled"] : FALSE;
            $Info[$PluginName]["Installed"] =
                    isset($this->PluginInfo[$PluginName]["Installed"])
                    ? $this->PluginInfo[$PluginName]["Installed"] : FALSE;
        }
        return $Info;
    }

    /**
    * Returns a list of plugins dependent on the specified plugin.
    * @param PluginName Base name of plugin.
    * @return Array of base names of dependent plugins.
    */
    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) plugins.
    * @return Array of base names of active plugins.
    */
    function GetActivePluginList()
    {
        return array_keys($this->PluginEnabled, 1);
    }

    /**
    * Get/set whether specified plugin is enabled.
    * @param PluginName Base name of plugin.
    * @param NewValue TRUE to enable, FALSE to disable.  (OPTIONAL)
    * @return TRUE if plugin is enabled, otherwise FALSE.
    */
    function PluginEnabled($PluginName, $NewValue = NULL)
    {
        if ($NewValue !== NULL)
        {
            $this->DB->Query("UPDATE PluginInfo"
                    ." SET Enabled = ".($NewValue ? "1" : "0")
                    ." WHERE BaseName = '".addslashes($PluginName)."'");
            $this->PluginEnabled[$PluginName] = $NewValue;
            $this->PluginInfo[$PluginName]["Enabled"] = $NewValue;
        }
        return $this->PluginEnabled[$PluginName];
    }

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

        # if plugin is installed
        if ($this->PluginInfo[$PluginName]["Installed"])
        {
            # 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->PluginInfo[$PluginName]);
                unset($this->PluginEnabled[$PluginName]);
                unset($this->PluginNames[$PluginName]);
                unset($this->PluginFiles[$PluginName]);
            }
        }

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


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

    private $Plugins = array();
    private $PluginFiles = array();
    private $PluginNames = array();
    private $PluginDirs = array();
    private $PluginInfo = array();
    private $PluginEnabled = array();
    private $PageFilePlugin = NULL;
    private $AF;
    private $DirsToSearch;
    private $ErrMsgs = array();
    private $DB;

    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))
                    {
                        # add file to list
                        $PluginName = preg_replace("/\.php$/", "", $FileName);
                        $this->PluginNames[$PluginName] = $PluginName;
                        $this->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
                        $PossibleFile = $Dir."/".$FileName."/".$FileName.".php";
                        if (file_exists($PossibleFile))
                        {
                            # add plugin and directory to lists
                            $this->PluginNames[$FileName] = $FileName;
                            $this->PluginFiles[$FileName] = $PossibleFile;
                            $this->PluginDirs[$FileName] = $Dir."/".$FileName;
                        }
                        else
                        {
                            $this->ErrMsgs[$FileName][] =
                                    "Expected plugin file <i>".$FileName.".php</i> not"
                                    ." found in plugin subdirectory <i>"
                                    .$Dir."/".$FileName."</i>";
                        }
                    }
                }
            }
        }

        # return list of base plugin files to caller
        return $PluginFiles;
    }

    private function InstallPlugin($Plugin)
    {
        # cache all plugin info from database
        if (count($this->PluginInfo) == 0)
        {
            $this->DB->Query("SELECT * FROM PluginInfo");
            while ($Row = $this->DB->FetchRow())
            {
                $this->PluginInfo[$Row["BaseName"]] = $Row;
            }
        }

        # if plugin was not found in database
        $PluginName = get_class($Plugin);
        $Attribs = $Plugin->GetAttributes();
        if (!isset($this->PluginInfo[$PluginName]))
        {
            # add plugin to database
            $this->DB->Query("INSERT INTO PluginInfo"
                    ." (BaseName, Version, Installed, Enabled)"
                    ." VALUES ('".addslashes($PluginName)."', "
                    ." '".addslashes($Attribs["Version"])."', "
                    ."0, "
                    ." ".($Attribs["EnabledByDefault"] ? 1 : 0).")");

            # read plugin settings back in
            $this->DB->Query("SELECT * FROM PluginInfo"
                 ." WHERE BaseName = '".addslashes($PluginName)."'");
            $this->PluginInfo[$PluginName] = $this->DB->FetchRow();
        }

        # store plugin settings for later use
        $this->PluginEnabled[$PluginName] = $this->PluginInfo[$PluginName]["Enabled"];

        # if plugin is enabled
        if ($this->PluginEnabled[$PluginName])
        {
            # if plugin has not been installed
            if (!$this->PluginInfo[$PluginName]["Installed"])
            {
                # install plugin
                $ErrMsg = $Plugin->Install();

                # if install succeeded
                if ($ErrMsg == NULL)
                {
                    # mark plugin as installed
                    $this->DB->Query("UPDATE PluginInfo SET Installed = 1"
                            ." WHERE BaseName = '".addslashes($PluginName)."'");
                    $this->PluginInfo[$PluginName]["Installed"] = 1;
                }
                else
                {
                    # disable plugin
                    $this->PluginEnabled[$PluginName] = FALSE;

                    # record error message about installation failure
                    $this->ErrMsgs[$PluginName][] = "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"],
                        $this->PluginInfo[$PluginName]["Version"]) == 1)
                {
                    # upgrade plugin
                    $ErrMsg = $Plugin->Upgrade($this->PluginInfo[$PluginName]["Version"]);

                    # if upgrade succeeded
                    if ($ErrMsg == NULL)
                    {
                        # update plugin version in database
                        $Attribs = $Plugin->GetAttributes();
                        $this->DB->Query("UPDATE PluginInfo"
                                ." SET Version = '".addslashes($Attribs["Version"])."'"
                                ." WHERE BaseName = '".addslashes($PluginName)."'");
                        $this->PluginInfo[$PluginName]["Version"] = $Attribs["Version"];
                    }
                    else
                    {
                        # disable plugin
                        $this->PluginEnabled[$PluginName] = FALSE;

                        # record error message about upgrade failure
                        $this->ErrMsgs[$PluginName][] = "Upgrade of plugin <b>"
                                .$PluginName."</b> from version <i>"
                                .addslashes($this->PluginInfo[$PluginName]["Version"])
                                ."</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"],
                        $this->PluginInfo[$PluginName]["Version"]) == -1)
                {
                    # disable plugin
                    $this->PluginEnabled[$PluginName] = FALSE;

                    # record error message about version conflict
                    $this->ErrMsgs[$PluginName][] = "Plugin <b>"
                            .$PluginName."</b> is older (<i>"
                            .addslashes($Attribs["Version"])
                            ."</i>) than previously-installed version (<i>"
                            .addslashes($this->PluginInfo[$PluginName]["Version"])."</i>).";
                }
            }
        }
    }

    private function CheckDependencies()
    {
        # look until all enabled plugins check out okay
        do
        {
            # start out assuming all plugins are okay
            $AllOkay = TRUE;

            # for each plugin
            foreach ($this->Plugins as $PluginName => $Plugin)
            {
                # if plugin is currently enabled
                if ($this->PluginEnabled[$PluginName])
                {
                    # load plugin attributes
                    if (!isset($Attribs[$PluginName]))
                    {
                        $Attribs[$PluginName] =
                                $this->Plugins[$PluginName]->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(), ">"))
                            {
                                $this->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) = split("_", $ReqName, 2);
                            if (!extension_loaded($ExtensionName))
                            {
                                $this->ErrMsgs[$PluginName][] = "PHP extension "
                                    ."<i>".$ExtensionName."</i>"
                                    ." required by <b>".$PluginName."</b>"
                                    ." was not available.";
                            }
                        }
                        # handle dependencies on other plugins
                        else
                        {
                            # load plugin attributes if not already loaded
                            if (isset($this->Plugins[$ReqName])
                                    && !isset($Attribs[$ReqName]))
                            {
                                $Attribs[$ReqName] =
                                        $this->Plugins[$ReqName]->GetAttributes();
                            }

                            # if target plugin is not present or is disabled or is too old
                            if (!isset($this->PluginEnabled[$ReqName])
                                    || !$this->PluginEnabled[$ReqName]
                                    || (version_compare($ReqVersion,
                                            $Attribs[$ReqName]["Version"], ">")))
                            {
                                # add error message and disable plugin
                                $this->ErrMsgs[$PluginName][] = "Plugin <i>"
                                        .$ReqName." ".$ReqVersion."</i>"
                                        ." required by <b>".$PluginName."</b>"
                                        ." was not available.";
                                $this->PluginEnabled[$PluginName] = FALSE;

                                # set flag indicating plugin did not check out
                                $AllOkay = FALSE;
                            }
                        }
                    }
                }
            }
        } while ($AllOkay == FALSE);
    }

    /** @cond */
    function FindPluginPhpFile($PageName)
    {
        return $this->FindPluginPageFile($PageName, "php");
    }
    /** @endcond */

    /** @cond */
    function FindPluginHtmlFile($PageName)
    {
        return $this->FindPluginPageFile($PageName, "html");
    }
    /** @endcond */

    private function FindPluginPageFile($PageName, $Suffix)
    {
        # 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 base page name contained name of existing plugin with its own subdirectory
        if ((count($Matches) == 3) && isset($this->PluginDirs[$Matches[1]]))
        {
            # if PHP file with specified name exists in plugin subdirectory
            $PageFile = $this->PluginDirs[$Matches[1]]."/".$Matches[2].".".$Suffix;
            if (file_exists($PageFile))
            {
                # set return value to contain full plugin PHP file name
                $ReturnValue["PageName"] = $PageFile;

                # save plugin name as home of current page
                $this->PageFilePlugin = $Matches[1];
            }
        }

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

    /** @cond */
    static function CfgSaveCallback($BaseName, $Cfg)
    {
        $DB = new Database();
        $DB->Query("UPDATE PluginInfo SET Cfg = '".addslashes(serialize($Cfg))
                ."' WHERE BaseName = '".addslashes($BaseName)."'");
    }
    /** @endcond */
}

/** @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 {

    function __construct($PluginName, $MethodName)
    {
        $this->PluginName = $PluginName;
        $this->MethodName = $MethodName;
    }

    function CallPluginMethod()
    {
        $Args = func_get_args();
        $Plugin = self::$Manager->GetPlugin($this->PluginName);
        return call_user_func_array(array($Plugin, $this->MethodName), $Args);
    }

    function GetCallbackAsText()
    {
        return $this->PluginName."::".$this->MethodName;
    }

    function __sleep()
    {
        return array("PluginName", "MethodName");
    }

    static public $Manager;

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

?>
