<?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;
        }

        // Drop the index instead of rebuilding it here. Indexing needs the
        // rendered content, which only exists during `the_content` — scanning
        // the raw text on save would stamp the post as current and stop the
        // render-time pass from ever looking at what visitors actually see.
        $this->invalidatePost($postId);
    }

    /**
     * Forget everything indexed for a post so the next render rebuilds it.
     *
     * @param integer $postId The post to invalidate.
     *
     * @return void
     */
    public function invalidatePost(int $postId): void
    {
        if ($postId <= 0) {
            return;
        }
        $this->keywords->clearPostKeywords($postId);
        delete_post_meta($postId, self::INDEXED_META_KEY);
    }

    /**
     * Stored post_content, for callers with no rendered content to offer.
     *
     * @param integer $postId The post to read.
     *
     * @return string
     */
    private function rawContent(int $postId): string
    {
        $post = get_post($postId);

        return ($post instanceof WP_Post) ? (string) $post->post_content : '';
    }

    /**
     * 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).
     *
     * Pass `$rendered` — the content as `the_content` produced it — whenever
     * the caller has it. Stored `post_content` is only a template: a keyword
     * that appears via a shortcode, block, or page-builder layout is not in
     * it, so scanning the raw text indexes nothing for that keyword and the
     * word never gets linked even though visitors can see it.
     *
     * The raw-text fallback is for direct callers (tools, tests) only —
     * `onSavePost()` no longer rebuilds here, it just invalidates.
     *
     * @param integer     $postId   The id of the post to index.
     * @param string|null $rendered Rendered content to scan, or null for raw.
     *
     * @return void
     */
    public function indexPost(int $postId, ?string $rendered = null): void
    {
        $this->keywords->clearPostKeywords($postId);

        $content = $rendered ?? $this->rawContent($postId);

        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 keywords added since 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 keywords have accumulated
     * since the last sync — at some point the delta is slower to replay
     * than it would be to just rescan from scratch.
     *
     * Pass `$rendered` whenever the caller has the output of `the_content`;
     * see indexPost() for why the stored text is not good enough.
     *
     * @param integer     $postId   The id of the post to catch up.
     * @param string|null $rendered Rendered content to scan, or null for raw.
     *
     * @return void
     */
    public function catchUpPost(int $postId, ?string $rendered = null): void
    {
        if ($postId <= 0) {
            return;
        }
        $stamp = $this->keywords->syncedStamp($postId);
        if ($stamp === '') {
            $this->indexPost($postId, $rendered);
            return;
        }

        // Stamp the post with the time we started reading, not the time we
        // finished. Both sources are queried with `> stamp`, so a keyword
        // created while this scan was running would otherwise end up older
        // than the stamp we write and never be picked up again — the same
        // permanent staleness this method exists to prevent.
        $startedAt = current_time('mysql', true);

        // Cheap guards first — the common case (nothing pending) settles on
        // two counts and loads no rows. max() rather than a sum because the
        // two sources overlap for any normally-logged add; it only needs to
        // be good enough to choose between replay and a full re-scan.
        $pending = max(
            $this->addLog->countSince($stamp),
            $this->keywords->countCreatedSince($stamp)
        );
        if ($pending === 0) {
            return;
        }
        if ($pending > KeywordAddLog::REPLAY_THRESHOLD) {
            $this->indexPost($postId, $rendered);
            return;
        }

        // Can still come back empty: a counted log entry whose keyword has
        // since been deleted is dropped by the join in since().
        $entries = $this->pendingSince($stamp);
        if (!$entries) {
            return;
        }

        $content = $rendered ?? $this->rawContent($postId);
        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, $startedAt);
    }

    /**
     * Keywords a post synced at $stamp still needs to be scanned for.
     *
     * Two sources, because neither is sufficient alone. The add-log is the
     * only one that knows about text edits — those don't move a keyword's
     * `created_at`. The keywords table is the only one that can't miss an
     * add, so it backstops log entries that were never written.
     *
     * Keyed by keyword id to dedupe the overlap, with the live row merged
     * last so it wins: `prli_keywords.text` is the keyword's current text,
     * whereas a log entry is a snapshot of what it was when written. They
     * agree for any edit routed through the invalidator; where they don't,
     * replaying the logged text would scan for a word that no longer exists.
     * Log-only ids (edited, but created before the stamp) keep their logged
     * text — it's the only text available, and it's current by construction
     * for edits that went through the log.
     *
     * @param string $stamp The post's sync stamp.
     *
     * @return array<int, array{keyword_id:int, keyword_text:string}>
     */
    private function pendingSince(string $stamp): array
    {
        $entries = [];
        foreach ($this->addLog->since($stamp) as $entry) {
            $entries[$entry['keyword_id']] = $entry;
        }
        foreach ($this->keywords->createdSince($stamp) as $entry) {
            $entries[$entry['keyword_id']] = $entry;
        }
        return array_values($entries);
    }

    /**
     * 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);
    }
}
