<?php

namespace onespace\core\modules\metrics;

use onespace\core\components\MetricsCollectorInterface;
use onespace\core\components\MetricsComponent;
use onespace\core\controllers\MetricsController;
use yii\base\Application;
use yii\base\BootstrapInterface;
use yii\base\InvalidConfigException;
use yii\base\Module as BaseModule;
use yii\web\Application as WebApplication;

/**
 * Drop-in Prometheus /metrics endpoint.
 *
 * Minimal usage - add to the app config:
 *
 * ```php
 * return [
 *     'bootstrap' => [..., 'metrics'],   // triggers self::bootstrap() below
 *     'modules' => [
 *         'metrics' => [
 *             'class' => \onespace\core\modules\metrics\Module::class,
 *             'collectors' => [
 *                 \onespace\core\components\QueueMetricsCollector::class,
 *             ],
 *         ],
 *     ],
 * ];
 * ```
 *
 * With that in place, GET /metrics (pretty URLs on) or
 * GET /index.php?r=metrics (pretty URLs off) is live. No controller,
 * action, or route needs to be written by the consuming application - the
 * module reuses the existing onespace\core\controllers\MetricsController
 * (via controllerMap) rather than shipping a duplicate one, and wires it to
 * MetricsAction with this module's collectors/metricsComponent settings.
 *
 * `collectors` defaults to empty - this module doesn't assume any
 * particular collector (e.g. queue metrics) should run; the consuming app
 * opts in explicitly. `metricsComponent` defaults to 'metrics' and expects
 * an application component of that ID to already exist (e.g.
 * onespace\core\components\MetricsComponent) - bootstrap() throws
 * immediately with a clear message if it doesn't, rather than failing
 * deep inside MetricsAction the first time something scrapes /metrics.
 *
 * Why listing it in `bootstrap` is required, not just `modules`: Yii only
 * instantiates modules lazily, on first access. Application::bootstrap()
 * resolves a plain string entry in `bootstrap` against component IDs
 * first, then module IDs - so `'metrics'` there is what forces the module
 * (and this bootstrap() method) to run on every request, not just ones
 * that happen to route into it. Declaring it only in `modules` would make
 * the endpoint reachable, but the auto-registered URL rule and fail-fast
 * check below would never run.
 *
 * To customise the path, component id, or reuse a different controller:
 *
 * ```php
 * 'modules' => [
 *     'metrics' => [
 *         'class' => \onespace\core\modules\metrics\Module::class,
 *         'urlRule' => 'internal/metrics',
 *         'metricsComponent' => 'myMetrics',
 *         'controllerMap' => ['default' => \app\controllers\CustomMetricsController::class],
 *     ],
 * ],
 * ```
 *
 * @psalm-api
 */
class Module extends BaseModule implements BootstrapInterface
{
    /** @var string the pretty-URL path the endpoint is exposed on */
    public string $urlRule = 'metrics';

    /** @var string application component ID for the Prometheus registry */
    public string $metricsComponent = 'metrics';

    /**
     * @var array<class-string<MetricsCollectorInterface>|MetricsCollectorInterface|array>
     * Collectors run, in declared order, at scrape time before the registry
     * is rendered. Empty by default - opt in per app.
     */
    public array $collectors = [];

    #[\Override]
    public function init(): void
    {
        parent::init();

        // Reuse the existing MetricsController rather than shipping a
        // second one - only if the app hasn't already overridden the
        // 'default' entry in its own module config.
        if (!isset($this->controllerMap['default'])) {
            $this->controllerMap['default'] = [
                'class'            => MetricsController::class,
                'metricsComponent' => $this->metricsComponent,
                'collectors'       => $this->collectors,
            ];
        }
    }

    public function bootstrap($app): void
    {
        $this->assertMetricsComponentConfigured($app);
        $this->registerUrlRule($app);
    }

    /**
     * Fails loudly and immediately if the app hasn't configured the
     * component this module is supposed to expose. No fallback is
     * registered here - guessing at a default would just trade a clear
     * error now for a confusing one later.
     */
    private function assertMetricsComponentConfigured(Application $app): void
    {
        if (!$app->has($this->metricsComponent)) {
            throw new InvalidConfigException(
                "The metrics module requires an application component named '{$this->metricsComponent}' "
                . '(configure it under `components` in your app config, e.g. '
                . MetricsComponent::class . ", or point Module::\$metricsComponent at whichever component you use instead)."
            );
        }
    }

    /**
     * Prepends (not appends) a rule mapping $this->urlRule to this module's
     * default controller/action - prepending guarantees it's matched
     * before anything else the host app has configured, regardless of how
     * many other rules exist. This is a console-safe no-op on non-web
     * applications, and addRules() itself is a no-op when the app hasn't
     * enabled pretty URLs - the bare route (e.g. ?r=metrics) still
     * resolves correctly either way via Yii's default module ->
     * defaultRoute -> default-action resolution, with zero rules
     * required.
     */
    private function registerUrlRule(Application $app): void
    {
        if (!$app instanceof WebApplication) {
            return;
        }

        $app->getUrlManager()->addRules([
            [
                'pattern' => $this->urlRule,
                'route'   => $this->id . '/default/index',
                'verb'    => ['GET'],
            ],
        ], false);
    }
}