<?php

namespace Apexl\Io;

use Apexl\Config\ConfigurationInterface;
use Apexl\Config\Exceptions\MissingConfigException;
use Apexl\Io\Enums\HttpMethod;
use Apexl\Io\Interfaces\IoModuleInterface;
use Apexl\Io\Routing\IoRoute;
use Apexl\Io\Routing\IoRouteGroup;
use RuntimeException;
use Slim\App;

abstract readonly class Module implements IoModuleInterface
{

    /**
     * Slim is private on purpose.
     *
     * A module registers through `get()`, `post()`, `route()` or `group()`, and those are the
     * only ways in. Reaching past them to `$app->get(...)` skipped the two things a module
     * route needs — the module recorded against it, so `routes:dump` can group by it, and a
     * name registered twice replacing the first rather than being shadowed by it — and did so
     * without any sign that it had.
     *
     * Register from `routes()`, not from `initialise()` or a constructor, or the routes land
     * before the module's hooks do.
     */
    public function __construct(
        protected readonly ConfigurationInterface $config,
        private readonly App $app,
    ) {}

    protected function get(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::GET, $pattern, $callable);
    }

    protected function post(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::POST, $pattern, $callable);
    }

    protected function put(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::PUT, $pattern, $callable);
    }

    protected function patch(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::PATCH, $pattern, $callable);
    }

    protected function delete(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::DELETE, $pattern, $callable);
    }

    protected function options(string $pattern, mixed $callable): IoRoute
    {
        return $this->route(HttpMethod::OPTIONS, $pattern, $callable);
    }

    /**
     * One route answering several verbs: `route([HttpMethod::GET, HttpMethod::POST], ...)`.
     *
     * The verb methods above are the ordinary way in; this is for the handful of routes that
     * genuinely answer more than one. It is also the only place HttpMethod is turned into
     * what Slim wants, which matters more than it looks: Slim does not normalise the methods
     * it is given, and compares them to `$request->getMethod()` — so a lowercase 'get' is
     * stored happily and matches nothing, exactly like a pattern without a leading slash.
     *
     * A route need not be named. Most are — the menu, `routes:dump` and RouteUrls all look
     * routes up by name — but a name is chained on like any other property, which is also
     * how a route inside a group is named, so the two read the same way.
     *
     * @param HttpMethod|HttpMethod[] $methods
     */
    protected function route(HttpMethod|array $methods, string $pattern, mixed $callable): IoRoute
    {
        $verbs = array_map(
            static fn (HttpMethod $method): string => $method->name,
            is_array($methods) ? $methods : [$methods],
        );

        if ($verbs === []) {
            throw new RuntimeException(sprintf('The route for \'%s\' must answer at least one verb.', $pattern));
        }

        $route = $this->app->map($verbs, $pattern, $callable);
        assert($route instanceof IoRoute);

        return $route->setModule(strtolower($this->getName()));
    }



    /**
     * A hook for modules that need to prepare something before their routes are read.
     *
     * It used to set a base route that every `addRoute()` call was silently prefixed with —
     * so a module's paths depended on its class name, its parent, and the api version, none
     * of which were visible where the route was written. A module that wants a prefix now
     * says so, with a group.
     */
    public function initialise(): void {}

    /**
     * A group under the versioned API path: `apiGroup('user', ...)` is `/api/v1/user/...`.
     *
     * The version is the only part of a route worth deriving rather than writing, because it
     * comes from config and changing it has to move every API route at once. Everything else
     * about the prefix is written where you can read it.
     *
     * @throws MissingConfigException
     */
    protected function apiGroup(string $module, callable $routes): IoRouteGroup
    {
        return $this->group(sprintf('api/v%d/%s', $this->apiVersion(), trim($module, '/')), $routes);
    }

    /**
     * A group of routes under this module's base route.
     *
     * Everything a group of routes shares — the prefix, and the permission when they share
     * one — said once:
     *
     *     $this->group('data/dropdowns', function (RouteCollectorProxy $routes): void {
     *         $routes->get('/parts/manufacturers', Controller::class)->setName(...);
     *     })->add(new RoutePermissionMiddleware(Common::IS_LOGGED_IN));
     *
     * Routes registered inside the closure go straight to Slim, so they miss the two things
     * `register()` does for a route: they carry no module, and a name registered twice would
     * leave both routes in the table with the older one still answering. Both are put right
     * here, for the routes this call actually added.
     */
    protected function group(string $pattern, callable $routes): IoRouteGroup
    {
        return $this->collectGroup(sprintf('/%s', ltrim($pattern, '/')), $routes);
    }

    private function collectGroup(string $pattern, callable $routes): IoRouteGroup
    {
        $collector = $this->app->getRouteCollector();
        $existing = $collector->getRoutes();

        $group = $this->app->group($pattern, $routes);
        assert($group instanceof IoRouteGroup);

        foreach ($collector->getRoutes() as $identifier => $route) {
            if (isset($existing[$identifier]) || !$route instanceof IoRoute) {
                continue;
            }

            $route->setModule(strtolower($this->getName()));
        }

        return $group;
    }

    public function getName(): string
    {
        return $this->getNameFromNamespace(get_class($this));
    }

    public function getNameFromNamespace($namespace): string
    {
        $moduleSpaces = explode('\\', $namespace);

        return str_replace('Module', '', array_pop($moduleSpaces));
    }

    /**
     * @throws MissingConfigException
     */
    public function apiVersion(): int
    {
        return $this->config->get('api.version');
    }


}
