<?php
/*
** Copyright (C) 2021-2026 initMAX s.r.o.
**
** This program is free software: you can redistribute it and/or modify it under the terms of
** the GNU Affero General Public License as published by the Free Software Foundation, version 3.
**
** This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
** without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
** See the GNU Affero General Public License for more details.
**
** You should have received a copy of the GNU Affero General Public License along with this program.
** If not, see <https://www.gnu.org/licenses/>.
**/


namespace Modules\uxMAX;

// Core\CModule imports the action type as `use CController as CAction`, so the
// parent's real parameter type is \CController. Importing the GLOBAL \CAction
// instead (it exists on 7.x) makes PHP reject the override with "must be
// compatible with Core\CModule::onBeforeAction(CController $action)" - a FATAL
// that blanks the whole 6.0 frontend, not just this module.
use CController as CAction;
use Core\CModule as BaseModule;
use Modules\uxMAX\Includes\Compat;
use Modules\uxMAX\Includes\MenuRegistrar;
use Modules\uxMAX\Includes\Presentation;
use Modules\uxMAX\Services\ModuleTranslator;
use Modules\uxMAX\Services\Preferences;

/**
 * uxMAX entry point for Zabbix 6.0 / 6.2 (manifest_version 1).
 *
 * Those frontends accept manifest 1 ONLY (6.4+ accept 2 ONLY - probed in the
 * official images via CModuleManager's MIN/MAX_MANIFEST_VERSION), so uxMAX has
 * to ship two trees. This one is an ADAPTER, not a second product: WHICH
 * assets and WHICH body attributes a request needs is decided in
 * Includes\Presentation, shared with the 6.4+ Module, and every page, form,
 * action and stylesheet is the same code. The only thing that genuinely
 * differs is DELIVERY:
 *
 *  - the v1 CModule has no `assets` block, so this class injects the module's
 *    scripts and stylesheet itself, in order;
 *  - it also has no setConfig(), no getRelativePath() and no getAssets() -
 *    Compat covers those.
 *
 * The stylesheet is NOT duplicated here: build-edition.py generates
 * public/css/*.css from the canonical assets/css/*.css, so the two frontends
 * cannot drift apart.
 */
class Module extends BaseModule {

    public $preferences;

    public function init(): void {
        ModuleTranslator::setRelativePath(Compat::relativePath($this));

        $this->preferences = new Preferences($this);

        // init() runs on EVERY authenticated request. An unguarded
        // getSubMenu()->add() here is a fatal on every page, and getSubMenu()
        // silently MANUFACTURES an empty submenu when the target is a leaf -
        // which "User settings" is on 6.0/6.2 for roles without API tokens.
        if ($this->preferences->isUserOverrideAllowed()) {
            MenuRegistrar::register();
        }
        else {
            MenuRegistrar::registerAdminOnly();
        }
    }

    public function onBeforeAction(CAction $action): void {
        if (strpos(get_class($action), __NAMESPACE__) === 0) {
            $action->module = $this;
        }

        $this->installAssets($action->getAction());
    }

    public function onTerminate(CAction $action): void {
    }

    /**
     * Deliver what Module::getAssets() returns on 6.4+.
     *
     * Everything goes through zbx_add_post_js, which layout.htmlpage emits at
     * the very end of the body - so the document is already parsed by the time
     * these run. That is exactly why the module's own scripts test
     * document.readyState instead of waiting for DOMContentLoaded: on this line
     * the event has already fired.
     */
    private function installAssets(string $action_name): void {
        $preferences = $this->preferences->get();

        $this->installStylesheets($preferences);

        foreach (Presentation::bodyAttributes($preferences, $action_name) as $name => $value) {
            $this->addPostJs('document.documentElement.setAttribute('
                .json_encode($name).', '.json_encode($value).');');
        }

        $scripts = [];

        foreach (Presentation::scripts($preferences, $action_name) as $script) {
            // The v1 package flattens assets/js/** into public/js/ (a manifest
            // v1 asset path has no directories), so the basename IS the name.
            $scripts[] = $this->getAssetsPath().'/js/'.basename($script);
        }

        if ($scripts) {
            $this->loadScriptsInOrder($scripts);
        }
    }

    /**
     * The static stylesheet is INLINED (that is what the %%MODULE_ASSETS%%
     * placeholder in the generated file is for); the preference-dependent one
     * is a normal <link> because it is served by an action, not a file.
     */
    private function installStylesheets(array $preferences): void {
        foreach (glob(__DIR__.'/public/css/*.css') ?: [] as $sheet) {
            $this->installCss($sheet);
        }

        if (!Presentation::needsGeneratedCss($preferences)) {
            return;
        }

        $this->addPostJs(
            'if (document.getElementById("uxmax-generated-css") === null) {'
            .'const link = document.createElement("link");'
            .'link.id = "uxmax-generated-css";'
            .'link.rel = "stylesheet";'
            .'link.type = "text/css";'
            .'link.href = "zabbix.php?action=mod.uxmax.css";'
            .'document.head.appendChild(link);'
            .'}'
        );
    }

    /**
     * Inject one generated stylesheet as a <style> element.
     *
     * Guarded by id so a re-render cannot stack duplicates, and the asset
     * placeholder is resolved against the directory the module actually loaded
     * from - an inlined url() would otherwise resolve against zabbix.php.
     */
    private function installCss(string $path): void {
        if (!is_readable($path)) {
            return;
        }

        $element_id = 'uxmax-'.basename($path, '.css').'-css';
        $css = strtr((string) file_get_contents($path), [
            '%%MODULE_ASSETS%%' => $this->getAssetsPath()
        ]);

        $this->addPostJs(
            'if (document.getElementById('.json_encode($element_id).') === null) {'
            .'const style = document.createElement("style");'
            .'style.id = '.json_encode($element_id).';'
            .'style.textContent = '.json_encode($css).';'
            .'document.head.appendChild(style);'
            .'}'
        );
    }

    /**
     * Load scripts STRICTLY in order.
     *
     * Appending <script src> elements in a loop does not do that - the browser
     * fetches them in parallel and runs whichever arrives first, so
     * uxmax.form.js can call ace.edit() before ace.js exists. Chaining on
     * onload is the only way to keep the order the 6.4+ manifest gets for free.
     *
     * @param string[] $urls
     */
    private function loadScriptsInOrder(array $urls): void {
        $this->addPostJs(
            '(function(){'
            .'var queue = '.json_encode(array_values($urls)).';'
            .'(function next(){'
            .'if (!queue.length) { return; }'
            .'var url = queue.shift();'
            .'if (document.querySelector(\'script[data-uxmax-src="\' + url + \'"]\')) { next(); return; }'
            .'var s = document.createElement("script");'
            .'s.src = url;'
            .'s.setAttribute("data-uxmax-src", url);'
            .'s.onload = next;'
            .'s.onerror = next;'
            .'document.head.appendChild(s);'
            .'})();'
            .'})();'
        );
    }

    private function addPostJs(string $script): void {
        zbx_add_post_js($script);
    }

    /** Web path of this module's own directory, for url() inside the CSS. */
    private function getAssetsPath(): string {
        return 'modules/'.basename(__DIR__).'/public';
    }
}
