<?php
namespace DevOwl\RealCookieBanner\settings;

use DevOwl\CookieConsentManagement\frontend\AbstractRevisionPersistance;
use DevOwl\RealCookieBanner\base\UtilsProvider;
use DevOwl\RealCookieBanner\comp\RevisionBackwardsCompatibility;
use DevOwl\RealCookieBanner\Core;
use DevOwl\RealCookieBanner\lite\settings\TcfVendorConfiguration;
use DevOwl\RealCookieBanner\UserConsent;
use DevOwl\RealCookieBanner\Utils;
use DevOwl\RealCookieBanner\view\Notices;
use DevOwl\RealProductManagerWpClient\license\License;
use DevOwl\ServiceCloudConsumer\middlewares\services\ManagerMiddleware;

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

/**
 * Create a mechanism to catch all settings and create an unique revision.
 * Note: A revision holds any option in "raw", so there isn't any filter or
 * postprocess like a getter.
 */
class Revision extends AbstractRevisionPersistance {
    use UtilsProvider;

    const TABLE_NAME = 'revision';

    const TABLE_NAME_INDEPENDENT = 'revision_independent';

    const OPTION_NAME_CURRENT_HASH_PREFIX = RCB_OPT_PREFIX . '-revision-current-hash';

    /**
     * Singleton instance.
     *
     * @var Revision
     */
    private static $me = null;

    /**
     * C'tor.
     */
    private function __construct() {
        // Silence is golden.
    }

    // Documented in AbstractRevisionPersistance
    public function persist($result, $forceNewConsent) {
        global $wpdb;

        $table_name = $this->getTableName(self::TABLE_NAME);
        $wpdb->query(
            // phpcs:disable WordPress.DB.PreparedSQL
            $wpdb->prepare(
                "INSERT IGNORE INTO $table_name (json_revision, `hash`, created) VALUES (%s, %s, %s)",
                json_encode($result['revision']),
                $result['hash'],
                current_time('mysql')
            )
            // phpcs:enable WordPress.DB.PreparedSQL
        );

        if ($forceNewConsent) {
            update_option($this->getCurrentHashOptionName(), sprintf('%s:%s', $result['hash'], time()), true);
            Core::getInstance()
                ->getNotices()
                ->getStates()
                ->set(Notices::NOTICE_REVISON_REQUEST_NEW_CONSENT_PREFIX . $this->getContextVariablesString(), '');

            /**
             * A new consent is requested on the frontend. That means, the new revision
             * hash is now present in the frontend.
             *
             * @hook RCB/Revision/Hash
             * @param {array} $result
             * @param {string} $hash Persisted hash to `wp_rcb_revision`
             * @returns {array}
             */
            return apply_filters('RCB/Revision/Hash', $result, $result['hash']);
        }
    }

    // Documented in AbstractRevisionPersistance
    public function persistIndependent($result) {
        global $wpdb;
        $table_name = $this->getTableName(self::TABLE_NAME_INDEPENDENT);
        $wpdb->query(
            // phpcs:disable WordPress.DB.PreparedSQL
            $wpdb->prepare(
                "INSERT IGNORE INTO $table_name (json_revision, `hash`, created) VALUES (%s, %s, %s)",
                json_encode($result['revision']),
                $result['hash'],
                current_time('mysql')
            )
            // phpcs:enable WordPress.DB.PreparedSQL
        );
    }

    // Documented in AbstractRevisionPersistance
    public function alterRevision(&$revision) {
        /**
         * Modify the revision array so specific data changes can cause a "Request new consent".
         *
         * @hook RCB/Revision/Array
         * @param {array} $result
         * @returns {array}
         */
        $revision = apply_filters('RCB/Revision/Array', $revision);
        return $revision;
    }

    // Documented in AbstractRevisionPersistance
    public function alterRevisionIndependent(&$revision) {
        $licenseActivation = Core::getInstance()
            ->getRpmInitiator()
            ->getPluginUpdater()
            ->getCurrentBlogLicense()
            ->getActivation();
        $isLicensed = !empty($licenseActivation->getCode());
        $isDevLicense = $licenseActivation->getInstallationType() === License::INSTALLATION_TYPE_DEVELOPMENT;

        $revision['banner'] = Core::getInstance()->getBanner()->getCustomize()->localizeValues();
        $revision['isPro'] = $this->isPro();
        $revision['isLicensed'] = $isLicensed;
        $revision['isDevLicense'] = $isDevLicense;

        /**
         * Modify the independent revision array. Changes do not cause a "Request new consent"!
         *
         * @hook RCB/Revision/Array/Independent
         * @param {array} $result
         * @returns {array}
         * @since 2.0.0
         */
        $revision = apply_filters('RCB/Revision/Array/Independent', $revision);

        return $revision;
    }

    /**
     * Checks if the given current revision (result of `getCurrent`) needs a retrigger.
     *
     * @param array $revision Result of `getCurrent`
     * @return boolean
     */
    public function needsRetrigger($revision) {
        $needsRetrigger = $revision['public_to_users'] !== $revision['calculated'];

        if ($needsRetrigger) {
            // Got this calculated hash dismissed?
            $dismissedHash = Core::getInstance()
                ->getNotices()
                ->getStates()
                ->get(Notices::NOTICE_REVISON_REQUEST_NEW_CONSENT_PREFIX . $this->getContextVariablesString());

            if (!empty($dismissedHash) && $dismissedHash === $revision['calculated']) {
                $needsRetrigger = false;
            }
        }

        /**
         * Checks if the revision(s) needs a retrigger. This can be useful if you create your own context
         * and you want to show a "Request new consents" button in the admin page.
         *
         * @hook RCB/Revision/NeedsRetrigger
         * @param {boolean} $needs_retrigger
         * @return {boolean}
         * @since 1.7.4
         */
        return apply_filters('RCB/Revision/NeedsRetrigger', $needsRetrigger);
    }

    /**
     * See filter RCB/Revision/Context/Translate.
     *
     * @param string $context
     * @return string
     */
    public function translateContextVariablesString($context) {
        /**
         * Translate a context variable string to human readable form. E. g. replace `lang:de` with `Sprache: Deutsch`
         *
         * @hook RCB/Revision/Context/Translate
         * @param {string} $context
         * @return {string}
         */
        $translated = apply_filters('RCB/Revision/Context/Translate', $context);

        if (empty($translated)) {
            $translated = __('Without context', 'real-cookie-banner');
        }

        return $translated;
    }

    /**
     * Get the current revision as array. It also includes the following infos:
     *
     * - `public_to_users`: The revision hash currently published to users
     * - `calculated`: The current revision hash from the latest settings
     * - `created_tag_managers`: Has a cookie with a valid Google/Matomo Tag Manager script (so you can show a notice in your config UI)
     * - `public_count`: A total count of public cookies
     *
     * @param boolean|"force" $recreate If true, a new revision gets created so new consents need to be made. Always recreates when no consents are given yet.
     */
    public function getCurrent($recreate = false) {
        $create = $this->getRevision()->create(
            $recreate === 'force' ? 'force' : $recreate || UserConsent::getInstance()->getCount() === 0
        );
        $calculated = $create['hash'];
        $publicToUsers = $this->getRevision()->getEnsuredCurrentHash();
        $consentsDeletedAt = mysql2date('c', get_transient(Consent::TRANSIENT_SCHEDULE_CONSENTS_DELETION), false);

        // Search for all available tag managers
        $createdTagManagers = [];
        foreach (ManagerMiddleware::TAG_MANAGER_IDENTIFIERS as $tagManagerIdentifier) {
            $ids = get_posts(
                Core::getInstance()->queryArguments(
                    [
                        'post_type' => Cookie::CPT_NAME,
                        'numberposts' => -1,
                        'nopaging' => true,
                        'fields' => 'ids',
                        'meta_query' => [
                            [
                                'key' => Blocker::META_NAME_PRESET_ID,
                                'value' => sprintf('^%s', $tagManagerIdentifier),
                                'compare' => 'REGEXP',
                            ],
                        ],
                    ],
                    'revisionGetManagerIds'
                )
            );

            $createdTagManagers[$tagManagerIdentifier] = $ids;
        }

        $notices = Core::getInstance()->getNotices();
        $needsUpdate = $notices->servicesWithUpdatedTemplates();
        $successors = $notices->servicesWithSuccessorTemplates();
        $gcmAdjustments = $notices->servicesWithGoogleConsentModeAdjustments();

        /**
         * Modify the result for the `/wp-json/real-cookie-banner/v1/revision/current` route. Usually,
         * this filter should not be used publicly as it is only intent for internal usage.
         *
         * @hook RCB/Revision/Current
         * @param {array} $value
         * @return {array}
         * @since 2.0.0
         */
        return apply_filters(
            'RCB/Revision/Current',
            array_merge(
                $create,
                [
                    'created_tag_managers' => $createdTagManagers,
                    'public_to_users' => $publicToUsers,
                    'template_needs_update' => $needsUpdate,
                    'check_saving_consent_via_rest_api_endpoint_working_html' => $notices->checkSavingConsentViaRestApiEndpointWorkingHtml(),
                    'template_update_notice_html' =>
                        count($needsUpdate) > 0 ? $notices->servicesWithUpdatedTemplatesHtml($needsUpdate) : null,
                    'template_successors_notice_html' =>
                        count($successors) > 0 ? $notices->servicesWithSuccessorTemplatesHtml($successors) : null,
                    'google_consent_mode_notices_html' => array_values(
                        $notices->servicesWithGoogleConsentModeAdjustmentsHtml($gcmAdjustments)
                    ),
                    'services_data_processing_in_unsafe_countries_notice_html' => $notices->servicesDataProcessingInUnsafeCountriesNoticeHtml(),
                    'services_with_empty_privacy_policy_notice_html' => $notices->serviceWithEmptyPrivacyPolicyNoticeHtml(),
                    'contexts' => UserConsent::getInstance()->getPersistedContexts(),
                    'calculated' => $calculated,
                    'public_cookie_count' => Cookie::getInstance()->getPublicCount(),
                    'all_cookie_count' => Cookie::getInstance()->getAllCount(),
                    'all_blocker_count' => Blocker::getInstance()->getAllCount(),
                    'cookie_counts' => wp_count_posts(Cookie::CPT_NAME),
                    'consents_deleted_at' => $consentsDeletedAt,
                    'bannerless_consent_checks' => $notices->servicesWithBannerlessConsentAdjustments(),
                ],
                $this->isPro()
                    ? [
                        'all_tcf_vendor_configuration_count' => TcfVendorConfiguration::getInstance()->getAllCount(),
                        'tcf_vendor_configuration_counts' => wp_count_posts(TcfVendorConfiguration::CPT_NAME),
                    ]
                    : []
            )
        );
    }

    // Documented in AbstractRevisionPersistance
    public function getContextVariablesImplicit() {
        $cookieVersion = Consent::getInstance()->getCookieVersion();
        $context = [];

        if ($cookieVersion > Consent::COOKIE_VERSION_1) {
            $context['v'] = $cookieVersion;
        }

        // Add current blog ID to keep multisite intact (https://stackoverflow.com/q/4056306/5506547)
        $context['blog'] = get_current_blog_id();

        // Include cookie domain and path in cookie name as `document.cookie` does not determine between pathes and this
        // leads to issues with WordPress installations in main folder, subfolders and subdomains concurrently.
        if ($cookieVersion > Consent::COOKIE_VERSION_1) {
            $cookieDomainAndPathIdentifier = untrailingslashit(Utils::getOriginalHomeUrl()) . constant('COOKIEPATH');

            // Also make the fact if a cookie domain is a wildcard part of the hash. Why? Imagine, you configure
            // `.owlsrv.de`, then user gives consent, you change it to `owlsrv.de` -> the JavaScript can only
            // read the first one and the cookie banner can never be clicked away.
            if ($cookieVersion > Consent::COOKIE_VERSION_2) {
                $cookieDomain = constant('COOKIE_DOMAIN');
                $cookieDomainAndPathIdentifier .=
                    ',' . is_string($cookieDomain) && Utils::startsWith($cookieDomain, '.')
                        ? 'wildcard'
                        : 'no-wildcard';
            }

            $context['path'] = substr(md5($cookieDomainAndPathIdentifier), 0, 7);
        }

        /**
         * Get implicit context relevant options like blog id. Implicit context variables are not populated
         * to the context, nor to the revision. Use this only if you want to modify the cookie name!
         *
         * Warning: Cookie names cannot contain any of the following '=,; \t\r\n\013\014', so please make
         * sure such characters are not stored in your value (if so, they get replaced with underscore `_`).
         *
         * @hook RCB/Revision/Context/Implicit
         * @param {array} $context
         * @return {array}
         */
        return apply_filters('RCB/Revision/Context/Implicit', $context);
    }

    // Documented in AbstractRevisionPersistance
    public function getContextVariablesExplicit() {
        /**
         * Get context relevant options like language code (WPML, PolyLang). If the language
         * changes, a new revision will be created or requested so they are completely independent.
         * They also get populated to the generated revision.
         *
         * Warning: Cookie names cannot contain any of the following '=,; \t\r\n\013\014', so please make
         * sure such characters are not stored in your value (if so, they get replaced with underscore `_`).
         *
         * @hook RCB/Revision/Context
         * @param {array} $context
         * @return {array}
         */
        return apply_filters('RCB/Revision/Context', []);
    }

    // Documented in AbstractRevisionPersistance
    public function getCurrentHash() {
        return explode(':', get_option($this->getCurrentHashOptionName(), ''))[0];
    }

    // Documented in AbstractRevisionPersistance
    public function getCurrentHashTime() {
        $split = explode(':', get_option($this->getCurrentHashOptionName(), ''));

        // Can be empty (e.g. when no revision created yet or backwards-compatibility)
        return count($split) > 1 ? intval($split[1]) : 0;
    }

    /**
     * Get the option for the current hash option name in `wp_options`.
     *
     * The option holds two values in format `{{hash}}:{{timeStamp}}`.
     */
    public function getCurrentHashOptionName() {
        return self::OPTION_NAME_CURRENT_HASH_PREFIX . '-' . $this->getContextVariablesString();
    }

    /**
     * Get the revision(s) by hash(es).
     *
     * @param string|string[] $hash
     * @param boolean $independent
     * @param boolean $applyBackwardsCompatibility See method `applyBackwardsCompatibility`
     */
    public function getByHash($hash, $independent = false, $applyBackwardsCompatibility = false) {
        global $wpdb;

        $table_name = $this->getTableName($independent ? self::TABLE_NAME_INDEPENDENT : self::TABLE_NAME);

        if (is_array($hash)) {
            // Read multiple
            $hashes = array_map('sanitize_key', array_unique($hash));
            $result = [];

            // phpcs:disable WordPress.DB.PreparedSQL
            $rows = $wpdb->get_results(
                sprintf("SELECT `hash`, json_revision FROM $table_name WHERE `hash` IN ('%s')", join("','", $hashes)),
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.PreparedSQL

            // Zip to key value map
            foreach ($rows as $key => $value) {
                $decoded = json_decode($value['json_revision'], ARRAY_A);
                $result[$value['hash']] = $applyBackwardsCompatibility
                    ? $this->applyBackwardsCompatibility($decoded, $independent)
                    : $decoded;
                unset($rows[$key]);
            }

            return $result;
        } else {
            // Read single
            // phpcs:disable WordPress.DB.PreparedSQL
            $row = $wpdb->get_var($wpdb->prepare("SELECT json_revision FROM $table_name WHERE `hash` = %s", $hash));
            // phpcs:enable WordPress.DB.PreparedSQL

            if ($row === null) {
                return null;
            }

            $decoded = json_decode($row, ARRAY_A);
            return $applyBackwardsCompatibility ? $this->applyBackwardsCompatibility($decoded, $independent) : $decoded;
        }
    }

    /**
     * See filter `RCB/Revision/BackwardsCompatibility`.
     *
     * @param array $revision
     * @param boolean $independent
     */
    public function applyBackwardsCompatibility($revision, $independent = false) {
        return (new RevisionBackwardsCompatibility($revision, $independent))->apply();
    }

    /**
     * Get singleton instance.
     *
     * @codeCoverageIgnore
     */
    public static function getInstance() {
        return self::$me === null ? (self::$me = new Revision()) : self::$me;
    }
}
