<?php

namespace VulnTitan\Services;

use WP_Error;

class CommentSpamService
{
    protected const TOKEN_FIELD = 'vulntitan_comment_token';
    protected const HONEYPOT_FIELD = 'vulntitan_contact_hp';
    protected const DUPLICATE_PREFIX = 'vulntitan_cs_dup_';
    protected const RATE_LIMIT_PREFIX = 'vulntitan_cs_rate_';
    protected const MIN_TOKEN_AGE = 1;
    protected const MAX_TOKEN_AGE = 2 * HOUR_IN_SECONDS;
    protected const MAX_TOKEN_FUTURE_SKEW = 5 * MINUTE_IN_SECONDS;
    protected const SHORT_COMMENT_MAX_WORDS = 3;
    protected const SHORT_COMMENT_MAX_LENGTH = 40;

    protected static bool $booted = false;

    /**
     * Cached decision for the current request so multi-step comment insertion
     * does not increment counters or emit duplicate logs.
     *
     * @var array<string,mixed>|null
     */
    protected static ?array $pendingDecision = null;
    protected static array $requestFieldOverrides = [];

    public static function boot(): void
    {
        if (self::$booted) {
            return;
        }

        self::$booted = true;

        add_action('comment_form_after_fields', [__CLASS__, 'renderCommentFormFields']);
        add_action('comment_form_logged_in_after', [__CLASS__, 'renderCommentFormFields']);
        add_filter('preprocess_comment', [__CLASS__, 'captureCommentDecision'], 9);
        add_filter('rest_preprocess_comment', [__CLASS__, 'captureRestCommentDecision'], 10, 2);
        add_filter('pre_comment_approved', [__CLASS__, 'applyCommentDecision'], 10, 2);
    }

    public static function renderCommentFormFields(): void
    {
        if (!self::isEnabled()) {
            return;
        }

        $postId = 0;
        if (function_exists('get_the_ID')) {
            $postId = (int) get_the_ID();
        }
        if ($postId <= 0 && function_exists('get_queried_object_id')) {
            $postId = (int) get_queried_object_id();
        }

        echo '<p class="comment-form-vulntitan-honeypot" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" aria-hidden="true">';
        echo '<label for="vulntitan-contact-hp">' . esc_html__('Leave this field empty', 'vulntitan') . '</label>';
        echo '<input type="text" id="vulntitan-contact-hp" name="' . esc_attr(self::HONEYPOT_FIELD) . '" value="" tabindex="-1" autocomplete="off" inputmode="text">';
        echo '</p>';
        echo '<input type="hidden" name="' . esc_attr(self::TOKEN_FIELD) . '" value="' . esc_attr(self::buildFormToken($postId)) . '">';
    }

    public static function captureCommentDecision(array $commentData): array
    {
        self::$requestFieldOverrides = [];
        self::$pendingDecision = self::evaluateDecision($commentData, false);

        return $commentData;
    }

    /**
     * @param array<string,mixed> $preparedComment
     * @return array<string,mixed>
     */
    public static function captureRestCommentDecision(array $preparedComment, $request): array
    {
        self::$requestFieldOverrides = self::extractRestRequestFields($request);
        self::$pendingDecision = self::evaluateDecision($preparedComment, true);

        return $preparedComment;
    }

    /**
     * @param int|string|WP_Error $approved
     * @param array<string,mixed> $commentData
     * @return int|string|WP_Error
     */
    public static function applyCommentDecision($approved, array $commentData)
    {
        if (is_wp_error($approved)) {
            return $approved;
        }

        $decision = self::$pendingDecision;
        if ($decision === null) {
            $decision = self::evaluateDecision($commentData, defined('REST_REQUEST') && REST_REQUEST);
            self::$pendingDecision = $decision;
        }

        if (($decision['action'] ?? 'allow') === 'allow') {
            if (self::isEnabled() && self::supportsCommentType($commentData) && !self::shouldBypass($commentData) && ($approved === 0 || $approved === '0')) {
                self::logPendingModeration($commentData);
            }

            return $approved;
        }

        if (($decision['action'] ?? '') !== 'block' && in_array($approved, ['spam', 'trash'], true)) {
            return $approved;
        }

        if (empty($decision['logged'])) {
            self::logDecision($decision, $commentData);
            self::$pendingDecision['logged'] = true;
            $decision['logged'] = true;
        }

        if (($decision['action'] ?? '') === 'block') {
            return new WP_Error(
                (string) ($decision['error_code'] ?? 'vulntitan_comment_spam_blocked'),
                (string) ($decision['message'] ?? __('Your comment was blocked by anti-spam protection.', 'vulntitan')),
                (int) ($decision['response_code'] ?? 403)
            );
        }

        return 0;
    }

    protected static function evaluateDecision(array $commentData, bool $isRest): array
    {
        $decision = self::allowDecision();

        if (!self::isEnabled() || !self::supportsCommentType($commentData)) {
            return $decision;
        }

        if (self::shouldBypass($commentData)) {
            return $decision;
        }

        $settings = FirewallService::getSettings();
        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
        $normalizedContent = self::normalizeCommentContent($commentContent);
        $authorName = isset($commentData['comment_author']) ? self::normalizeCommentContent((string) wp_unslash($commentData['comment_author'])) : '';
        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
        $postId = (int) ($commentData['comment_post_ID'] ?? 0);
        $ipAddress = self::resolveIpAddress($commentData);
        $visitorKey = self::buildVisitorKey($commentData, $ipAddress);
        $rateWindowMinutes = max(1, (int) ($settings['submission_rate_limit_window_minutes'] ?? $settings['comment_rate_limit_window_minutes'] ?? 10));
        $rateCount = self::incrementRateCounter($visitorKey, $rateWindowMinutes);
        $maxAttempts = max(1, (int) ($settings['submission_rate_limit_attempts'] ?? $settings['comment_rate_limit_attempts'] ?? 3));
        $linkCount = self::countLinks($commentContent);
        $authorDomain = self::normalizeDomain($authorUrl);
        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);
        [$topExternalDomain, $topExternalHits] = self::findTopDomain($externalDomains);
        $spamKeywordMatches = self::matchSpamKeywords(implode("\n", array_filter([$normalizedContent, $authorName, self::normalizeCommentContent($authorUrl)])));
        $promotionalPhraseMatches = self::matchPromotionalPhrases($normalizedContent);
        $wordCount = self::countWords($normalizedContent);
        $contentLength = self::measureLength($normalizedContent);

        if ($rateCount > $maxAttempts) {
            return self::buildDecision(
                'block',
                'comment_rate_limited',
                'ip_rate_limit',
                __('Comment submission rate limit exceeded.', 'vulntitan'),
                __('Too many comment attempts from your connection. Please wait before trying again.', 'vulntitan'),
                429,
                [
                    'post_id' => $postId,
                    'rate_count' => $rateCount,
                    'rate_limit' => $maxAttempts,
                    'window_minutes' => $rateWindowMinutes,
                ],
                [
                    'comment_hash' => self::buildCommentHash($normalizedContent),
                    'link_count' => $linkCount,
                ]
            );
        }

        $honeypotValue = self::readRequestField(self::HONEYPOT_FIELD);
        if ($honeypotValue !== '') {
            return self::buildDecision(
                'block',
                'comment_spam_blocked',
                'honeypot_filled',
                __('Blocked comment submission because the hidden honeypot field was filled.', 'vulntitan'),
                __('Your comment was blocked by anti-spam protection.', 'vulntitan'),
                403,
                [
                    'post_id' => $postId,
                    'rate_count' => $rateCount,
                    'window_minutes' => $rateWindowMinutes,
                ],
                [
                    'comment_hash' => self::buildCommentHash($normalizedContent),
                ]
            );
        }

        $tokenValidation = self::validateFormToken((string) self::readRequestField(self::TOKEN_FIELD), $postId);
        if ($isRest && !self::isAuthenticatedCommenter($commentData) && empty($tokenValidation['present'])) {
            return self::buildDecisionFromSuspiciousAction(
                (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                'missing_form_token',
                __('REST comment request is missing the signed anti-spam token.', 'vulntitan'),
                [
                    'post_id' => $postId,
                    'rate_count' => $rateCount,
                    'window_minutes' => $rateWindowMinutes,
                ],
                [
                    'comment_hash' => self::buildCommentHash($normalizedContent),
                ]
            );
        }

        if (!empty($tokenValidation['present']) && empty($tokenValidation['valid'])) {
            return self::buildDecisionFromSuspiciousAction(
                (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                'invalid_form_token',
                __('Comment form token validation failed.', 'vulntitan'),
                [
                    'post_id' => $postId,
                    'rate_count' => $rateCount,
                    'window_minutes' => $rateWindowMinutes,
                ],
                [
                    'comment_hash' => self::buildCommentHash($normalizedContent),
                ]
            );
        }

        $minSeconds = max(self::MIN_TOKEN_AGE, (int) ($settings['submission_min_submit_seconds'] ?? $settings['comment_min_submit_seconds'] ?? 3));
        $tokenAge = (int) ($tokenValidation['age'] ?? 0);
        if (!empty($tokenValidation['valid']) && $tokenAge > 0 && $tokenAge < $minSeconds) {
            return self::buildDecisionFromSuspiciousAction(
                (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                'submitted_too_fast',
                __('Comment was submitted faster than the configured minimum time threshold.', 'vulntitan'),
                [
                    'post_id' => $postId,
                    'rate_count' => $rateCount,
                    'window_minutes' => $rateWindowMinutes,
                    'submitted_after_seconds' => $tokenAge,
                    'minimum_seconds' => $minSeconds,
                ],
                [
                    'comment_hash' => self::buildCommentHash($normalizedContent),
                ]
            );
        }

        if ($normalizedContent !== '') {
            $duplicateHits = self::markDuplicateFingerprint($postId, $visitorKey, $normalizedContent, $rateWindowMinutes);
            if ($duplicateHits > 1) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'duplicate_comment_hash',
                    __('Repeated duplicate comment content detected in the active protection window.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'duplicate_hits' => $duplicateHits,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                    ]
                );
            }
        }

        if (!self::isAuthenticatedCommenter($commentData)) {
            $maxLinks = max(0, (int) ($settings['submission_max_links'] ?? $settings['comment_max_links'] ?? 2));

            if ($spamKeywordMatches !== [] && $externalDomains !== []) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'spam_keywords_with_external_url',
                    __('Comment contains common spam keywords together with an external URL or website.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'link_count' => $linkCount,
                        'author_url_domain' => $authorDomain,
                        'matched_keywords' => $spamKeywordMatches,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'link_count' => $linkCount,
                        'external_domains' => array_values(array_unique($externalDomains)),
                    ]
                );
            }

            if (count($spamKeywordMatches) >= 2) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'spam_keyword_cluster',
                    __('Comment contains multiple high-risk spam keyword categories.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'matched_keywords' => $spamKeywordMatches,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'matched_keywords' => $spamKeywordMatches,
                    ]
                );
            }

            if ($topExternalDomain !== '' && $topExternalHits >= 2) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'repeated_external_domain',
                    __('Comment repeats the same external domain across the message and author website fields.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'top_external_domain' => $topExternalDomain,
                        'top_external_domain_hits' => $topExternalHits,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'link_count' => $linkCount,
                        'external_domains' => array_values(array_unique($externalDomains)),
                        'top_external_domain' => $topExternalDomain,
                        'top_external_domain_hits' => $topExternalHits,
                    ]
                );
            }

            if ($promotionalPhraseMatches !== [] && $externalDomains !== []) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'promotional_link_comment',
                    __('Comment combines promotional phrases with an external link or website.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'matched_promotions' => $promotionalPhraseMatches,
                        'link_count' => $linkCount,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'external_domains' => array_values(array_unique($externalDomains)),
                        'matched_promotions' => $promotionalPhraseMatches,
                    ]
                );
            }

            if ($externalDomains !== [] && $wordCount > 0 && $wordCount <= self::SHORT_COMMENT_MAX_WORDS && $contentLength <= self::SHORT_COMMENT_MAX_LENGTH) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'thin_external_link_comment',
                    __('Very short guest comment submitted with an external link or website.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'word_count' => $wordCount,
                        'content_length' => $contentLength,
                        'author_url_domain' => $authorDomain,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'link_count' => $linkCount,
                        'external_domains' => array_values(array_unique($externalDomains)),
                    ]
                );
            }

            if ($linkCount > $maxLinks) {
                return self::buildDecisionFromSuspiciousAction(
                    (string) ($settings['comment_suspicious_action'] ?? 'hold'),
                    'too_many_links',
                    __('Comment contains more links than the configured threshold for guest commenters.', 'vulntitan'),
                    [
                        'post_id' => $postId,
                        'rate_count' => $rateCount,
                        'window_minutes' => $rateWindowMinutes,
                        'link_count' => $linkCount,
                        'max_links' => $maxLinks,
                    ],
                    [
                        'comment_hash' => self::buildCommentHash($normalizedContent),
                        'link_count' => $linkCount,
                    ]
                );
            }
        }

        return $decision;
    }

    protected static function allowDecision(): array
    {
        return [
            'action' => 'allow',
            'logged' => false,
        ];
    }

    protected static function buildDecisionFromSuspiciousAction(string $action, string $ruleId, string $reason, array $details, array $context): array
    {
        if ($action === 'block') {
            return self::buildDecision(
                'block',
                'comment_spam_blocked',
                $ruleId,
                $reason,
                __('Your comment was blocked by anti-spam protection.', 'vulntitan'),
                403,
                $details,
                $context
            );
        }

        return self::buildDecision(
            'hold',
            'comment_spam_held',
            $ruleId,
            $reason,
            __('Your comment is awaiting moderation.', 'vulntitan'),
            0,
            $details,
            $context
        );
    }

    protected static function buildDecision(
        string $action,
        string $eventType,
        string $ruleId,
        string $reason,
        string $message,
        int $responseCode,
        array $details,
        array $context
    ): array {
        $details['action'] = $action;

        if ($action === 'hold') {
            $details['approval_status'] = 'pending';
        }

        return [
            'action' => $action,
            'event_type' => $eventType,
            'rule_group' => 'comment_spam',
            'rule_id' => $ruleId,
            'reason' => $reason,
            'message' => $message,
            'error_code' => 'vulntitan_' . $eventType,
            'response_code' => $responseCode,
            'details' => $details,
            'context' => $context,
            'logged' => false,
        ];
    }

    protected static function logDecision(array $decision, array $commentData): void
    {
        $ipAddress = self::resolveIpAddress($commentData);
        $eventType = (string) ($decision['event_type'] ?? 'comment_spam_blocked');
        $isBlocked = ($decision['action'] ?? '') === 'block';

        FirewallService::logEvent($eventType, [
            'event_source' => 'comment_shield',
            'event_action' => $isBlocked ? 'block' : 'log',
            'blocked' => $isBlocked ? 1 : 0,
            'response_code' => (int) ($decision['response_code'] ?? ($isBlocked ? 403 : 0)),
            'severity' => $isBlocked ? 4 : 2,
            'ip_address' => $ipAddress,
            'username' => self::resolveUsername($commentData),
            'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')),
            'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
            'request_path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
            'rule_group' => (string) ($decision['rule_group'] ?? 'comment_spam'),
            'rule_id' => (string) ($decision['rule_id'] ?? ''),
            'reason' => (string) ($decision['reason'] ?? ''),
            'details' => $decision['details'] ?? [],
            'context' => $decision['context'] ?? [],
        ]);
    }

    protected static function logPendingModeration(array $commentData): void
    {
        $commentContent = isset($commentData['comment_content']) ? (string) wp_unslash($commentData['comment_content']) : '';
        $normalizedContent = self::normalizeCommentContent($commentContent);
        $authorUrl = isset($commentData['comment_author_url']) ? trim((string) wp_unslash($commentData['comment_author_url'])) : '';
        $authorDomain = self::normalizeDomain($authorUrl);
        $externalDomains = self::extractExternalDomains($commentContent, $authorDomain);

        FirewallService::logEvent('comment_pending_review', [
            'event_source' => 'comment_shield',
            'event_action' => 'log',
            'blocked' => 0,
            'response_code' => 0,
            'severity' => 1,
            'ip_address' => self::resolveIpAddress($commentData),
            'username' => self::resolveUsername($commentData),
            'request_method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'POST')),
            'request_uri' => (string) ($_SERVER['REQUEST_URI'] ?? ''),
            'request_path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
            'rule_group' => 'comment_moderation',
            'rule_id' => 'wordpress_pending_review',
            'reason' => __('Comment entered the moderation queue.', 'vulntitan'),
            'details' => [
                'action' => 'hold',
                'approval_status' => 'pending',
                'post_id' => (int) ($commentData['comment_post_ID'] ?? 0),
                'link_count' => self::countLinks($commentContent),
                'author_url_domain' => $authorDomain,
            ],
            'context' => [
                'comment_hash' => self::buildCommentHash($normalizedContent),
                'external_domains' => array_values(array_unique($externalDomains)),
            ],
        ]);
    }

    protected static function isEnabled(): bool
    {
        $settings = FirewallService::getSettings();

        return !empty($settings['comment_shield_enabled']);
    }

    protected static function supportsCommentType(array $commentData): bool
    {
        $commentType = strtolower(trim((string) ($commentData['comment_type'] ?? 'comment')));

        return $commentType === '' || $commentType === 'comment';
    }

    protected static function shouldBypass(array $commentData): bool
    {
        if (function_exists('current_user_can') && current_user_can('moderate_comments')) {
            return true;
        }

        $userId = (int) ($commentData['user_id'] ?? $commentData['user_ID'] ?? 0);
        if ($userId <= 0 || !function_exists('get_userdata')) {
            return false;
        }

        $user = get_userdata($userId);

        return $user instanceof \WP_User && $user->has_cap('moderate_comments');
    }

    protected static function resolveIpAddress(array $commentData): string
    {
        $ipAddress = trim((string) ($commentData['comment_author_IP'] ?? ''));
        if ($ipAddress !== '') {
            return $ipAddress;
        }

        return FirewallService::detectClientIp();
    }

    protected static function resolveUsername(array $commentData): string
    {
        if (!empty($commentData['user_id']) && function_exists('get_userdata')) {
            $user = get_userdata((int) $commentData['user_id']);
            if ($user instanceof \WP_User) {
                return sanitize_user((string) $user->user_login, true);
            }
        }

        return sanitize_user((string) ($commentData['comment_author'] ?? ''), true);
    }

    protected static function buildVisitorKey(array $commentData, string $ipAddress): string
    {
        $parts = [
            (string) ($commentData['user_id'] ?? $commentData['user_ID'] ?? 0),
            strtolower(trim((string) wp_unslash($commentData['comment_author_email'] ?? ''))),
            $ipAddress,
            strtolower(trim((string) wp_unslash($commentData['comment_agent'] ?? ($_SERVER['HTTP_USER_AGENT'] ?? '')))),
        ];

        return sha1(implode('|', $parts));
    }

    protected static function incrementRateCounter(string $visitorKey, int $windowMinutes): int
    {
        $transientKey = self::RATE_LIMIT_PREFIX . substr($visitorKey, 0, 40);
        $data = get_transient($transientKey);

        if (!is_array($data)) {
            $data = [
                'count' => 0,
                'started_at' => time(),
            ];
        }

        $data['count'] = max(0, (int) ($data['count'] ?? 0)) + 1;
        $data['started_at'] = (int) ($data['started_at'] ?? time());

        set_transient($transientKey, $data, max(1, $windowMinutes) * MINUTE_IN_SECONDS);

        return (int) $data['count'];
    }

    protected static function markDuplicateFingerprint(int $postId, string $visitorKey, string $normalizedContent, int $windowMinutes): int
    {
        $contentHash = self::buildCommentHash($normalizedContent);
        if ($contentHash === '') {
            return 0;
        }

        $transientKey = self::DUPLICATE_PREFIX . substr(sha1($postId . '|' . $visitorKey . '|' . $contentHash), 0, 40);
        $count = (int) get_transient($transientKey);
        $count++;

        set_transient($transientKey, $count, max(1, $windowMinutes) * MINUTE_IN_SECONDS);

        return $count;
    }

    protected static function normalizeCommentContent(string $content): string
    {
        $content = wp_strip_all_tags($content);
        $content = strtolower(trim($content));
        $content = preg_replace('/\s+/u', ' ', $content);

        return is_string($content) ? $content : '';
    }

    protected static function buildCommentHash(string $normalizedContent): string
    {
        if ($normalizedContent === '') {
            return '';
        }

        return sha1($normalizedContent);
    }

    protected static function countLinks(string $content): int
    {
        if ($content === '') {
            return 0;
        }

        preg_match_all('/https?:\/\/|www\./i', $content, $matches);

        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
    }

    /**
     * @return array<int,string>
     */
    protected static function extractExternalDomains(string $content, string $authorDomain = ''): array
    {
        $domains = self::extractLinkDomains($content);

        if ($authorDomain !== '') {
            $domains[] = $authorDomain;
        }

        return array_values(array_filter($domains, [__CLASS__, 'isExternalDomain']));
    }

    /**
     * @return array<int,string>
     */
    protected static function extractLinkDomains(string $content): array
    {
        if ($content === '') {
            return [];
        }

        preg_match_all('~(?:https?://|www\.)[^\s<>"\']+~iu', $content, $matches);
        $rawUrls = is_array($matches[0] ?? null) ? $matches[0] : [];
        $domains = [];

        foreach ($rawUrls as $rawUrl) {
            if (!is_string($rawUrl)) {
                continue;
            }

            $domain = self::normalizeDomain($rawUrl);
            if ($domain !== '') {
                $domains[] = $domain;
            }
        }

        return $domains;
    }

    protected static function normalizeDomain(string $value): string
    {
        $value = trim($value);
        if ($value === '') {
            return '';
        }

        if (strpos($value, '://') === false) {
            $value = 'https://' . ltrim($value, '/');
        }

        $host = wp_parse_url($value, PHP_URL_HOST);
        if (!is_string($host) || $host === '') {
            return '';
        }

        $host = strtolower(trim($host));

        return strpos($host, 'www.') === 0 ? substr($host, 4) : $host;
    }

    protected static function isExternalDomain(string $domain): bool
    {
        $normalizedDomain = self::normalizeDomain($domain);
        if ($normalizedDomain === '') {
            return false;
        }

        $siteDomain = self::normalizeDomain((string) home_url('/'));
        if ($siteDomain === '') {
            return true;
        }

        return $normalizedDomain !== $siteDomain
            && substr($normalizedDomain, -strlen('.' . $siteDomain)) !== '.' . $siteDomain;
    }

    /**
     * @param array<int,string> $domains
     * @return array{0:string,1:int}
     */
    protected static function findTopDomain(array $domains): array
    {
        if ($domains === []) {
            return ['', 0];
        }

        $counts = array_count_values(array_filter($domains, 'is_string'));
        if ($counts === []) {
            return ['', 0];
        }

        arsort($counts);
        $topDomain = (string) array_key_first($counts);

        return [$topDomain, (int) ($counts[$topDomain] ?? 0)];
    }

    /**
     * @return array<int,string>
     */
    protected static function matchSpamKeywords(string $content): array
    {
        if ($content === '') {
            return [];
        }

        $matches = [];

        foreach (self::getSpamKeywordPatterns() as $label => $pattern) {
            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
                continue;
            }

            if (preg_match($pattern, $content)) {
                $matches[] = (string) $label;
            }
        }

        return $matches;
    }

    /**
     * @return array<int,string>
     */
    protected static function getSpamKeywordPatterns(): array
    {
        $patterns = [
            'casino' => '/\bcasino(?:s)?\b/iu',
            'betting' => '/\b(?:bet(?:s|ting)?|sportsbook|bookmaker(?:s)?)\b/iu',
            'gambling' => '/\b(?:gambl(?:e|ing)|wager(?:s|ing)?)\b/iu',
            'slots' => '/\b(?:slot(?:s)?|jackpot(?:s)?|roulette|blackjack|baccarat|poker|free\s*spin(?:s)?)\b/iu',
            'pharma' => '/\b(?:viagra|cialis|levitra|pharmacy|pill(?:s)?)\b/iu',
            'adult' => '/\b(?:adult|porn|xxx|escort(?:s)?)\b/iu',
            'loans' => '/\b(?:loan(?:s)?|payday\s*loan(?:s)?|cash\s*advance|debt\s*relief)\b/iu',
            'crypto' => '/\b(?:crypto|bitcoin|forex|trading\s*signal(?:s)?)\b/iu',
            'seo' => '/\b(?:seo(?:\s+services?)?|backlink(?:s)?|guest\s+post(?:ing)?|domain\s+authority)\b/iu',
        ];

        $filtered = apply_filters('vulntitan_comment_spam_keyword_patterns', $patterns);

        return is_array($filtered) ? $filtered : $patterns;
    }

    /**
     * @return array<int,string>
     */
    protected static function matchPromotionalPhrases(string $content): array
    {
        if ($content === '') {
            return [];
        }

        $matches = [];

        foreach (self::getPromotionalPhrasePatterns() as $label => $pattern) {
            if (!is_string($pattern) || @preg_match($pattern, '') === false) {
                continue;
            }

            if (preg_match($pattern, $content)) {
                $matches[] = (string) $label;
            }
        }

        return $matches;
    }

    /**
     * @return array<int|string,string>
     */
    protected static function getPromotionalPhrasePatterns(): array
    {
        $patterns = [
            'check_it_out' => '/\bcheck\s+(?:it|this|them)\s+out\b/iu',
            'go_to_spot' => '/\bgo-to\s+spot\b/iu',
            'quick_bets' => '/\bquick\s+bets?\b/iu',
            'best_odds' => '/\bbest\s+odds\b/iu',
            'join_now' => '/\bjoin\s+now\b/iu',
            'visit_site' => '/\bvisit\s+(?:my|our|this)\s+(?:site|website)\b/iu',
        ];

        $filtered = apply_filters('vulntitan_comment_promotional_phrase_patterns', $patterns);

        return is_array($filtered) ? $filtered : $patterns;
    }

    protected static function countWords(string $content): int
    {
        if ($content === '') {
            return 0;
        }

        preg_match_all('/[\p{L}\p{N}]+(?:[\'’-][\p{L}\p{N}]+)*/u', $content, $matches);

        return is_array($matches[0] ?? null) ? count($matches[0]) : 0;
    }

    protected static function measureLength(string $content): int
    {
        if ($content === '') {
            return 0;
        }

        return function_exists('mb_strlen')
            ? (int) mb_strlen($content, 'UTF-8')
            : strlen($content);
    }

    protected static function isAuthenticatedCommenter(array $commentData): bool
    {
        return !empty($commentData['user_id']) || !empty($commentData['user_ID']);
    }

    protected static function buildFormToken(int $postId): string
    {
        $timestamp = time();
        $payload = $timestamp . '|' . max(0, $postId);
        $signature = wp_hash($payload . '|' . self::TOKEN_FIELD);

        return $payload . '|' . $signature;
    }

    protected static function validateFormToken(string $rawToken, int $postId): array
    {
        $token = trim($rawToken);
        if ($token === '') {
            return [
                'present' => false,
                'valid' => false,
                'age' => 0,
            ];
        }

        $parts = explode('|', $token, 3);
        if (count($parts) !== 3) {
            return [
                'present' => true,
                'valid' => false,
                'age' => 0,
            ];
        }

        [$timestampRaw, $postIdRaw, $signature] = $parts;
        $timestamp = (int) $timestampRaw;
        $tokenPostId = (int) $postIdRaw;
        $expected = wp_hash($timestampRaw . '|' . $postIdRaw . '|' . self::TOKEN_FIELD);
        $now = time();
        $age = max(0, $now - $timestamp);

        if ($timestamp <= 0 || $tokenPostId < 0 || !hash_equals($expected, $signature)) {
            return [
                'present' => true,
                'valid' => false,
                'age' => 0,
            ];
        }

        if ($postId > 0 && $tokenPostId > 0 && $postId !== $tokenPostId) {
            return [
                'present' => true,
                'valid' => false,
                'age' => 0,
            ];
        }

        if ($timestamp > ($now + self::MAX_TOKEN_FUTURE_SKEW) || $age > self::getTokenMaxAge()) {
            return [
                'present' => true,
                'valid' => false,
                'age' => $age,
            ];
        }

        return [
            'present' => true,
            'valid' => true,
            'age' => $age,
        ];
    }

    protected static function getTokenMaxAge(): int
    {
        $maxAge = (int) apply_filters('vulntitan_comment_token_max_age', self::MAX_TOKEN_AGE);

        return max(self::MIN_TOKEN_AGE, $maxAge);
    }

    protected static function readRequestField(string $field): string
    {
        if (array_key_exists($field, self::$requestFieldOverrides)) {
            return trim((string) self::$requestFieldOverrides[$field]);
        }

        if (!isset($_REQUEST[$field])) {
            return '';
        }

        $value = wp_unslash($_REQUEST[$field]);
        if (!is_scalar($value) && $value !== null) {
            return '';
        }

        return trim((string) $value);
    }

    protected static function extractRestRequestFields($request): array
    {
        if (!is_object($request) || !method_exists($request, 'get_param')) {
            return [];
        }

        $fields = [];
        foreach ([self::TOKEN_FIELD, self::HONEYPOT_FIELD] as $field) {
            $value = $request->get_param($field);
            if (is_scalar($value) || $value === null) {
                $fields[$field] = trim((string) $value);
            }
        }

        return $fields;
    }
}
