<?php
/**
 * Web Application Component Toolkit
 *
 * @link http://www.phpwact.org/
 *
 * @author Wact Development Team
 * @link http://www.phpwact.org/team
 *
 * @copyright Copyright 2006, Jeff Moore
 * @license http://opensource.org/licenses/mit-license.php MIT
 */

/**
 * Manage and Load plugin classes.
 * This class should be serializable for caching plugin locations
 * The caller must cannonicalize plugin names before passing to the plugin loader
 */
class Wact_Plugin_Loader {

    /**
    */
    protected $searchPath = array();

    /**
    */
    protected $fileSuffix;

    /**
    */
    protected $classFiles = array();

    /**
    */
    protected $classNames = array();
    
    /**
    */
    protected $haveAllBeenFound = FALSE;

    /**
    *
    */
    function __construct($suffix = '.inc.php') {
        $this->fileSuffix = $suffix;
    }
    
    /**
    * Add a path to search in for plugins or change the class prefix of an existing path
    */
    function addPath($path, $classPrefix = NULL) {
        if (empty($classPrefix)) {
            // We're ignorant of namespaces by default
            $classPrefix = str_replace(DIRECTORY_SEPARATOR, '_', rtrim($path, DIRECTORY_SEPARATOR));
        }
        $classPrefix = rtrim($classPrefix, '_') . '_';
        $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        if (!isset($this->searchPath[$path]) || $this->searchPath[$path] != $classPrefix) {
            $this->searchPath[$path] = $classPrefix;
            $this->clearCache();
        }
        return $this;  // stay fluent
    }

    /**
    * remove a path
    */
    function removePath($path) {
        $path   = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
        if (isset($this->searchPath[$path])) {
            unset($this->searchPath[$path]);
            $this->clearCache();
        }
    }

    /**
    * Clear any cached information.
    */
    function clearCache() {
        $this->classNames = array();
        $this->classFiles = array();
        $this->haveAllBeenFound = FALSE;
    }

    /**
    * @return array Known plugin names
    */
    function getKnown() {
        $known = array();
        foreach($this->classNames as $name => $found) {
            if ($found) {
                $known[] = $name;
            }
        }
        return $known;
    }

    /**
    * Scan for all plugins
    * Helpful for warming up the cache before serializing or before calling getKnown.
    */
    function scanAll() {
        if ($this->haveAllBeenFound) {
            return;
        }
        
        $include_path = array_reverse(explode(PATH_SEPARATOR, get_include_path()));

        $pattern  = '|^(.*)'. preg_quote( $this->fileSuffix, '|') . '$|';

        // @TODO: Support recursion
        foreach (array_reverse($this->searchPath) as $path => $classPrefix) {
            foreach($include_path as $include_dir) {
                $dir = $include_dir . DIRECTORY_SEPARATOR . $path;
                if (is_dir($dir)) {
                    if ($dh = opendir($dir)) {
                        while (($file = readdir($dh)) !== false) {
                            if (preg_match($pattern, $file, $match)) {
                                $name = $match[1];
                                $className = $classPrefix . $name;
                                $classFile = $path . $file;
                                if (empty($this->classNames[$name])) {
                                    $this->classNames[$name] = $className;
                                    $this->classFiles[strtolower($className)] = $classFile;
                                }
                            }
                        }
                        closedir($dh);
                    }
                }
            }
        }

        $this->haveAllBeenFound = TRUE;
    }
    
    /**
    * Search for a plugin class by name.  Make sure the plugin class is loaded.
    * Note, the case of the class name returned is not guarenteed to match the case of the actual Class.
    * The case of the plugin name must match that of the portion of the file name.
    * The allowable characters in the plugin name are those allowed in a PHP class name, plus 
    * The directory separator.
    * @param string $name Name of the plugin to load
    * @return string|bool The name of the class loaded or FALSE if no class could be loaded
    */
    function load($name) {
        if (isset($this->classNames[$name])) {
            if (!$this->classNames[$name]) {
                return FALSE;
            }
            
            $className = $this->classNames[$name];

            // Does the class we're looking for already exist via other means?
            if (class_exists($className, false)) {
                return $className;
            }

            // Our cache knows the class name, but it doesn't exist.  Do we know the file?
            if (isset($this->classFiles[strtolower($className)])) {
                include $this->classFiles[strtolower($className)];
                return $className;
            }
            
            // We knew the class but not the file?  Our cache is broken!  Should never happen.
            trigger_error("Invalid plugin cache state for $name", E_USER_WARNING);
            $this->clearCache();
        }
        
        if ($this->haveAllBeenFound) {
            return FALSE;
        }
        
        foreach ($this->searchPath as $path => $classPrefix) {
            $className = $classPrefix . str_replace(DIRECTORY_SEPARATOR, '_', $name);

            // Don't even look for invalid class names
            if (!preg_match('/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/', $className)) {
                continue;
            }

            // Does the class we're looking for already exist via other means?
            if (class_exists($className, false)) {
                return $className;
            }

            $classFile = $path . $name . $this->fileSuffix;
            if($fp = @fopen($classFile, 'r', true)) {
                fclose($fp);
                include $classFile;
                $this->classNames[$name] = $className;
                $this->classFiles[strtolower($className)] = $classFile;
                return $className;
            }            

        }

        // record the miss
        $this->classNames[$name] = FALSE;
        return FALSE;
    }
    
}
