<?php

// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Application;

/**
 * PSR-4 class loader
 */
class ClassLoader
{
    /**
     * Namespace separator
     */
    const NAMESPACE_SEPARATOR = '\\';

    /**
     * Icinga Web 2 module namespace prefix
     */
    const MODULE_PREFIX = 'Icinga\\Module\\';

    /**
     * Icinga Web 2 module namespace prefix length
     *
     * Helps to make substr/strpos operations even faster
     */
    const MODULE_PREFIX_LENGTH = 14;

    /**
     * A hardcoded class/subdir map for application ns prefixes
     *
     * When a module registers with an application directory, those
     * namespace prefixes (after the module prefix) will be looked up
     * in the corresponding application subdirectories
     *
     * @var array
     */
    protected $applicationPrefixes = array(
        'Clicommands' => 'clicommands',
        'Controllers' => 'controllers',
        'Forms'       => 'forms'
    );

    /**
     * Whether we already instantiated the ZF autoloader
     *
     * @var boolean
     */
    protected $gotZend = false;

    /**
     * Namespaces
     *
     * @var array
     */
    private $namespaces = array();

    /**
     * Application directories
     *
     * @var array
     */
    private $applicationDirectories = array();

    /**
     * Register a base directory for a namespace prefix
     *
     * Application directory is optional and provides additional lookup
     * logic for hardcoded namespaces like "Forms"
     *
     * @param   string  $namespace
     * @param   string  $directory
     * @param   string  $appDirectory
     *
     * @return  $this
     */
    public function registerNamespace($namespace, $directory, $appDirectory = null)
    {
        $this->namespaces[$namespace] = $directory;

        if ($appDirectory !== null) {
            $this->applicationDirectories[$namespace] = $appDirectory;
        }

        return $this;
    }

    /**
     * Test whether a namespace exists
     *
     * @param   string $namespace
     *
     * @return  bool
     */
    public function hasNamespace($namespace)
    {
        return array_key_exists($namespace, $this->namespaces);
    }

    /**
     * Get the source file of the given class or interface
     *
     * @param   string      $class Name of the class or interface
     *
     * @return  string|null
     */
    public function getSourceFile($class)
    {
        if ($file = $this->getModuleSourceFile($class)) {
            return $file;
        }

        foreach ($this->namespaces as $namespace => $dir) {
            if ($class === strstr($class, "$namespace\\")) {
                return $this->buildClassFilename($class, $namespace);
            }
        }

        return null;
    }

    /**
     * Get the source file of the given module class or interface
     *
     * @param   string      $class Module class or interface name
     *
     * @return  string|null
     */
    protected function getModuleSourceFile($class)
    {
        if (! $this->classBelongsToModule($class)) {
            return null;
        }

        $modules = Icinga::app()->getModuleManager();
        $namespace = $this->extractModuleNamespace($class);

        if ($this->hasNamespace($namespace)) {
            return $this->buildClassFilename($class, $namespace);
        } elseif (! $modules->loadedAllEnabledModules()) {
            $moduleName = $this->extractModuleName($class);

            if ($modules->hasEnabled($moduleName)) {
                $modules->loadModule($moduleName);

                return $this->buildClassFilename($class, $namespace);
            }
        }

        return null;
    }

    /**
     * Extract the Icinga module namespace from a given namespaced class name
     *
     * Does no validation, prefix must have been checked before
     *
     * @return string
     */
    protected function extractModuleNamespace($class)
    {
        return substr(
            $class,
            0,
            strpos($class, self::NAMESPACE_SEPARATOR, self::MODULE_PREFIX_LENGTH + 1)
        );
    }

    /**
     * Extract the Icinga module name from a given namespaced class name
     *
     * Does no validation, prefix must have been checked before
     *
     * @return string
     */
    public static function extractModuleName($class)
    {
        return lcfirst(
            substr(
                $class,
                self::MODULE_PREFIX_LENGTH,
                strpos(
                    $class,
                    self::NAMESPACE_SEPARATOR,
                    self::MODULE_PREFIX_LENGTH + 1
                ) - self::MODULE_PREFIX_LENGTH
            )
        );
    }

    /**
     * Whether the given class name belongs to a module namespace
     *
     * @return boolean
     */
    public static function classBelongsToModule($class)
    {
        return substr($class, 0, self::MODULE_PREFIX_LENGTH) === self::MODULE_PREFIX;
    }

    /**
     * Prepare a filename string for the given class
     *
     * Expects the given namespace to be registered with a path name
     *
     * @return string
     */
    protected function buildClassFilename($class, $namespace)
    {
        $relNs = substr($class, strlen($namespace) + 1);

        if ($this->namespaceHasApplictionDirectory($namespace)) {
            $prefixSeparator = strpos($relNs, self::NAMESPACE_SEPARATOR);
            $prefix = substr($relNs, 0, $prefixSeparator);

            if ($this->isApplicationPrefix($prefix)) {
                return $this->applicationDirectories[$namespace]
                    . DIRECTORY_SEPARATOR
                    . $this->applicationPrefixes[$prefix]
                    . $this->classToRelativePhpFilename(substr($relNs, $prefixSeparator));
            }
        }

        return $this->namespaces[$namespace] . DIRECTORY_SEPARATOR . $this->classToRelativePhpFilename($relNs);
    }

    /**
     * Return the relative file name for the given (namespaces) class
     *
     * @param  string $class
     *
     * @return string
     */
    protected function classToRelativePhpFilename($class)
    {
        return str_replace(
            self::NAMESPACE_SEPARATOR,
            DIRECTORY_SEPARATOR,
            $class
        ) . '.php';
    }

    /**
     * Whether given prefix (Forms, Controllers...) makes part of "application"
     *
     * @param  string $prefix
     *
     * @return boolean
     */
    protected function isApplicationPrefix($prefix)
    {
        return array_key_exists($prefix, $this->applicationPrefixes);
    }

    /**
     * Whether the given namespace registered an application directory
     *
     * @return boolean
     */
    protected function namespaceHasApplictionDirectory($namespace)
    {
        return array_key_exists($namespace, $this->applicationDirectories);
    }

    /**
     * Load the given class or interface
     *
     * @param   string  $class  Name of the class or interface
     *
     * @return  bool            Whether the class or interface has been loaded
     */
    public function loadClass($class)
    {
        if ($file = $this->getSourceFile($class)) {
            if (file_exists($file)) {
                require $file;
                return true;
            }
        }

        return false;
    }

    /**
     * Register {@link loadClass()} as an autoloader
     */
    public function register()
    {
        spl_autoload_register(array($this, 'loadClass'));
    }

    /**
     * Unregister {@link loadClass()} as an autoloader
     */
    public function unregister()
    {
        spl_autoload_unregister(array($this, 'loadClass'));
    }

    /**
     * Unregister this as an autoloader
     */
    public function __destruct()
    {
        $this->unregister();
    }
}
