<?php

/**
 * @package Dotclear
 *
 * @copyright Olivier Meunier & Association Dotclear
 * @copyright AGPL-3.0
 */
declare(strict_types=1);

namespace Dotclear\Helper\Network;

use Exception;

/**
 * @class UrlHandler
 */
class UrlHandler
{
    /**
     * Stack of URL types (name)
     *
     * @var array<string, array{url: string, representation: string, handler: callable} >    $types
     */
    protected array $types = [];

    /**
     * Default handler, used if requested type handler not registered
     *
     * @var ?callable   $default_handler
     *
     * Note: cannot use ?callable or callable as property type so we use mixed
     */
    protected mixed $default_handler = null;

    /**
     * Stack of error handlers
     *
     * @var        callable[]    $error_handlers
     */
    protected array $error_handlers = [];

    /**
     * Current handler
     */
    protected string $type = 'default';

    /**
     * Constructs a new instance.
     *
     * @param      string  $mode   The URL mode, should be 'path_info' or 'query_string'
     */
    public function __construct(
        public string $mode = 'path_info'
    ) {
    }

    /**
     * Register an URL handler
     *
     * @param      string       $type            The URI type
     * @param      string       $url             The base URI
     * @param      string       $representation  The URI representation (regex, string)
     * @param      callable     $handler         The handler
     */
    public function register(string $type, string $url, string $representation, callable $handler): void
    {
        $this->types[$type] = [
            'url'            => $url,
            'representation' => $representation,
            'handler'        => $handler,
        ];
    }

    /**
     * Register the default URL handler
     *
     * @param      callable  $handler  The handler
     */
    public function registerDefault(callable $handler): void
    {
        $this->default_handler = $handler;
    }

    /**
     * Register an error handler (prepend at the begining of the error handler stack)
     *
     * @param      callable  $handler  The handler
     */
    public function registerError(callable $handler): void
    {
        array_unshift($this->error_handlers, $handler);
    }

    /**
     * Unregister an URL handler
     *
     * @param      string  $type   The type
     */
    public function unregister(string $type): void
    {
        if (isset($this->types[$type])) {
            unset($this->types[$type]);
        }
    }

    /**
     * Gets the current type.
     *
     * @return     string  The type.
     */
    public function getType(): string
    {
        return $this->type;
    }

    /**
     * Sets the current type.
     *
     * @param      string  $type   The type
     */
    public function setType(string $type): void
    {
        $this->type = $type;
    }

    /**
     * Check if one of the given types is the current defined type
     *
     * @param  string|array<array-key, mixed> $type The type(s) to check
     */
    public function isType(string|array $type): bool
    {
        if (is_array($type)) {
            $check = array_filter(array_values($type), is_string(...));

            return in_array($this->type, $check, true);
        }

        return $this->type === $type;
    }

    /**
     * Gets the current mode.
     *
     * @return     string  The mode.
     */
    public function getMode(): string
    {
        return $this->mode;
    }

    /**
     * Sets the current mode.
     *
     * @param      string  $mode   The mode
     */
    public function setMode(string $mode): void
    {
        $this->mode = $mode;
    }

    /**
     * Gets the registered URL handlers.
     *
     * @return     array<string, array{url: string, representation: string, handler: callable} >  The types.
     */
    public function getTypes(): array
    {
        return $this->types;
    }

    /**
     * Gets the base URI of an URL handler.
     *
     * @param      string  $type   The type
     */
    public function getBase(string $type): string
    {
        if (isset($this->types[$type])) {
            return $this->types[$type]['url'];
        }

        return '';
    }

    /**
     * Gets the document using an URL handler.
     */
    public function getDocument(): void
    {
        if ($this->mode === 'path_info') {
            $path_info = isset($_SERVER['PATH_INFO']) && is_string($path_info = $_SERVER['PATH_INFO']) ? $path_info : '';
            $part      = $path_info !== '' ? substr($path_info, 1) : '';
        } else {
            $part = '';

            $query_string = $this->parseQueryString();

            # Recreates some _GET and _REQUEST pairs
            if ($query_string !== []) {
                foreach ($_GET as $k => $v) {
                    if (isset($_REQUEST[$k])) {
                        unset($_REQUEST[$k]);
                    }
                }

                $_GET     = $query_string;
                $_REQUEST = array_merge($query_string, $_REQUEST);

                foreach ($query_string as $k => $v) {
                    if ($v === null) {
                        $part = $k;
                        unset($_GET[$k], $_REQUEST[$k]);
                    }

                    break;
                }
            }
        }

        $_SERVER['URL_REQUEST_PART'] = $part;

        $type = '';
        $args = '';
        $this->getArgs($part, $type, $args);

        if (!$type) {
            $this->type = 'default';
            $this->callDefaultHandler($args);
        } else {
            $this->type = $type;
            $this->callHandler($type, $args);
        }
    }

    /**
     * Gets the arguments from an URI
     *
     * @param      string  $part   The part
     * @param      string  $type   The type
     * @param      string  $args   The arguments
     */
    public function getArgs(string $part, ?string &$type, ?string &$args): void
    {
        if ($part === '') {
            $type = null;
            $args = null;

            return;
        }

        $this->sortTypes();

        foreach ($this->types as $k => $v) {
            $repr = $v['representation'];
            if ($repr === $part) {
                $type = $k;
                $args = null;

                return;
            }

            if (preg_match('#' . $repr . '#', $part, $m)) {
                $type = $k;
                $args = $m[1] ?? null;

                return;
            }
        }

        // No type, pass args to default
        $args = $part;
    }

    /**
     * Call an URL handler callback
     *
     * @param      callable         $handler  The handler
     * @param      string           $args     The arguments
     * @param      string           $type     The URL handler type
     */
    protected function callHelper(callable $handler, ?string $args = null, string $type = 'default'): void
    {
        try {
            call_user_func($handler, $args);
        } catch (Exception $exception) {
            foreach ($this->error_handlers as $err_handler) {
                if (call_user_func($err_handler, $args, $type, $exception) === true) {
                    return;
                }
            }

            // propagate exception, as it has not been processed by handlers
            throw $exception;
        }
    }

    /**
     * Call an registered URL handler callback
     *
     * @param      string     $type   The type
     * @param      string     $args   The arguments
     *
     * @throws     Exception
     */
    public function callHandler(string $type, ?string $args = null): void
    {
        if (!isset($this->types[$type])) {
            throw new Exception('Unknown URL type');
        }

        $this->callHelper($this->types[$type]['handler'], $args, $type);
    }

    /**
     * Call the default handler callback
     *
     * @param      string  $args   The arguments
     *
     * @throws     Exception
     */
    public function callDefaultHandler(?string $args = null): void
    {
        if ($this->default_handler === null) {
            throw new Exception('Undefined default URL handler');
        }

        $this->callHelper($this->default_handler, $args, 'default');
    }

    /**
     * Parse query string part of server URI
     *
     * @return     array<string, string|null>
     */
    protected function parseQueryString(): array
    {
        $res = [];

        $query_string = isset($_SERVER['QUERY_STRING']) && is_string($query_string = $_SERVER['QUERY_STRING']) ? $query_string : '';
        if ($query_string !== '') {
            $parameters = explode('&', $query_string);
            foreach ($parameters as $parameter) {
                $elements = explode('=', $parameter, 2);

                // Decode the parameter's name
                $elements[0] = rawurldecode($elements[0]);
                // Decode parameter's value if set
                $res[$elements[0]] = isset($elements[1]) ? urldecode($elements[1]) : null;
            }
        }

        return $res;
    }

    /**
     * Sort registered URL on their representations descending order
     */
    protected function sortTypes(): void
    {
        $representations = [];
        foreach ($this->types as $k => $v) {
            $representations[$k] = $v['url'];
        }

        array_multisort($representations, SORT_DESC, $this->types);
    }
}
