<?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_PAGE_LOAD", array($this, "FindPluginPhpFile"));
        $this->AF->HookEvent("EVENT_HTML_PAGE_LOAD", array($this, "FindPluginHtmlFile"));
    }

    /**
    * 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"))
                {
                    # 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>";
            }
        }

        # install or upgrade plugins if needed
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $this->InstallPlugin($Plugin);
            }
        }

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

        # initialize each plugin
        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 each plugin
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $Events = $Plugin->DeclareEvents();
                if (count($Events)) {  $this->AF->RegisterEvent($Events);  }
            }
        }

        # hook plugins to events
        foreach ($this->Plugins as $PluginName => $Plugin)
        {
            if ($this->PluginEnabled[$PluginName])
            {
                $EventsToHook = $Plugin->HookEvents();
                foreach ($EventsToHook as $EventName => $PluginMethod)
                {
                    $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]);
            }
        }

        # add plugin directories to list to be searched for object files
        $ObjDirs = array();
        foreach ($this->PluginDirs as $Dir)
        {
            $ObjDirs[$Dir] = "";
        }
        $this->AF->AddObjectDirectories($ObjDirs);

        # 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 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"] = 
                    $this->PluginInfo[$PluginName]["Enabled"];
        }
        return $Info;
    }

    /**
    * 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];
    }


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

    private $Plugins = array();
    private $PluginFiles = array();
    private $PluginNames = array();
    private $PluginDirs = array();
    private $PluginInfo = array();
    private $PluginEnabled = array();
    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)
    {
        # retrieve info about plugin from database
        $PluginName = get_class($Plugin);
        $this->DB->Query("SELECT * FROM PluginInfo"
                ." WHERE BaseName = '".addslashes($PluginName)."'");

        # if plugin was not found in database
        $Attribs = $Plugin->GetAttributes();
        if ($this->DB->NumRowsSelected() == 0)
        {
            # 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)."'");
        }

        # store plugin settings for later use
        $this->PluginInfo[$PluginName] = $this->DB->FetchRow();
        $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)."'");
                }
                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)
                    {
                        # if target plugin is not present or is disabled or is too old
                        if (isset($this->Plugins[$ReqName])
                                && !isset($Attribs[$ReqName]))
                        {
                            $Attribs[$ReqName] = 
                                    $this->Plugins[$ReqName]->GetAttributes();
                        }
                        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;
            }
        }

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

?>
