<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Replacements;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). No caching: these tables are the source
// of truth for click/redirect data and must read-through. "meta_key"/"meta_value" here
// refer to our own prli_link_metas table, not wp_postmeta.
use PrettyLinks\Pro\Options\Store as OptionsStore;
use WP_Post;

/**
 * Builds and maintains the `prli_post_keywords` index — a per-post record
 * of which keywords appear in that post's content.
 *
 * Indexed on save_post (via onSavePost hook) and lazily on first render
 * for posts that have never been saved while v4 was active. The index
 * keeps the hot-path filter cheap: at render time only keywords already
 * known to match are fetched rather than re-scanning the full keyword set.
 *
 * clearAll() only clears the index tables; CacheInvalidator owns flushing
 * the rendered-HTML caches.
 */
class KeywordIndexer
{
    private const INDEXED_META_KEY = KeywordRepository::INDEXED_META_KEY;

    /**
     * The keyword repository.
     *
     * @var KeywordRepository
     */
    private KeywordRepository $keywords;

    /**
     * The Pro options store.
     *
     * @var OptionsStore
     */
    private OptionsStore $options;

    /**
     * The append-only keyword-add log.
     *
     * @var KeywordAddLog
     */
    private KeywordAddLog $addLog;

    /**
     * Memoized site-wide filter state — option reads are stable for the
     * lifetime of a request, so both indexing paths share the same
     * resolved values instead of re-fetching per iteration.
     *
     * @var boolean|null
     */
    private $forceCaseSensitive = null;

    /**
     * Memoized site-wide keyword ignore list (lowercased entries).
     *
     * @var string[]|null
     */
    private $ignoreList = null;

    /**
     * Constructor.
     *
     * @param KeywordRepository $keywords The keyword repository.
     * @param OptionsStore      $options  The Pro options store.
     * @param KeywordAddLog     $addLog   The append-only keyword-add log.
     */
    public function __construct(
        KeywordRepository $keywords,
        OptionsStore $options,
        KeywordAddLog $addLog
    ) {
        $this->keywords = $keywords;
        $this->options  = $options;
        $this->addLog   = $addLog;
    }

    /**
     * Site-wide keyword filters. Mirrors KeywordEngine::applySiteFilters
     * so the index and the replacer share the same effective ruleset —
     * otherwise a post can be marked as matching a keyword the replacer
     * would never actually replace (e.g. site-wide case-sensitive is on
     * but the per-keyword flag is off, or the keyword is on the
     * site-wide ignore list).
     *
     * @return array{0: bool, 1: string[]}
     */
    private function siteFilters(): array
    {
        if ($this->forceCaseSensitive === null) {
            $this->forceCaseSensitive = (bool) $this->options->getPro('keyword_case_sensitive', false);
        }
        if ($this->ignoreList === null) {
            $this->ignoreList = [];
            $raw              = (string) $this->options->getPro('keyword_ignore', '');
            if ($raw !== '') {
                foreach (preg_split('/[\r\n,]+/', $raw) ?: [] as $entry) {
                    $entry = trim((string) $entry);
                    if ($entry !== '') {
                        $this->ignoreList[] = mb_strtolower($entry);
                    }
                }
            }
        }
        return [(bool) $this->forceCaseSensitive, $this->ignoreList];
    }

    /**
     * Index a post when it is saved.
     *
     * @param integer|string|null $postId The id of the saved post.
     * @param WP_Post|null        $post   The saved post object, if available.
     *
     * @return void
     */
    public function onSavePost($postId, $post = null): void
    {
        $postId = (int) $postId;
        if ($postId <= 0) {
            return;
        }

        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }
        if (!($post instanceof WP_Post)) {
            $post = get_post($postId);
        }
        if (!($post instanceof WP_Post)) {
            return;
        }
        if ($post->post_status === 'trash' || $post->post_status === 'auto-draft') {
            return;
        }
        // Skip post types that can't render keyword replacements anyway.
        // Saves the regex scan and avoids filling prli_post_keywords with
        // rows that will never be read.
        if (!$this->postTypeEligible((string) $post->post_type)) {
            return;
        }

        $this->indexPost($postId);
    }

    /**
     * Whether the given post type is eligible for keyword replacement.
     *
     * @param string $postType The post type slug to check.
     *
     * @return boolean
     */
    private function postTypeEligible(string $postType): bool
    {
        $cpts = $this->options->getPro('keyword_replacement_cpts', []);
        return is_array($cpts) && in_array($postType, $cpts, true);
    }

    /**
     * Scan all keywords against the post content and write matches to
     * `prli_post_keywords`. Stamps `_plp_post_keywords_updated_at` postmeta
     * so KeywordEngine can skip re-indexing posts that genuinely have no
     * matching keywords (zero rows ≠ unindexed).
     *
     * @param integer $postId The id of the post to index.
     *
     * @return void
     */
    public function indexPost(int $postId): void
    {
        $this->keywords->clearPostKeywords($postId);

        $post    = get_post($postId);
        $content = ($post instanceof WP_Post) ? (string) $post->post_content : '';

        if ($content !== '') {
            [$forceCase, $ignore] = $this->siteFilters();
            $matchIds             = [];
            foreach ($this->keywords->all() as $kw) {
                $text = (string) $kw['keyword'];
                if ($ignore && in_array(mb_strtolower($text), $ignore, true)) {
                    continue;
                }
                if ($this->matches($content, $text, $forceCase)) {
                    $matchIds[] = (int) $kw['id'];
                }
            }
            if ($matchIds) {
                $this->keywords->linkPostKeywordsMany($postId, $matchIds);
            }
        }

        update_post_meta($postId, self::INDEXED_META_KEY, current_time('mysql', true));
    }

    /**
     * Wipe the entire post-keyword index and all indexed-at stamps.
     * Called when keywords are added/edited/deleted so every post
     * re-indexes lazily on its next view.
     */
    public function clearAll(): void
    {
        global $wpdb;
        $this->keywords->clearAllPostKeywords();
        $wpdb->delete($wpdb->postmeta, ['meta_key' => self::INDEXED_META_KEY]);
        // Direct $wpdb->delete bypasses WP's meta cache. If a post's meta was
        // loaded earlier in the request, get_post_meta() would keep returning
        // the stale "indexed-at" stamp and KeywordEngine would skip re-indexing
        // that post until the next request.
        wp_cache_flush_group('post_meta');
    }

    /**
     * Incremental catch-up: replay add-log entries newer than the post's
     * stamp against its content, inserting any new matches. No-op when
     * the post is already current.
     *
     * Falls through to a full re-scan when there's no stamp (first view
     * of an unindexed post) or when too many entries have accumulated
     * since the last sync — at some point the delta is slower to replay
     * than it would be to just rescan from scratch.
     *
     * @param integer $postId The id of the post to catch up.
     *
     * @return void
     */
    public function catchUpPost(int $postId): void
    {
        if ($postId <= 0) {
            return;
        }
        $stamp = $this->keywords->syncedStamp($postId);
        if ($stamp === '') {
            $this->indexPost($postId);
            return;
        }
        if ($this->addLog->countSince($stamp) > KeywordAddLog::REPLAY_THRESHOLD) {
            $this->indexPost($postId);
            return;
        }
        $entries = $this->addLog->since($stamp);
        if (!$entries) {
            return;
        }

        $post    = get_post($postId);
        $content = ($post instanceof WP_Post) ? (string) $post->post_content : '';
        if ($content !== '') {
            [$forceCase, $ignore] = $this->siteFilters();
            $matchIds             = [];
            foreach ($entries as $entry) {
                $text = (string) $entry['keyword_text'];
                if ($ignore && in_array(mb_strtolower($text), $ignore, true)) {
                    continue;
                }
                if ($this->matches($content, $text, $forceCase)) {
                    $matchIds[] = $entry['keyword_id'];
                }
            }
            if ($matchIds) {
                $this->keywords->linkPostKeywordsMany($postId, $matchIds);
            }
        }

        update_post_meta($postId, self::INDEXED_META_KEY, current_time('mysql', true));
    }

    /**
     * Whether the keyword appears in the content as a whole word.
     *
     * @param string  $content       The content to search.
     * @param string  $keyword       The keyword to look for.
     * @param boolean $caseSensitive Whether matching is case-sensitive.
     *
     * @return boolean
     */
    private function matches(string $content, string $keyword, bool $caseSensitive): bool
    {
        if ($keyword === '') {
            return false;
        }
        $flags = 'u';
        if (!$caseSensitive) {
            $flags .= 'i';
        }
        $pattern = '/\b' . preg_quote($keyword, '/') . '\b/' . $flags;
        return (bool) preg_match($pattern, $content);
    }
}
