<?php
declare(strict_types=1);

namespace Ekesto\CacheInvalidator\Integrations\Caching\Adapters;

use Ekesto\CacheInvalidator\Core\EmissionHelper;
use Ekesto\CacheInvalidator\Integrations\Caching\AdapterSignalGuard;
use Ekesto\CacheInvalidator\Integrations\Caching\CacheAdapterInterface;
use Ekesto\CacheInvalidator\Integrations\Caching\CacheAdapterUrlNormalizer;
use Ekesto\CacheInvalidator\Integrations\Caching\UrlPurgeCapableAdapterInterface;
use Ekesto\CacheInvalidator\Support\Debug;

/**
 * LiteSpeed Cache adapter.
 */
final class LiteSpeedCacheAdapter implements CacheAdapterInterface, UrlPurgeCapableAdapterInterface
{
    /**
     * Request-local dedupe of normalized URLs already emitted through any
     * targeted LSC hook handler in this class. Shared between
     * handleManualSingleTargetPurge() and handlePurgedLink() because LSC's
     * _purge_front() fires `litespeed_purged_link` and `litespeed_purged_front`
     * in sequence for the same URL, and AdapterSignalGuard's per-hook dedupe
     * does not collapse the two.
     *
     * @var array<string,bool>
     */
    private static array $emitted = [];

    public function getLabel(): string
    {
        return 'LiteSpeed Cache';
    }

    public function getKey(): string
    {
        return 'litespeed-cache';
    }

    /**
     * We consider LSCache available when LiteSpeed plugin constants are present.
     *
     * @return bool
     */
    public function isAvailable(): bool
    {
        return defined('LSCWP_V');
    }

    /**
     * Returns whether LiteSpeed supports runtime cache-status detection.
     *
     * @return bool
     */
    public function supportsCacheStatusDetection(): bool
    {
        return true;
    }

    /**
     * Returns whether LiteSpeed caching is currently enabled.
     *
     * @return bool
     */
    public function isCacheEnabled(): bool
    {
        return defined('LITESPEED_ON') && (bool) LITESPEED_ON;
    }

    /**
     * Register adapter-specific ingestion hooks.
     *
     * @return void
     */
    public function registerHooks(): void
    {
        add_action('litespeed_purged_front', [$this, 'handleManualSingleTargetPurge'], 10, 1);
        add_action('litespeed_purged_pages', [$this, 'handlePurgedPages'], 10, 0);
        add_action('litespeed_purged_all', [$this, 'handlePurgedAll'], 10, 0);
        add_action('litespeed_purged_all_lscache', [$this, 'handlePurgedAllLscache'], 10, 0);
        add_action('litespeed_purged_link', [$this, 'handlePurgedLink'], 10, 1);
    }

    /**
     * Handle LiteSpeed manual single-target purge events.
     *
     * @param mixed $url Purged URL payload.
     * @return void
     */
    public function handleManualSingleTargetPurge($url): void
    {
        $hook = (string) current_filter();

        $classification = $this->classifyManualSignal($url);
        if ($classification === null) {
            $this->logIgnoredSingleTarget($hook, 'invalid_payload', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => 'none',
            ]);
            return;
        }

        if (!$this->isManualSingleTargetRequest($classification)) {
            $this->logIgnoredSingleTarget($hook, 'not_manual', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => (string) $classification['type'],
            ]);
            return;
        }

        $validationFailureReason = '';
        $validated = $this->validateManualSignal($classification, $validationFailureReason);
        if ($validated === null) {
            $this->logIgnoredSingleTarget($hook, $validationFailureReason !== '' ? $validationFailureReason : 'invalid_payload', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => (string) $classification['type'],
            ]);
            return;
        }

        if (!isset($validated['url']) || !is_string($validated['url']) || $validated['url'] === '') {
            $this->logIgnoredSingleTarget($hook, 'invalid_payload', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => (string) $validated['mode'],
                'post_id' => 0,
            ]);
            return;
        }

        // If adapter introduces batch purge APIs in future, DO NOT reuse this path.
        // Trusted logic is strictly single-target only.
        $targetUrls = [$validated['url']];
        if (count($targetUrls) !== 1) {
            $this->logIgnoredSingleTarget($hook, 'invalid_url_count', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => (string) $validated['mode'],
                'post_id' => 0,
                'url_count' => count($targetUrls),
            ]);
            return;
        }

        // Guard dedupe identity depends on (adapter + hook + signal_type + url hash).
        // Do not alter $targetUrls shape or ordering before this call.
        if (!AdapterSignalGuard::shouldProcessSignal('litespeed', $hook, 'targeted', $targetUrls)) {
            $this->logIgnoredSingleTarget($hook, 'guard_rejected', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => $validated['mode'],
                'url' => $validated['url'],
                'post_id' => 0,
            ]);
            return;
        }

        // Cross-hook dedupe: LSC's _purge_front() fires `litespeed_purged_link`
        // immediately before `litespeed_purged_front` for the same URL.
        // AdapterSignalGuard's key includes the hook name so it does not
        // collapse the pair; we do here.
        if (self::wasEmittedThisRequest($validated['url'])) {
            $this->logIgnoredSingleTarget($hook, 'cross_hook_deduped', [
                'request_type' => 'admin_bar_purge_current_page',
                'classification_path' => $validated['mode'],
                'url' => $validated['url'],
                'post_id' => 0,
            ]);
            return;
        }

        Debug::log('cache_plugin_single_target_detected', [
            'source' => 'litespeed',
            'hook' => $hook,
            'accepted' => true,
            'request_type' => 'admin_bar_purge_current_page',
            'classification_path' => $validated['mode'],
            'mode' => $validated['mode'],
            'url' => $validated['url'],
            'post_id' => 0,
            'trusted' => true,
        ]);

        self::markEmittedThisRequest($validated['url']);

        EmissionHelper::emit($targetUrls, [
            'trigger_class' => 'cache_adapter',
            'trigger_event' => 'litespeed_manual_single_target_purge',
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'reason' => 'supported_cache_plugin_single_target_purge',
            'source' => 'litespeed',
            'trusted_source' => 'cache_adapter_single_target',
            'resolution' => 'TARGETED',
            'intent' => 'TARGETED',
        ]);
    }

    /**
     * Handle LiteSpeed `litespeed_purged_link` — fired by Purge::purge_url()
     * once per URL that LSC purges at runtime. Fallback signal complementing
     * the structured hooks above.
     *
     * Call sites observed in LSC:
     *   - Conf::update() diff loop (cache-exc, cache-force_uri, priv_uri,
     *     force_pub_uri, purge-timed_urls, media-lazy_uri_exc, optm-exc):
     *     passes a RELATIVE path after stripping `^`/`$` anchors.
     *   - Purge::_purge_front() / Purge::_purge_ucss(): passes an ABSOLUTE URL
     *     from HTTP_REFERER, followed by the structured `litespeed_purged_*`
     *     action for the same URL.
     *   - API::purge_url(): caller-dependent.
     *
     * We model "purge = rebuild trigger": emit TARGETED for any URL LSC
     * purges, and let the warmer handle the rest. We drop regex-pattern
     * entries because they never resolve to a real page.
     *
     * @param mixed $url URL purged by LSC (string; relative or absolute).
     * @return void
     */
    public function handlePurgedLink($url): void
    {
        $hook = (string) current_filter();

        if (!is_scalar($url)) {
            $this->logIgnoredSingleTarget($hook, 'invalid_payload', [
                'request_type' => 'runtime_purge_url',
                'classification_path' => 'none',
            ]);
            return;
        }

        $raw = trim((string) $url);
        if ($raw === '') {
            return;
        }

        // Drop LSC regex patterns early. `^`/`$` are already stripped by LSC
        // before the action fires; anything else regex-ish is a pattern, not
        // a warmable URL.
        if ($this->containsRegexMetacharacters($raw)) {
            $this->logIgnoredSingleTarget($hook, 'regex_pattern_dropped', [
                'request_type' => 'runtime_purge_url',
                'classification_path' => 'regex',
            ]);
            return;
        }

        $normalized = $this->normalizeMaybeRelativeUrl($raw);
        if ($normalized === '') {
            $this->logIgnoredSingleTarget($hook, 'invalid_or_cross_origin_url', [
                'request_type' => 'runtime_purge_url',
                'classification_path' => 'url',
            ]);
            return;
        }

        if (self::wasEmittedThisRequest($normalized)) {
            $this->logIgnoredSingleTarget($hook, 'cross_hook_deduped', [
                'request_type' => 'runtime_purge_url',
                'classification_path' => 'url',
                'url' => $normalized,
            ]);
            return;
        }

        if (!AdapterSignalGuard::shouldProcessSignal('litespeed', $hook, 'targeted', [$normalized])) {
            $this->logIgnoredSingleTarget($hook, 'guard_rejected', [
                'request_type' => 'runtime_purge_url',
                'classification_path' => 'url',
                'url' => $normalized,
            ]);
            return;
        }

        Debug::log('cache_plugin_single_target_detected', [
            'source' => 'litespeed',
            'hook' => $hook,
            'accepted' => true,
            'request_type' => 'runtime_purge_url',
            'classification_path' => 'url',
            'mode' => 'url',
            'url' => $normalized,
            'post_id' => 0,
            'trusted' => true,
        ]);

        self::markEmittedThisRequest($normalized);

        EmissionHelper::emit([$normalized], [
            'trigger_class' => 'cache_adapter',
            'trigger_event' => 'litespeed_runtime_url_purged',
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'reason' => 'supported_cache_plugin_single_target_purge',
            'source' => 'litespeed',
            'trusted_urls' => [$normalized],
            'trusted_source' => 'cache_adapter_single_target',
            'resolution' => 'TARGETED',
            'intent' => 'TARGETED',
        ]);
    }

    /**
     * Classify the LiteSpeed manual single-target purge payload.
     *
     * @param mixed $url Purged URL payload.
     * @return array{type:string,urls:array<int,mixed>,post_id:int|null}|null
     */
    private function classifyManualSignal($url): ?array
    {
        if (!is_scalar($url)) {
            return null;
        }

        return [
            'type' => 'url',
            'urls' => [(string) $url],
            'post_id' => null,
        ];
    }

    /**
     * Determine whether the classified signal is a manual request.
     *
     * @param array{type:string,urls:array<int,mixed>,post_id:int|null} $classification Classified signal.
     * @return bool
     */
    private function isManualSingleTargetRequest(array $classification): bool
    {
        return $classification['type'] === 'url';
    }

    /**
     * Validate one classified signal and resolve one normalized target URL.
     *
     * @param array{type:string,urls:array<int,mixed>,post_id:int|null} $classification Classified signal.
     * @return array{
     *   urls:array<int,string>,
     *   url:string,
     *   mode:string
     * }|null
     */
    private function validateManualSignal(array $classification, string &$failureReason = ''): ?array
    {
        if ($classification['type'] !== 'url' || count($classification['urls']) !== 1) {
            return null;
        }

        $url = CacheAdapterUrlNormalizer::normalize($classification['urls'][0]);
        if ($url === '') {
            $failureReason = 'invalid_or_cross_origin_url';
            return null;
        }

        return [
            'urls' => [$url],
            'url' => $url,
            'mode' => 'url',
        ];
    }

    /**
     * Normalize a URL that may be relative (LSC passes relative paths from
     * its option-diff flow) or absolute (manual/API purges) into one canonical
     * absolute same-origin URL. Returns '' on failure.
     *
     * Reuses CacheAdapterUrlNormalizer::normalize() for validation; the only
     * thing we add is promoting scheme-less input onto home_url() first.
     *
     * @param string $url Raw URL string from `litespeed_purged_link`.
     * @return string Canonical absolute URL, or '' if invalid / cross-origin.
     */
    private function normalizeMaybeRelativeUrl(string $url): string
    {
        $candidate = trim($url);
        if ($candidate === '') {
            return '';
        }

        $parts = wp_parse_url($candidate);
        $hasScheme = is_array($parts) && isset($parts['scheme']) && (string) $parts['scheme'] !== '';

        if (!$hasScheme) {
            // Treat protocol-relative (`//host/path`) as scheme-less and let
            // CacheAdapterUrlNormalizer::normalize() reject it — we don't want cross-origin
            // input promoted by accident.
            if (strpos($candidate, '//') === 0) {
                return '';
            }

            $path = $candidate;
            if ($path[0] !== '/') {
                $path = '/' . $path;
            }
            $candidate = rtrim(home_url('/'), '/') . $path;
        }

        $normalized = CacheAdapterUrlNormalizer::normalize($candidate);
        if ($normalized === '') {
            return '';
        }

        // Collapse `/journal` and `/journal/` onto the same dedupe key.
        // CacheAdapterUrlNormalizer::normalize() already rtrims the path for
        // non-root inputs, but this is defensive: anything slipping past
        // (e.g. root host `https://example.com/`) gets flattened for
        // dedupe stability. This is LSC-adapter-local by design.
        return untrailingslashit($normalized);
    }

    /**
     * True if the input looks like an LSC regex pattern rather than a URL.
     * LSC strips `^`/`$` before the action fires, so we only need to guard
     * against the remaining regex metacharacters.
     *
     * @param string $url Raw LSC purge string.
     * @return bool
     */
    private function containsRegexMetacharacters(string $url): bool
    {
        return (bool) preg_match('/[\*\(\)\[\]\|\\\\\+\?\{\}]/', $url);
    }

    /**
     * Request-local cross-hook dedupe check. See $emitted docblock.
     *
     * @param string $normalizedUrl Canonical URL from CacheAdapterUrlNormalizer::normalize().
     * @return bool
     */
    private static function wasEmittedThisRequest(string $normalizedUrl): bool
    {
        return $normalizedUrl !== '' && isset(self::$emitted[$normalizedUrl]);
    }

    /**
     * Record that a URL has been emitted in this request.
     *
     * @param string $normalizedUrl Canonical URL from CacheAdapterUrlNormalizer::normalize().
     * @return void
     */
    private static function markEmittedThisRequest(string $normalizedUrl): void
    {
        if ($normalizedUrl === '') {
            return;
        }
        self::$emitted[$normalizedUrl] = true;
    }

    /**
     * Log an ignored manual single-target signal.
     *
     * @param string               $hook Hook name.
     * @param string               $reason Ignore reason key.
     * @param array<string,mixed> $context Additional context.
     * @return void
     */
    private function logIgnoredSingleTarget(string $hook, string $reason, array $context = []): void
    {
        if (Debug::verboseEnabled()) {
            Debug::log('cache_plugin_single_target_ignored', array_merge([
                'source' => 'litespeed',
                'hook' => $hook,
                'accepted' => false,
                'reason' => $reason,
                'url' => '',
                'post_id' => 0,
            ], $context));
        }
    }

    /**
     * Handle LiteSpeed broad pages purge events.
     *
     * @return void
     */
    public function handlePurgedPages(): void
    {
        $hook = (string) current_filter();
        if (!AdapterSignalGuard::shouldProcessSignal('litespeed', $hook, 'global_invalidation')) {
            return;
        }

        EmissionHelper::emit([], [
            'trigger_class' => 'system',
            'trigger_event' => 'cache_plugin_cleared',
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'reason' => 'cache_plugin_clear',
            'source' => 'litespeed',
            'resolution' => 'GLOBAL_INVALIDATION',
            'intent' => 'GLOBAL_INVALIDATION',
        ]);
    }

    /**
     * Handle LiteSpeed full purge events.
     *
     * @return void
     */
    public function handlePurgedAll(): void
    {
        $hook = (string) current_filter();
        if (!AdapterSignalGuard::shouldProcessSignal('litespeed', $hook, 'global_invalidation')) {
            return;
        }

        EmissionHelper::emit([], [
            'trigger_class' => 'system',
            'trigger_event' => 'cache_plugin_cleared',
            'trigger_post_id' => 0,
            'trigger_post_type' => '',
            'reason' => 'cache_plugin_clear',
            'source' => 'litespeed',
            'resolution' => 'GLOBAL_INVALIDATION',
            'intent' => 'GLOBAL_INVALIDATION',
        ]);
    }

    /**
     * Handle LiteSpeed full lscache purge events.
     *
     * @return void
     */
    public function handlePurgedAllLscache(): void
    {
        $this->handlePurgedAll();
    }

    /**
     * Purge one post id.
     *
     * @param int $postId
     * @return bool
     */
    public function purgePostId(int $postId): bool
    {
        $postId = absint($postId);
        if ($postId <= 0 || !$this->isAvailable()) {
            return false;
        }

        AdapterSignalGuard::runWithSuppressedSignals(static function () use ($postId): void {
            do_action('litespeed_purge_post', $postId);
        });

        return true;
    }

    /**
     * Purge one absolute URL.
     *
     * @param string $url
     * @return bool
     */
    public function purgeUrl(string $url): bool
    {
        $url = trim($url);
        if ($url === '' || !$this->isAvailable()) {
            return false;
        }

        AdapterSignalGuard::runWithSuppressedSignals(static function () use ($url): void {
            do_action('litespeed_purge_url', $url);
        });

        return true;
    }

    /**
     * Purge all cache entries.
     *
     * @return bool
     */
    public function purgeAll(): bool
    {
        if (!$this->isAvailable()) {
            return false;
        }

        AdapterSignalGuard::runWithSuppressedSignals(static function (): void {
            do_action('litespeed_purge_all');
        });

        return true;
    }
}
