<?php
/**
 * Copyright © 2019-2026 Rhubarb Tech Inc. All Rights Reserved.
 *
 * The Object Cache Pro Software and its related materials are property and confidential
 * information of Rhubarb Tech Inc. Any reproduction, use, distribution, or exploitation
 * of the Object Cache Pro Software and its related materials, in whole or in part,
 * is strictly forbidden unless prior permission is obtained from Rhubarb Tech Inc.
 *
 * In addition, any reproduction, use, distribution, or exploitation of the Object Cache Pro
 * Software and its related materials, in whole or in part, is subject to the End-User License
 * Agreement accessible in the included `LICENSE` file, or at: https://objectcache.pro/eula
 */

declare(strict_types=1);

namespace RedisCachePro\ObjectCaches\Concerns;

use Throwable;

use RedisCachePro\Connections\RelayConnection;
use RedisCachePro\Exceptions\MetadataException;
use RedisCachePro\Connectors\Concerns\HandlesBackoff;

use function RedisCachePro\log;

/**
 * Keeps track of object cache metadata, such as the used configuration.
 *
 * If risky configuration options have changed and the `strict` mode is
 * enabled, the cache will be automatically flushed to avoid collisions.
 */
trait KeepsMetadata
{
    use HandlesBackoff;

    /**
     * The stored object cache metadata.
     *
     * @var ?array<string, array<string, mixed>>
     */
    private $metadata;

    /**
     * Boots the metadata component.
     *
     * @return void
     */
    protected function bootMetadata(): void
    {
        try {
            $this->loadMetadata();
            $this->throwIfRiskyConfigurationChanged();
        } catch (MetadataException $exception) {
            $this->integrityProtectionFlush($exception);
        } catch (Throwable $exception) {
            throw $exception;
        }

        $this->maybeFlushRelayMemory();
        $this->maybeResetRelayRatiosMemory();

        $this->maybeUpdateMetadata();
    }

    /**
     * Loads the metadata from the cache.
     *
     * @return void
     */
    private function loadMetadata()
    {
        $json = $this->retrieveMetadataWithRetries();

        if (! is_string($json)) {
            throw new MetadataException(
                'Cache metadata not found',
                MetadataException::NOT_FOUND
            );
        }

        $metadata = json_decode($json, true);

        if (! is_array($metadata)) {
            throw new MetadataException(sprintf(
                'Unable to decode cache metadata (%s)',
                (json_last_error() !== JSON_ERROR_NONE)
                    ? json_last_error_msg()
                    : gettype($metadata) . ' found'
            ), MetadataException::DECODE_FAILED);
        }

        $this->metadata = $metadata;
    }

    /**
     * Returns the stored metadata from the cache.
     *
     * @return string
     */
    private function retrieveMetadataWithRetries()
    {
        $retries = 3;
        $attempt = $delay = 0;

        while (true) {
            $delay = self::nextDelay($this->config(), $attempt, $delay);

            try {
                return $this->withoutMutations([$this, 'getMetadata']);
            } catch (Throwable $exception) {
                if (++$attempt >= $retries) {
                    throw $exception;
                }

                \usleep($delay * 1000);
            }
        }
    }

    /**
     * Saves the current metadata to the cache.
     *
     * @return void
     */
    public function writeMetadata()
    {
        $this->metadata = $this->buildMetadata();
        $this->withoutMutations([$this, 'setMetadata']);
    }

    /**
     * Build the metadata based on the current configuration.
     *
     * @return array<string, array<string, mixed>>
     */
    private function buildMetadata(): array
    {
        global $wp_version;

        return [
            'config' => [
                'client' => strtolower($this->clientName()),
                'database' => $this->config->database,
                'prefix' => $this->config->prefix,
                'serializer' => $this->config->serializer,
                'compression' => $this->config->compression,
                'prefetch' => $this->config->prefetch,
                'split_alloptions' => $this->config->split_alloptions,
            ],
            'versions' => [
                'wordpress' => $wp_version,
            ],
            'relay' => [
                'flushed_at' => $this->metadata('relay.flushed_at'),
                'ratios_reset_at' => $this->metadata('relay.ratios_reset_at'),
            ],
        ];
    }

    /**
     * Flushes the Relay memory for the current FPM pool if needed.
     *
     * @return void
     */
    private function maybeFlushRelayMemory()
    {
        if (! ($this->connection instanceof RelayConnection)) {
            return;
        }

        $flushRequestedAt = $this->metadata('relay.flushed_at');

        if (! $flushRequestedAt) {
            return;
        }

        $this->connection->maybeFlushRelayMemory($flushRequestedAt, $this->config->database);
    }

    /**
     * Resets the Relay ratios memory for the current FPM pool if needed.
     *
     * @return void
     */
    private function maybeResetRelayRatiosMemory()
    {
        if (! ($this->connection instanceof RelayConnection)) {
            return;
        }

        $resetRequestedAt = $this->metadata('relay.ratios_reset_at');

        if (! $resetRequestedAt) {
            return;
        }

        $resetAt = $this->connection->adaptiveCache()->lastFlush();

        if ($resetAt < $resetRequestedAt) {
            $this->connection->adaptiveCache()->flush();
        }
    }

    /**
     * Resets the Relay ratios memory for the current FPM pool if needed.
     *
     * @return void
     */
    private function resetRelayRatios()
    {
        if (! ($this->connection instanceof RelayConnection)) {
            return;
        }

        $this->connection->adaptiveCache()->flush();
    }

    /**
     * Throws an exception if a risky configuration option has changed.
     *
     * @return void
     */
    private function throwIfRiskyConfigurationChanged()
    {
        global $wp_version;

        $storedConfig = $this->metadata['config'] ?? [];
        $currentConfig = $this->buildMetadata()['config'];

        $riskyOptions = [
            'database', // avoid loading foreign dataset
            'prefix', // avoid loading foreign dataset
            'split_alloptions', // avoid loading stale `alloptions` data
            'serializer', // mixing serializers will cause fatal errors
            'compression', // mixing data compressions will cause fatal errors
        ];

        foreach ($riskyOptions as $option) {
            if (! array_key_exists($option, $storedConfig) || $storedConfig[$option] !== $currentConfig[$option]) {
                throw MetadataException::for($option);
            }
        }

        // Relay dataset could be stale
        if (! array_key_exists('client', $storedConfig) || strcasecmp(
            (string) ($storedConfig['client'] ?? ''),
            (string) ($currentConfig['client'] ?? '')
        ) !== 0) {
            throw MetadataException::for('client');
        }

        $storedVersion = $this->metadata('versions.wordpress') ?? '0';

        if (version_compare((string) $wp_version, $storedVersion, '<>')) {
            throw new MetadataException(
                'WordPress version has changed',
                MetadataException::VERSION_WORDPRESS
            );
        }
    }

    /**
     * Updates the object cache metadata, if it has changed.
     *
     * @return void
     */
    private function maybeUpdateMetadata()
    {
        $metadata = $this->buildMetadata();

        $configChanges = array_diff_assoc(
            $metadata['config'],
            $this->metadata['config'] ?? []
        );

        $versionChanges = array_diff_assoc(
            $metadata['versions'],
            $this->metadata['versions'] ?? []
        );

        if (! empty($configChanges) || ! empty($versionChanges)) {
            $this->writeMetadata();
        }
    }

    /**
     * Flushes the object cache for integrity protection, if `strict` mode is enabled.
     *
     * @param  \RedisCachePro\Exceptions\MetadataException  $exception
     * @return bool
     */
    private function integrityProtectionFlush(MetadataException $exception)
    {
        global $wp_object_cache_flushlog;

        $this->metadata = null;

        $message = $exception->getMessage();

        if (! $this->config->strict) {
            log('notice', "{$message}, skipping integrity protection flush because `strict` mode is disabled");

            return false;
        }

        log('notice', "{$message}, flushing cache for integrity protection...");

        $wp_object_cache_flushlog[] = [
            'type' => 'flush',
            'reason' => $message,
            'backtrace' => \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 5),
        ];

        try {
            $this->flush_runtime();

            return $this->connection->flushdb();
        } catch (Throwable $exception) {
            $this->error($exception);

            return false;
        } finally {
            $this->metrics->flush();

            if ($exception->getCode() === MetadataException::PREFIX_CHANGED) {
                $this->resetRelayRatios();
            }
        }
    }

    /**
     * Returns or set metadata paths.
     *
     * @param  string  $path
     * @param  mixed  $value
     * @return mixed
     */
    public function metadata($path, $value = null)
    {
        if (strpos($path, '.') === false) {
            if (! is_null($value)) {
                $this->metadata[$path] = $value;
                $this->writeMetadata();
            }

            return $this->metadata[$path] ?? null;
        }

        [$group, $key] = explode('.', $path);

        if (! is_null($value)) {
            $this->metadata[$group][$key] = $value;
            $this->writeMetadata();
        }

        return $this->metadata[$group][$key] ?? null;
    }

    /**
     * Internal callback for `loadMetadata()`, improves Query Monitor readability.
     *
     * @internal
     * @return string|false
     */
    public function getMetadata()
    {
        return $this->get('meta', 'objectcache');
    }

    /**
     * Internal callback for `writeMetadata()`, improves Query Monitor readability.
     *
     * Ignores `maxttl` configuration option.
     *
     * @internal
     * @return void
     */
    public function setMetadata()
    {
        $this->connection->set(
            (string) $this->id('meta', 'objectcache'),
            json_encode($this->metadata)
        );
    }
}
