<?php

namespace dokuwiki\Remote;

use dokuwiki\Extension\RemotePlugin;
use dokuwiki\Logger;
use dokuwiki\test\Remote\Mock\ApiCore as MockApiCore;

/**
 * This class provides information about remote access to the wiki.
 *
 * == Types of methods ==
 * There are two types of remote methods. The first is the core methods.
 * These are always available and provided by dokuwiki.
 * The other is plugin methods. These are provided by remote plugins.
 *
 * == Information structure ==
 * The information about methods will be given in an array with the following structure:
 * array(
 *     'method.remoteName' => array(
 *          'args' => array(
 *              'type eg. string|int|...|date|file',
 *          )
 *          'name' => 'method name in class',
 *          'return' => 'type',
 *          'public' => 1/0 - method bypass default group check (used by login)
 *          ['doc' = 'method documentation'],
 *     )
 * )
 *
 * plugin names are formed the following:
 *   core methods begin by a 'dokuwiki' or 'wiki' followed by a . and the method name itself.
 *   i.e.: dokuwiki.version or wiki.getPage
 *
 * plugin methods are formed like 'plugin.<plugin name>.<method name>'.
 * i.e.: plugin.clock.getTime or plugin.clock_gmt.getTime
 */
class Api
{
    /** @var ApiCall[] core methods provided by dokuwiki */
    protected $coreMethods;

    /** @var ApiCall[] remote methods provided by dokuwiki plugins */
    protected $pluginMethods;

    /**
     * Get all available methods with remote access.
     *
     * @return ApiCall[] with information to all available methods
     */
    public function getMethods()
    {
        return array_merge($this->getCoreMethods(), $this->getPluginMethods());
    }

    /**
     * Collects all the core methods
     *
     * @param ApiCore|MockApiCore $apiCore this parameter is used for testing.
     *        Here you can pass a non-default RemoteAPICore instance. (for mocking)
     * @return ApiCall[] all core methods.
     */
    public function getCoreMethods($apiCore = null)
    {
        if (!$this->coreMethods) {
            if ($apiCore === null) {
                $this->coreMethods = (new LegacyApiCore())->getMethods();
            } else {
                $this->coreMethods = $apiCore->getMethods();
            }
        }
        return $this->coreMethods;
    }

    /**
     * Collects all the methods of the enabled Remote Plugins
     *
     * @return ApiCall[] all plugin methods.
     */
    public function getPluginMethods()
    {
        if ($this->pluginMethods) return $this->pluginMethods;

        $plugins = plugin_list('remote');
        foreach ($plugins as $pluginName) {
            /** @var RemotePlugin $plugin */
            $plugin = plugin_load('remote', $pluginName);
            if (!is_subclass_of($plugin, RemotePlugin::class)) {
                Logger::error("Remote Plugin $pluginName does not implement dokuwiki\Extension\RemotePlugin");
                continue;
            }

            try {
                $methods = $plugin->getMethods();
            } catch (\ReflectionException $e) {
                Logger::error(
                    "Remote Plugin $pluginName failed to return methods",
                    $e->getMessage(),
                    $e->getFile(),
                    $e->getLine()
                );
                continue;
            }

            foreach ($methods as $method => $call) {
                $this->pluginMethods["plugin.$pluginName.$method"] = $call;
            }
        }

        return $this->pluginMethods;
    }

    /**
     * Call a method via remote api.
     *
     * @param string $method name of the method to call.
     * @param array $args arguments to pass to the given method
     * @return mixed result of method call, must be a primitive type.
     * @throws RemoteException
     */
    public function call($method, $args = [])
    {
        if ($args === null) {
            $args = [];
        }

        // pre-flight checks
        $this->ensureApiIsEnabled();
        $methods = $this->getMethods();
        if (!isset($methods[$method])) {
            throw new RemoteException('Method does not exist', -32603);
        }
        $this->ensureAccessIsAllowed($methods[$method]);

        // invoke the ApiCall
        try {
            return $methods[$method]($args);
        } catch (\InvalidArgumentException | \ArgumentCountError $e) {
            throw new RemoteException($e->getMessage(), -32602, $e);
        }
    }

    /**
     * Check that the API is generally enabled
     *
     * @return void
     * @throws RemoteException thrown when the API is disabled
     */
    public function ensureApiIsEnabled()
    {
        global $conf;
        if (!$conf['remote'] || trim($conf['remoteuser']) == '!!not set!!') {
            throw new AccessDeniedException('Server Error. API is not enabled in config.', -32604);
        }
    }

    /**
     * Check if the current user is allowed to call the given method
     *
     * @param ApiCall $method
     * @return void
     * @throws AccessDeniedException Thrown when the user is not allowed to call the method
     */
    public function ensureAccessIsAllowed(ApiCall $method)
    {
        global $conf;
        global $INPUT;
        global $USERINFO;

        if ($method->isPublic()) return; // public methods are always allowed
        if (!$conf['useacl']) return; // ACL is not enabled, so we can't check users
        if (trim($conf['remoteuser']) === '') return; // all users are allowed
        if (auth_isMember($conf['remoteuser'], $INPUT->server->str('REMOTE_USER'), (array)($USERINFO['grps'] ?? []))) {
            return; // user is allowed
        }

        // still here? no can do
        throw new AccessDeniedException('server error. not authorized to call method', -32604);
    }
}
