<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Replacements;

use PrettyLinks\Pro\Options\Store as OptionsStore;
use PrettyLinks\Pro\PostOptions\PostOptions;

/**
 * Keyword replacement filter.
 *
 * Hooks `the_content`, and (when scoped by `replace_keywords_in_*` values)
 * `the_content_feed`, `comment_text`, `comment_text_rss`. Also wires into
 * BBPress and WooCommerce content. Pro Bootstrap owns the registration.
 *
 * The `prli_post_keywords` index keeps the hot-path cheap — only keywords
 * already known to match a post are fetched at render time. Unindexed posts
 * are indexed lazily on first view.
 */
class KeywordEngine
{
    /**
     * The keyword repository.
     *
     * @var KeywordRepository
     */
    private KeywordRepository $keywords;

    /**
     * The keyword index manager.
     *
     * @var KeywordIndexer
     */
    private KeywordIndexer $indexer;

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

    /**
     * The global disclosure injector, if available.
     *
     * @var Disclosures|null
     */
    private ?Disclosures $disclosures;

    /**
     * Memoized site-wide case-sensitivity toggle.
     *
     * @var boolean|null
     */
    private ?bool $cachedForceCaseSensitive = null;

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

    /**
     * Per-post, per-request memoization for `the_content`. Third-party
     * plugins/themes sometimes call `apply_filters('the_content', …)`
     * multiple times per page load (e.g. SEO previews, share-bar
     * plugins). Re-running the keyword engine each time would duplicate
     * wrapping and waste CPU. v3 cached on `post->ID + strlen(content)`;
     * we do the same.
     *
     * @var array<int, array{len: int, out: string}>
     */
    private array $renderCache = [];

    /**
     * Constructor.
     *
     * @param KeywordRepository $keywords    The keyword repository.
     * @param KeywordIndexer    $indexer     The keyword index manager.
     * @param OptionsStore      $options     The Pro options store.
     * @param Disclosures|null  $disclosures The optional disclosure injector.
     */
    public function __construct(
        KeywordRepository $keywords,
        KeywordIndexer $indexer,
        OptionsStore $options,
        ?Disclosures $disclosures = null
    ) {
        $this->keywords    = $keywords;
        $this->indexer     = $indexer;
        $this->options     = $options;
        $this->disclosures = $disclosures;
    }

    /**
     * The `the_content` keyword replacement filter.
     *
     * @param string $content The post content to filter.
     *
     * @return string
     */
    public function filter(string $content): string
    {
        // Skip automatic excerpt generation. WordPress builds those by running
        // `the_content` over the raw post body inside `wp_trim_excerpt()` and
        // then stripping tags: our anchors are discarded, but a configured
        // disclosure suffix is plain text and would survive into the excerpt.
        // `get_the_excerpt` is still on `$wp_current_filter` while that inner
        // `the_content` pass runs, so the stack answers this for us — nested
        // excerpt renders included, and every entry point covered rather than
        // only the hooks we remembered to bind.
        if ($content === '' || doing_filter('get_the_excerpt') || !$this->enabled()) {
            return $content;
        }

        $postId = (int) get_the_ID();
        $len    = strlen($content);

        // V3 parity: per-post static cache keyed on $content length so
        // repeated the_content invocations within a request don't redo
        // the work (or double-wrap on the second pass).
        if (
            $postId > 0 && isset($this->renderCache[$postId])
            && $this->renderCache[$postId]['len'] === $len
        ) {
            return $this->renderCache[$postId]['out'];
        }

        $out = $this->computeFilter($content, $postId);

        if ($postId > 0) {
            $this->renderCache[$postId] = [
                'len' => $len,
                'out' => $out,
            ];
        }
        return $out;
    }

    /**
     * The filter logic minus the cache lookup/write. Split out so every
     * early-return path can funnel through the cache write in filter() —
     * v3's behavior.
     *
     * @param string  $content The content to filter.
     * @param integer $postId  The current post id (0 when none).
     *
     * @return string
     */
    private function computeFilter(string $content, int $postId): string
    {
        // V3 parity: bail on password-protected posts whose cookie is
        // missing. We'd otherwise cache linked markup behind the
        // password form.
        if ($postId > 0) {
            $post = get_post($postId);
            if ($post && post_password_required($post)) {
                return $content;
            }
        }

        $normalized = $this->preNormalize($content);
        $replOpt    = $this->resolveReplacerOptions();

        if ($postId <= 0) {
            $replaced = (new KeywordReplacer())->replace(
                $normalized,
                $this->applySiteFilters($this->keywords->all()),
                $replOpt
            );
            return $this->maybeAppendDisclosure($normalized, $replaced);
        }

        if (!$this->postTypeAllowed($postId) || $this->postDisabled($postId)) {
            return $content;
        }

        // Incremental catch-up: applies any keyword adds that landed after
        // this post was last synced, then loads the (now current) match set.
        // Handles first-time indexing internally when no stamp exists.
        //
        // We hand it the content we were given rather than letting it re-read
        // post_content. At priority 999 that is the fully rendered output — blocks,
        // shortcodes and page-builder layouts have all expanded — so keywords
        // that only exist after rendering get indexed and linked. Scanning the
        // stored text missed them entirely, and re-saving never helped because
        // the word genuinely isn't in the stored text.
        //
        // Excerpt passes never reach here: filter() returns early while
        // `get_the_excerpt` is on the filter stack. That matters for indexing
        // as much as for output — `wp_trim_excerpt()` applies `the_content` to
        // block-stripped raw text, so indexing from it would record the wrong
        // match set and stamp the post as current, stopping the real render
        // from ever scanning the rendered output.
        //
        // $normalized, not $content: the replacer matches against the
        // normalized text (entities reverted, optional &nbsp; stripping), so
        // the indexer has to see the same string or it records a narrower
        // match set than the replacer would actually use. A keyword holding an
        // apostrophe or ampersand appears as `&#8217;` / `&amp;` in rendered
        // output, so scanning un-normalized text never matches it and the word
        // never gets linked. Same parity rule as siteFilters().
        $this->indexer->catchUpPost($postId, $normalized);
        $keywords = $this->applySiteFilters($this->keywords->forPost($postId));

        // V3 parity: allow third-party code to modify the keyword→link map before replacement.
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy v3 Pretty Links Pro public filter; preserved for back-compat.
        $keywords = (array) apply_filters('plp_get_post_keywords_lookup', $keywords, $post);

        if (!$keywords) {
            return $content;
        }

        $replaced = (new KeywordReplacer())->replace($normalized, $keywords, $replOpt);
        return $this->maybeAppendDisclosure($normalized, $replaced);
    }

    /**
     * V3 parity workaround: some plugins/themes sprinkle `&nbsp;` into
     * content, breaking `\b`-anchored keyword matches. v3 gated the
     * stripping behind `plp_keywords_replace_nbsp`; keep the same filter
     * name so sites that relied on it keep working. v3 also reverted
     * `&#8217;` (smart apostrophe) and `&amp;` unconditionally so
     * keywords containing `'` or `&` match after WP has run its texturize
     * / kses pass.
     *
     * @param string $content The content to normalize before matching.
     *
     * @return string
     */
    private function preNormalize(string $content): string
    {
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy v3 Pretty Links Pro public filter; preserved for back-compat.
        if (apply_filters('plp_keywords_replace_nbsp', false)) {
            $content = str_replace('&nbsp;', ' ', $content);
        }
        return str_replace(['&#8217;', '&amp;'], ["'", '&'], $content);
    }

    /**
     * Comment variant. Gated on `replace_keywords_in_comments` being
     * `'keywords'` or `'both'`. Honors the `keyword_replacement_is_on`
     * master; no post-level disable (comments are not posts).
     *
     * @param string $content The comment content to filter.
     *
     * @return string
     */
    public function filterComment(string $content): string
    {
        if (!self::scopeAllowsKeywords($this->options->getPro('replace_keywords_in_comments', 'none'))) {
            return $content;
        }
        return $this->runUnscoped($content);
    }

    /**
     * RSS comment variant. v3 registers this only when BOTH feeds and
     * comments scopes are non-none — match that.
     *
     * @param string $content The RSS comment content to filter.
     *
     * @return string
     */
    public function filterCommentRss(string $content): string
    {
        $comments = $this->options->getPro('replace_keywords_in_comments', 'none');
        $feeds    = $this->options->getPro('replace_keywords_in_feeds', 'none');
        if ($comments === 'none' || $feeds === 'none') {
            return $content;
        }
        if (!self::scopeAllowsKeywords($comments)) {
            return $content;
        }
        return $this->runUnscoped($content);
    }

    /**
     * Feed `the_content_feed` variant. Gated on `replace_keywords_in_feeds`
     * being `'keywords'` or `'both'`.
     *
     * @param string $content The feed content to filter.
     *
     * @return string
     */
    public function filterFeed(string $content): string
    {
        if (!self::scopeAllowsKeywords($this->options->getPro('replace_keywords_in_feeds', 'none'))) {
            return $content;
        }
        if ($content === '' || doing_filter('get_the_excerpt') || !$this->enabled()) {
            return $content;
        }
        $postId   = (int) get_the_ID();
        $replOpt  = $this->resolveReplacerOptions();
        $keywords = $postId > 0
            ? $this->keywords->forPost($postId)
            : $this->keywords->all();
        return (new KeywordReplacer())->replace(
            $this->preNormalize($content),
            $this->applySiteFilters($keywords),
            $replOpt
        );
    }

    /**
     * BBPress replies + WooCommerce short descriptions. Runs only when the
     * current post type is in `keyword_replacement_cpts` (v3 shape).
     *
     * @param string $content The BBPress/WooCommerce content to filter.
     *
     * @return string
     */
    public function filterBbpress(string $content): string
    {
        if ($content === '' || doing_filter('get_the_excerpt') || !$this->enabled()) {
            return $content;
        }
        $post = get_post();
        if (!$post || !$this->cptAllowed((string) $post->post_type)) {
            return $content;
        }
        return (new KeywordReplacer())->replace(
            $this->preNormalize($content),
            $this->applySiteFilters($this->keywords->all()),
            $this->resolveReplacerOptions()
        );
    }

    /**
     * Run keyword replacement on content with no post-level scoping.
     *
     * @param string $content The content to filter.
     *
     * @return string
     */
    private function runUnscoped(string $content): string
    {
        if ($content === '' || doing_filter('get_the_excerpt') || !$this->enabled()) {
            return $content;
        }
        return (new KeywordReplacer())->replace(
            $this->preNormalize($content),
            $this->applySiteFilters($this->keywords->all()),
            $this->resolveReplacerOptions()
        );
    }

    /**
     * Append the disclosure block when a replacement actually occurred.
     *
     * @param string $original The pre-replacement content.
     * @param string $replaced The post-replacement content.
     *
     * @return string
     */
    private function maybeAppendDisclosure(string $original, string $replaced): string
    {
        if ($this->disclosures === null || $replaced === $original) {
            return $replaced;
        }
        return $this->disclosures->inject($replaced);
    }

    /**
     * Apply site-wide pro options to the keyword set:
     *   - `keyword_case_sensitive`: single global toggle that drives
     *     every keyword's match mode. v4 dropped v3's per-keyword
     *     column in favor of this site-wide option.
     *   - `keyword_ignore`: newline-/comma-separated text to skip.
     *
     * Both option reads and the ignore-list parse are memoized on the
     * instance — options are stable for the lifetime of a request.
     *
     * @param array<int, array<string, mixed>> $keywords The keyword set to filter.
     *
     * @return array<int, array<string, mixed>>
     */
    private function applySiteFilters(array $keywords): array
    {
        if (!$keywords) {
            return $keywords;
        }

        if ($this->cachedForceCaseSensitive === null) {
            $this->cachedForceCaseSensitive = (bool) $this->options->getPro('keyword_case_sensitive', false);
        }
        if ($this->cachedIgnoreList === null) {
            $this->cachedIgnoreList = [];
            $raw                    = (string) $this->options->getPro('keyword_ignore', '');
            if ($raw !== '') {
                foreach (preg_split('/[\r\n,]+/', $raw) ?: [] as $entry) {
                    $entry = trim((string) $entry);
                    if ($entry !== '') {
                        $this->cachedIgnoreList[] = mb_strtolower($entry);
                    }
                }
            }
        }

        $forceCaseSensitive = $this->cachedForceCaseSensitive;
        $ignore             = $this->cachedIgnoreList;

        $out = [];
        foreach ($keywords as $kw) {
            if ($ignore && in_array(mb_strtolower((string) ($kw['keyword'] ?? '')), $ignore, true)) {
                continue;
            }
            // Case-sensitivity is site-wide in 4.0. KeywordReplacer reads
            // this key off each entry — set it here from the global toggle.
            $kw['case_sensitive'] = $forceCaseSensitive ? 1 : 0;
            $out[]                = $kw;
        }
        return $out;
    }

    /**
     * Shape the options-blob data into the compact array KeywordReplacer
     * expects. Evaluated once per filter() call.
     *
     * @return array<string, mixed>
     */
    private function resolveReplacerOptions(): array
    {
        $out = [];

        if ((bool) $this->options->getPro('set_keyword_thresholds', false)) {
            // Three independent caps (v3 had two; total is a v4 addition).
            // keywords_per_page            — unique keywords per post.
            // keyword_links_per_page       — per-keyword cap (v3 semantics).
            // keyword_total_links_per_page — global ceiling across all
            // keywords on the post.
            $out['maxKeywords']   = max(0, (int) $this->options->getPro('keywords_per_page', 0));
            $out['maxPerKeyword'] = max(0, (int) $this->options->getPro('keyword_links_per_page', 0));
            $out['maxTotal']      = max(0, (int) $this->options->getPro('keyword_total_links_per_page', 0));
        }

        if ((bool) $this->options->getPro('keyword_links_nofollow', false)) {
            $out['forceNofollow'] = true;
        }
        if ((bool) $this->options->getPro('keyword_links_sponsored', false)) {
            $out['forceSponsored'] = true;
        }
        if ((bool) $this->options->getPro('keyword_links_open_new_window', false)) {
            $out['forceNewWindow'] = true;
        }

        // V3 parity: disclosure text is only rendered when the
        // `enable_keyword_link_disclosures` toggle is on.
        if ((bool) $this->options->getPro('enable_keyword_link_disclosures', false)) {
            $disclosure = trim((string) $this->options->getPro('keyword_link_disclosure', ''));
            if ($disclosure !== '') {
                $out['disclosure'] = $disclosure;
            }
        }

        return $out;
    }

    /**
     * Whether keyword replacement is enabled site-wide.
     *
     * @return boolean
     */
    private function enabled(): bool
    {
        return (bool) $this->options->getPro('keyword_replacement_is_on', false);
    }

    /**
     * V3's 4-state scope selector: `'none' | 'keywords' | 'urls' | 'both'`.
     * Keyword replacement runs only on `'keywords'` and `'both'`.
     *
     * @param mixed $value The stored scope option value.
     *
     * @return boolean
     */
    public static function scopeAllowsKeywords($value): bool
    {
        return $value === 'keywords' || $value === 'both';
    }

    /**
     * Whether the post's type is in the keyword replacement allow-list.
     *
     * @param integer $postId The post id to check.
     *
     * @return boolean
     */
    private function postTypeAllowed(int $postId): bool
    {
        $post = get_post($postId);
        if (!$post) {
            return false;
        }
        return $this->cptAllowed((string) $post->post_type);
    }

    /**
     * `keyword_replacement_cpts` is the v3-shape post-type allow-list.
     * Empty array = no replacement anywhere (matches v3's `in_array`
     * semantics when the option is unset).
     *
     * @param string $postType The post type slug to check.
     *
     * @return boolean
     */
    private function cptAllowed(string $postType): bool
    {
        $cpts = $this->options->getPro('keyword_replacement_cpts', []);
        if (!is_array($cpts)) {
            return false;
        }
        return in_array($postType, $cpts, true);
    }

    /**
     * Whether keyword replacement is disabled at the post level.
     *
     * @param integer $postId The post id to check.
     *
     * @return boolean
     */
    private function postDisabled(int $postId): bool
    {
        return PostOptions::get($postId)['disable_keyword_replacements'];
    }
}
