<?php
namespace MatthiasWeb\Utils;

defined('ABSPATH') or die('No script kiddies please!'); // Avoid direct file request

/**
 * Base class for the applications Core class.
 */
trait Core {
    /**
     * The stored plugin data.
     */
    private $plugin_data;

    /**
     * The plugins activator class.
     *
     * @var Activator
     */
    private $activator;

    /**
     * The plugins asset class.
     *
     * @var Assets
     */
    private $assets;

    /**
     * The utils service class.
     *
     * @var Service
     */
    private $service;

    /**
     * The constructor handles the core startup mechanism.
     *
     * The constructor is protected because a factory method should only create
     * a Core object.
     */
    protected function construct() {
        // Define lazy constants
        $pluginFile = $this->getPluginConstant(Constants::PLUGIN_CONST_FILE);

        // Register immediate actions and filters
        $this->activator = $this->getPluginClassInstance(Constants::PLUGIN_CLASS_ACTIVATOR);
        $this->assets = $this->getPluginClassInstance(Constants::PLUGIN_CLASS_ASSETS);
        $this->service = Service::instance($this);

        add_action('after_setup_theme', [$this, 'i18n']); // `after_setup_theme` cause `functions.php` can add additional hooks for i18n
        add_action('plugins_loaded', [$this, 'updateDbCheck']);
        add_action('init', [$this, 'init']);
        add_action('rest_api_init', [$this->getService(), 'rest_api_init']);
        add_action('admin_notices', [$this->getService(), 'admin_notices']);
        add_action('request', [$this->getService(), 'request']);

        // Localize the plugin and package itself
        $this->getPluginClassInstance(Constants::PLUGIN_CLASS_LOCALIZATION)->hooks();
        PackageLocalization::instance(
            $this->getPluginConstant(Constants::PLUGIN_CONST_ROOT_SLUG),
            dirname(__DIR__)
        )->hooks();

        register_activation_hook($pluginFile, [$this->getActivator(), 'registerCapabilities']);
        register_activation_hook($pluginFile, [$this->getActivator(), 'install']);
        register_activation_hook($pluginFile, [$this->getActivator(), 'activate']);
        register_deactivation_hook($pluginFile, [$this->getActivator(), 'deactivate']);

        RateLimitNotice::instance($this)->hooks();
    }

    /**
     * The plugin is loaded. Start to register the localization (i18n) files.
     * Also respect packages in vendor dir.
     */
    public function i18n() {
        load_plugin_textdomain(
            $this->getPluginConstant(Constants::PLUGIN_CONST_TEXT_DOMAIN),
            false,
            dirname(plugin_basename($this->getPluginConstant(Constants::PLUGIN_CONST_FILE))) .
                $this->getPluginData('DomainPath')
        );

        // Include text domains of packages
        $packages = $this->getInternalPackages();
        $locale = determine_locale();
        foreach ($packages as $package => $path) {
            $textdomain = $this->getPluginConstant(Constants::PLUGIN_CONST_ROOT_SLUG) . '-' . $package;
            $locale = apply_filters('plugin_locale', $locale, $textdomain);
            $mofile = $path . '/languages/backend/' . $package . '-' . $locale . '.mo';
            load_textdomain($textdomain, PackageLocalization::getParentLanguageFolder($mofile, true));
        }
    }

    /**
     * Updates the database version in the options table.
     * It also installs the needed database tables.
     */
    public function updateDbCheck() {
        $activator = $this->getActivator();
        $databaseVersion = $activator->getDatabaseVersion();
        $installed = $databaseVersion['version'];
        $invalidateKey = $databaseVersion['invalidateKey'];

        $slug = $this->getPluginConstant(Constants::PLUGIN_CONST_SLUG);

        if (
            ($installed !== $this->getPluginConstant(Constants::PLUGIN_CONST_VERSION) ||
                json_encode($invalidateKey) !==
                    json_encode(apply_filters('DevOwl/Utils/DatabaseVersion/InvalidateKey/' . $slug, []))) &&
            !$activator->isMigrationLocked() &&
            $activator->isMigrationLocked(time())
        ) {
            $textdomain = $this->getPluginConstant(Constants::PLUGIN_CONST_TEXT_DOMAIN);
            $this->debug('(Re)install the database tables', __FUNCTION__);

            // Clear localization cache for JSON MO files
            $this->getPluginClassInstance(Constants::PLUGIN_CLASS_LOCALIZATION)->clearMoCacheDir($slug, $textdomain);

            $activator->registerCapabilities();
            if ($activator->install()) {
                /**
                 * A new version got installed for this plugin. Consider to use the [`versionCompareOlderThan()`](../php/classes/MatthiasWeb-Utils-Core.html#method_versionCompareOlderThan)
                 * method from the Core class.
                 *
                 * @hook DevOwl/Utils/NewVersionInstallation/$slug
                 * @param {string} $installed Previously version, can be also `null` for new installations
                 */
                do_action('DevOwl/Utils/NewVersionInstallation/' . $slug, $installed);

                /**
                 * A new version got installed for a plugin. Please use `DevOwl/Utils/NewVersionInstallation/$slug`.
                 *
                 * @hook DevOwl/Utils/NewVersionInstallation
                 * @param {string} $installed Previously version, can be also `null` for new installations
                 * @param {string} $slug
                 */
                do_action('DevOwl/Utils/NewVersionInstallation', $installed, $slug);

                $activator->isMigrationLocked(0);
            }
        }
    }

    /**
     * Get a list of internal packages (our own, symlinked from the monorepo).
     *
     * @return object
     */
    public function getInternalPackages() {
        $result = [];

        $globPath =
            trailingslashit($this->getPluginConstant(Constants::PLUGIN_CONST_PATH)) .
            'vendor/' .
            $this->getPluginConstant(Constants::PLUGIN_CONST_ROOT_SLUG) .
            '/*';
        foreach (glob($globPath) as $path) {
            $package = pathinfo($path, PATHINFO_FILENAME);
            $result[$package] = $path;
        }

        return $result;
    }

    /**
     * Gets the plugin data.
     *
     * @param string $key The key of the data to return
     * @return string[]|string|null
     * @see https://developer.wordpress.org/reference/functions/get_plugin_data/
     */
    public function getPluginData($key = null) {
        // @codeCoverageIgnoreStart
        if (!defined('PHPUNIT_FILE')) {
            require_once ABSPATH . '/wp-admin/includes/plugin.php';
        }
        // @codeCoverageIgnoreEnd

        $data = isset($this->plugin_data)
            ? $this->plugin_data
            : ($this->plugin_data = get_plugin_data(
                $this->getPluginConstant(Constants::PLUGIN_CONST_FILE),
                false,
                false
            ));
        return $key === null ? $data : (isset($data[$key]) ? $data[$key] : null);
    }

    /**
     * Getter.
     *
     * @return Activator
     * @codeCoverageIgnore
     */
    public function getActivator() {
        return $this->activator;
    }

    /**
     * Getter
     *
     * @return Assets
     * @codeCoverageIgnore
     */
    public function getAssets() {
        return $this->assets;
    }

    /**
     * Getter
     *
     * @return Service
     * @codeCoverageIgnore
     */
    public function getService() {
        return $this->service;
    }

    /**
     * Checks if a previously installed version is lower than an expected version.
     * Additionally, we can check for a prerelease version, too. The `$allowedPrerelease` needs
     * to be an array (e.g. `["2.15.1", "2.16.0"]`), and if the installed version is a prerelease
     * (e.g. `2.15.1-9507`), the version `2.15.1` gets extracted and checked for existence in the array.
     *
     * Usage with `$prereleaseAdditionalCheck`: You can pass an additional callback which is executed before
     * returning `true` when a prerelease got found. An example scenario for this callable is to check for
     * a specific database table if you drop a column. Why is this needed? Imagine you are sending multiple
     * prerelease to a customer.
     *
     * @param string|false $installed
     * @param string $version
     * @param string[] $allowedPrerelease
     * @param callable $prereleaseAdditionalCheck `($prereleaseVersion, $prereleaseCiIid) => boolean`
     */
    public static function versionCompareOlderThan(
        $installed,
        $version,
        $allowedPrerelease = [],
        $prereleaseAdditionalCheck = null
    ) {
        if ($installed) {
            $prerelease = explode('-', $installed, 2);
            $prereleaseVersion = count($prerelease) > 1 ? $prerelease[0] : null;
            $prereleaseCiIid = count($prerelease) > 1 && is_numeric($prerelease[1]) ? intval($prerelease[1]) : null;

            if (version_compare($installed, $version, '<=') || $prereleaseVersion === $version) {
                return true;
            }

            if (count($allowedPrerelease) > 0 && $prereleaseVersion !== null) {
                if (in_array($prereleaseVersion, $allowedPrerelease, true)) {
                    if (is_callable($prereleaseAdditionalCheck)) {
                        return $prereleaseAdditionalCheck($prereleaseVersion, $prereleaseCiIid);
                    }

                    return true;
                }
            }
        }

        return false;
    }
}
