<?php

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

namespace Icinga\Application;

use Exception;
use Icinga\Authentication\Auth;
use Icinga\Application\Modules\Manager;
use Icinga\Exception\ProgrammingError;

/**
 * Icinga Hook registry
 *
 * Modules making use of predefined hooks have to use this registry
 *
 * Usage:
 * <code>
 * Hook::register('grapher', 'My\\Grapher\\Class');
 * </code>
 */
class Hook
{
    /**
     * Our hook name registry
     *
     * @var array
     */
    protected static $hooks = array();

    /**
     * Hooks that have already been instantiated
     *
     * @var array
     */
    protected static $instances = array();

    /**
     * Namespace prefix
     *
     * @var string
     */
    public static $BASE_NS = 'Icinga\\Application\\Hook\\';

    /**
     * Append this string to base class
     *
     * All base classes renamed to *Hook
     *
     * @var string
     */
    public static $classSuffix = 'Hook';

    /**
     * Reset object state
     */
    public static function clean()
    {
        self::$hooks = array();
        self::$instances = array();
        self::$BASE_NS = 'Icinga\\Application\\Hook\\';
    }

    /**
     * Whether someone registered itself for the given hook name
     *
     * @param   string  $name   One of the predefined hook names
     *
     * @return  bool
     */
    public static function has($name)
    {
        $name = self::normalizeHookName($name);

        if (! array_key_exists($name, self::$hooks)) {
            return false;
        }

        foreach (self::$hooks[$name] as $hook) {
            list($class, $alwaysRun) = $hook;
            if ($alwaysRun || self::hasPermission($class)) {
                return true;
            }
        }

        return false;
    }

    protected static function normalizeHookName($name)
    {
        if (strpos($name, '\\') === false) {
            $parts = explode('/', $name);
            foreach ($parts as & $part) {
                $part = ucfirst($part);
            }

            return implode('\\', $parts);
        }

        return $name;
    }

    /**
     * Create or return an instance of a given hook
     *
     * TODO: Should return some kind of a hook interface
     *
     * @param   string  $name   One of the predefined hook names
     * @param   string  $key    The identifier of a specific subtype
     *
     * @return  mixed
     */
    public static function createInstance($name, $key)
    {
        $name = self::normalizeHookName($name);

        if (!self::has($name)) {
            return null;
        }

        if (isset(self::$instances[$name][$key])) {
            return self::$instances[$name][$key];
        }

        $class = self::$hooks[$name][$key][0];

        if (! class_exists($class)) {
            throw new ProgrammingError(
                'Erraneous hook implementation, class "%s" does not exist',
                $class
            );
        }
        try {
            $instance = new $class();
        } catch (Exception $e) {
            Logger::debug(
                'Hook "%s" (%s) (%s) failed, will be unloaded: %s',
                $name,
                $key,
                $class,
                $e->getMessage()
            );
            // TODO: Persist unloading for "some time" or "current session"
            unset(self::$hooks[$name][$key]);
            return null;
        }

        self::assertValidHook($instance, $name);
        self::$instances[$name][$key] = $instance;
        return $instance;
    }

    protected static function splitHookName($name)
    {
        $sep = '\\';
        if (false === $module = strpos($name, $sep)) {
            return array(null, $name);
        }
        return array(
            substr($name, 0, $module),
            substr($name, $module + 1)
        );
    }

    /**
     * Extract the Icinga module name from a given namespaced class name
     *
     * Does no validation, prefix must have been checked before
     *
     * Shameless copy of ClassLoader::extractModuleName()
     *
     * @param   string  $class  The hook's class path
     *
     * @return string
     */
    protected static function extractModuleName($class)
    {
        return lcfirst(
            substr(
                $class,
                ClassLoader::MODULE_PREFIX_LENGTH,
                strpos(
                    $class,
                    ClassLoader::NAMESPACE_SEPARATOR,
                    ClassLoader::MODULE_PREFIX_LENGTH + 1
                ) - ClassLoader::MODULE_PREFIX_LENGTH
            )
        );
    }

    /**
     * Return whether the user has the permission to access the module which provides the given hook
     *
     * @param   string  $class  The hook's class path
     *
     * @return  bool
     */
    protected static function hasPermission($class)
    {
        if (Icinga::app()->isCli()) {
            return true;
        }

        return Auth::getInstance()->hasPermission(
            Manager::MODULE_PERMISSION_NS . self::extractModuleName($class)
        );
    }

    /**
     * Test for a valid class name
     *
     * @param   mixed   $instance
     * @param   string  $name
     *
     * @throws  ProgrammingError
     */
    private static function assertValidHook($instance, $name)
    {
        $name = self::normalizeHookName($name);

        $suffix = self::$classSuffix; // 'Hook'
        $base = self::$BASE_NS;       // 'Icinga\\Web\\Hook\\'

        list($module, $name) = self::splitHookName($name);

        if ($module === null) {
            $base_class = $base . ucfirst($name) . 'Hook';

            // I'm unsure whether this makes sense. Unused and Wrong.
            if (strpos($base_class, $suffix) === false) {
                $base_class .= $suffix;
            }
        } else {
            $base_class = 'Icinga\\Module\\'
                        . ucfirst($module)
                        . '\\Hook\\'
                        . ucfirst($name)
                        . $suffix;
        }

        if (!$instance instanceof $base_class) {
            // This is a compatibility check. Should be removed one far day:
            if ($module !== null) {
                $compat_class = 'Icinga\\Module\\'
                              . ucfirst($module)
                              . '\\Web\\Hook\\'
                              . ucfirst($name)
                              . $suffix;

                if ($instance instanceof $compat_class) {
                    return;
                }
            }

            throw new ProgrammingError(
                '%s is not an instance of %s',
                get_class($instance),
                $base_class
            );
        }
    }

    /**
     * Return all instances of a specific name
     *
     * @param   string  $name   One of the predefined hook names
     *
     * @return  array
     */
    public static function all($name): array
    {
        $name = self::normalizeHookName($name);
        if (! self::has($name)) {
            return [];
        }

        foreach (self::$hooks[$name] as $key => $hook) {
            list($class, $alwaysRun) = $hook;
            if ($alwaysRun || self::hasPermission($class)) {
                self::createInstance($name, $key);
            }
        }

        return self::$instances[$name] ?? [];
    }

    /**
     * Get the first hook
     *
     * @param   string  $name   One of the predefined hook names
     *
     * @return  null|mixed
     */
    public static function first($name)
    {
        $name = self::normalizeHookName($name);

        if (self::has($name)) {
            foreach (self::$hooks[$name] as $key => $hook) {
                list($class, $alwaysRun) = $hook;
                if ($alwaysRun || self::hasPermission($class)) {
                    return self::createInstance($name, $key);
                }
            }
        }
    }

    /**
     * Register a class
     *
     * @param   string      $name           One of the predefined hook names
     * @param   string      $key            The identifier of a specific subtype
     * @param   string      $class          Your class name, must inherit one of the
     *                                      classes in the Icinga/Application/Hook folder
     * @param   bool        $alwaysRun      To run the hook always (e.g. without permission check)
     */
    public static function register($name, $key, $class, $alwaysRun = false)
    {
        $name = self::normalizeHookName($name);

        if (!isset(self::$hooks[$name])) {
            self::$hooks[$name] = array();
        }

        $class = ltrim($class, ClassLoader::NAMESPACE_SEPARATOR);

        self::$hooks[$name][$key] = [$class, $alwaysRun];
    }
}
