<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Replacements;

/**
 * Pure keyword replacement. Stateless — given content + keyword list +
 * options, returns replaced HTML. No database access.
 *
 * Skip-region protection (matches v3's four-layer onion):
 *   1. Shortcodes `[…]`
 *   2. Full-pair dangerous tags `<a|h1-6|pre|script|iframe|code|applet|
 *      audio|canvas|button|textarea …>…</tag>`
 *   3. Self-closing / void tags `<img …/>`
 *   4. Any remaining tag markup `<…>` — protects attribute values in
 *      unclosed or inline tags (title, alt, data-*, etc.)
 * Content outside those masks is the only text the keyword regex sees.
 *
 * Other invariants:
 *   - Word-boundary match (`\b`) with configured case-sensitivity.
 *   - UTF-8 (`u`) always — safe for ASCII, required for multibyte.
 *   - Per-keyword `replace_limit` (0 = unlimited) — overrides
 *     `maxPerKeyword` for the specific keyword when set.
 *   - Per-post thresholds via $options:
 *       maxKeywords    — cap on unique keyword phrases linked (v3:
 *                        `keywords_per_page`).
 *       maxPerKeyword  — default cap on link count for each keyword
 *                        (v3: `keyword_links_per_page` — per-keyword,
 *                        not global).
 *       maxTotal       — absolute ceiling on total anchors emitted on
 *                        the post across all keywords (v4 addition;
 *                        0 = unlimited, which is the v3 behavior).
 *     Whichever cap fires first stops further linking.
 *   - Site-wide forced flags via $options on top of per-link flags.
 *   - Single `preg_replace_callback` per keyword over masked text —
 *     strictly faster than v3's sequential `preg_replace`-with-count loop.
 *   - Disclosure suffix appended after each emitted anchor when configured.
 *   - Emits `<a href="…" title="…"
 *     class="pretty-link pretty-link-keyword prli-keyword"
 *     data-prli-link-id="…" …>match</a>`. v3 class kept as a drop-in
 *     so themes/CSS targeting `.pretty-link-keyword` still work; the
 *     leading `pretty-link` marker matches every other Pretty Links-
 *     inserted anchor (block, shortcodes, TinyMCE, URL replacement).
 */
class KeywordReplacer
{
    /**
     * Compiled regex patterns keyed by keyword and case-sensitivity.
     *
     * @var array<string, string>
     */
    private static array $patternCache = [];

    /**
     * Wrap keyword matches in the given content with pretty-link anchors.
     *
     * @param string                           $content  The content to scan and replace within.
     * @param array<int, array<string, mixed>> $keywords The keyword rows to match.
     * @param array<string, mixed>             $options  The replacement limit and force options
     *                                                   (maxKeywords, maxPerKeyword, maxTotal,
     *                                                   forceNofollow, forceSponsored,
     *                                                   forceNewWindow, disclosure).
     *
     * @return string
     */
    public function replace(string $content, array $keywords, array $options = []): string
    {
        if ($content === '' || !$keywords) {
            return $content;
        }

        $maxKeywords   = (int) ($options['maxKeywords'] ?? 0);
        $maxPerKeyword = (int) ($options['maxPerKeyword'] ?? 0);
        $maxTotal      = (int) ($options['maxTotal'] ?? 0);

        $masked = self::maskSkipRegions($content);
        $text   = $masked['text'];
        $tokens = $masked['tokens'];

        $keywordsUsed = 0;
        $totalLinks   = 0;

        // Deferred emission buffer. Emitted anchors contain strings like
        // `class="pretty-link-keyword"` which would otherwise get re-
        // matched by a later keyword (e.g. the literal keyword `"keyword"`
        // matching inside the class attribute of an already-wrapped link).
        // v3 dodged this by replacing with a placeholder during matching
        // and substituting the HTML in a final pass — same approach here.
        $links   = [];
        $linkIdx = 0;

        foreach ($keywords as $kw) {
            if ($maxKeywords > 0 && $keywordsUsed >= $maxKeywords) {
                break;
            }
            // Total link budget exhausted — no further keyword can produce
            // a link, so skip the regex work entirely.
            if ($maxTotal > 0 && $totalLinks >= $maxTotal) {
                break;
            }

            $keyword = (string) ($kw['keyword'] ?? '');
            if ($keyword === '') {
                continue;
            }
            $prettyUrl = self::prettyUrl((string) ($kw['slug'] ?? ''));
            if ($prettyUrl === '') {
                continue;
            }

            // Per-keyword cap. The keyword's own `replace_limit` overrides
            // the global `maxPerKeyword` default when set (>0); otherwise
            // the global default applies. Either being 0 means unlimited
            // for that source. -1 sentinel inside the loop means "no cap".
            $perKeywordLimit = (int) ($kw['replace_limit'] ?? 0);
            if ($perKeywordLimit <= 0 && $maxPerKeyword > 0) {
                $perKeywordLimit = $maxPerKeyword;
            }
            $pattern = self::pattern($keyword, !empty($kw['case_sensitive']));

            $remaining   = $perKeywordLimit > 0 ? $perKeywordLimit : -1;
            $usedThisKey = 0;

            $text = preg_replace_callback(
                $pattern,
                static function (array $m) use (
                    $kw,
                    $prettyUrl,
                    $options,
                    &$remaining,
                    &$totalLinks,
                    &$usedThisKey,
                    &$links,
                    &$linkIdx,
                    $maxTotal
                ): string {
                    if ($maxTotal > 0 && $totalLinks >= $maxTotal) {
                        return $m[0];
                    }
                    if ($remaining === 0) {
                        return $m[0];
                    }
                    if ($remaining > 0) {
                        $remaining--;
                    }
                    $totalLinks++;
                    $usedThisKey++;
                    $links[$linkIdx] = self::anchor($m[0], $prettyUrl, $kw, $options);
                    $token           = "\x01PRLI_LINK_{$linkIdx}\x02";
                    $linkIdx++;
                    return $token;
                },
                $text
            );

            if (!is_string($text)) {
                // Preg_replace_callback returned null (regex error) — drop
                // replacements from this keyword and continue.
                $text = $masked['text'];
                continue;
            }

            if ($usedThisKey > 0) {
                ++$keywordsUsed;
            }
        }

        // Materialize link placeholders first — their replacement text
        // may contain characters (word chars in class names etc.) that
        // must not be seen by keyword regex, but at this point no more
        // keyword regex runs. Then restore mask tokens.
        if ($links) {
            $text = (string) preg_replace_callback(
                '/\x01PRLI_LINK_(\d+)\x02/',
                static fn(array $m): string => $links[(int) $m[1]] ?? $m[0],
                $text
            );
        }

        return self::restoreSkipRegions($text, $tokens);
    }

    /**
     * Build the replacement anchor HTML for a single keyword match.
     *
     * @param string               $matched   The matched keyword text to wrap.
     * @param string               $prettyUrl The resolved pretty-link URL.
     * @param array<string, mixed> $kw        The keyword row being applied.
     * @param array<string, mixed> $options   The replacement force options.
     *
     * @return string
     */
    private static function anchor(string $matched, string $prettyUrl, array $kw, array $options): string
    {
        $linkId = (int) ($kw['link_id'] ?? 0);

        $nofollow  = !empty($kw['nofollow']) || !empty($options['forceNofollow']);
        $sponsored = !empty($kw['sponsored']) || !empty($options['forceSponsored']);
        $newWindow = !empty($kw['new_window']) || !empty($options['forceNewWindow']);

        $relParts = [];
        if ($nofollow) {
            $relParts[] = 'nofollow';
        }
        if ($sponsored) {
            $relParts[] = 'sponsored';
        }
        if ($newWindow) {
            // Safety when target="_blank" (prevent reverse tabnabbing).
            $relParts[] = 'noopener';
        }
        $rel    = $relParts ? ' rel="' . esc_attr(implode(' ', array_unique($relParts))) . '"' : '';
        $target = $newWindow ? ' target="_blank"' : '';
        $href   = esc_url($prettyUrl);

        // V3 emitted a `title` attribute from the link's name. Keep it so
        // link hover text matches 3.x behavior.
        $title     = trim((string) ($kw['name'] ?? ''));
        $titleAttr = $title !== '' ? ' title="' . esc_attr($title) . '"' : '';

        $anchor = '<a href="' . $href . '"' . $titleAttr
            . ' class="pretty-link pretty-link-keyword prli-keyword" data-prli-link-id="'
            . esc_attr((string) $linkId) . '"' . $rel . $target . '>'
            . esc_html($matched) . '</a>';

        $disclosure = isset($options['disclosure']) ? (string) $options['disclosure'] : '';
        if ($disclosure !== '') {
            // Single leading space so "link(aff)" reads "link (aff)".
            $anchor .= ' ' . esc_html($disclosure);
        }
        return $anchor;
    }

    /**
     * Build (and cache) the word-boundary regex for a keyword.
     *
     * @param string  $keyword       The keyword to match.
     * @param boolean $caseSensitive Whether matching is case-sensitive.
     *
     * @return string
     */
    private static function pattern(string $keyword, bool $caseSensitive): string
    {
        $cacheKey = $caseSensitive ? "1:{$keyword}" : "0:{$keyword}";
        if (!isset(self::$patternCache[$cacheKey])) {
            $flags                         = $caseSensitive ? 'u' : 'ui';
            self::$patternCache[$cacheKey] = '/\b' . preg_quote($keyword, '/') . '\b/' . $flags;
        }
        return self::$patternCache[$cacheKey];
    }

    /**
     * Resolve a stored slug to its full pretty-link URL.
     *
     * @param string $slug The stored slug (full URL path).
     *
     * @return string
     */
    private static function prettyUrl(string $slug): string
    {
        if ($slug === '') {
            return '';
        }
        // Stored slug is the full URL path — prefix baked in at creation.
        // LinkUrl::build honors the alternate shortlink domain
        // (`use_prettylink_url`), so keyword-replaced links and explicit
        // pretty links resolve to the same host.
        return \PrettyLinks\Helpers\LinkUrl::build($slug);
    }

    /**
     * Mask every region where keyword replacement must not happen, replacing
     * each with an opaque `\x01PRLI_MASK_{idx}\x02` token. Four layers are
     * stripped in order, matching v3's onion-peeling protection:
     *
     *   1. Shortcodes `[…]`
     *   2. Full-pair dangerous tags `<a|h1-6|pre|script|iframe|code|applet|
     *      audio|canvas|button|textarea …>…</tag>` (so `<h2>Keyword</h2>`
     *      and `<a>…</a>` are left alone)
     *   3. Self-closing / void tags (`<img … />`, `<br/>`)
     *   4. Any remaining tag markup `<…>` — catches unclosed/inline tags
     *      and, critically, protects attribute values like `title="…"`,
     *      `alt="…"`, `data-*="…"` from getting link-wrapped mid-attribute.
     *
     * The layers must run outer-to-inner on the already-mutated text so a
     * later layer never re-parses a masked region. Token format is
     * `\x01PRLI_MASK_N\x02` — the SOH/STX bytes never appear in HTML that
     * WordPress renders, so they can't collide with content.
     *
     * @param string $content The content to mask skip regions within.
     *
     * @return array{text: string, tokens: array<int, string>}
     */
    private static function maskSkipRegions(string $content): array
    {
        $tokens = [];
        $idx    = 0;

        $mask = static function (string $pattern, string $text) use (&$tokens, &$idx): string {
            $replaced = preg_replace_callback(
                $pattern,
                static function (array $m) use (&$tokens, &$idx): string {
                    $tokens[$idx] = $m[0];
                    $token        = "\x01PRLI_MASK_{$idx}\x02";
                    $idx++;
                    return $token;
                },
                $text
            );
            // Preg_replace_callback returns null on regex error — fall back
            // to the un-masked text rather than clobbering content.
            return is_string($replaced) ? $replaced : $text;
        };

        // 1. Shortcodes — keywords inside `[shortcode attr="foo"]` would
        // corrupt the shortcode's attribute parser.
        $text = $mask('/\[[^\]]*\]/', $content);

        // 2. Full-pair dangerous tags. `\1` backref pins the closing tag
        // to the same name as the opener so `<code>x</pre>` doesn't
        // swallow to the next `</pre>`.
        $text = $mask(
            '/<(a|h[1-6]|pre|script|iframe|code|applet|audio|canvas|button|textarea)\b[^>]*>.*?<\/\1>/is',
            $text
        );

        // 3. Self-closing / void tags — `<img alt="keyword" />`, `<br/>`.
        $text = $mask('/<[^>]*\/\s*>/', $text);

        // 4. Any remaining tag markup — including inline `<span title="x">`
        // openers and closers, HTML comments `<!-- … -->`, doctype,
        // CDATA. Attribute values live inside the `<…>` so masking
        // the whole token protects them from keyword regex. Matches
        // v3's permissive `<…>` pass.
        $text = $mask('/<[^>]*>/s', $text);

        return [
            'text'   => $text,
            'tokens' => $tokens,
        ];
    }

    /**
     * Restore placeholders. Mask layers can nest — e.g. a shortcode masked
     * by layer 1 inside a header masked by layer 2 produces a token whose
     * stored value itself contains another token. Each pass can only
     * restore one level, so loop until no placeholder remains.
     *
     * The loop is bounded by the token count — every successful pass
     * resolves at least one token, so it terminates in O(depth).
     *
     * @param string             $text   The masked text to restore tokens within.
     * @param array<int, string> $tokens The token index to original-value map.
     *
     * @return string
     */
    private static function restoreSkipRegions(string $text, array $tokens): string
    {
        if (!$tokens) {
            return $text;
        }
        $maxPasses = count($tokens) + 1;
        for ($i = 0; $i < $maxPasses; $i++) {
            if (strpos($text, "\x01PRLI_MASK_") === false) {
                break;
            }
            $next = preg_replace_callback(
                '/\x01PRLI_MASK_(\d+)\x02/',
                static fn(array $m): string => $tokens[(int) $m[1]] ?? $m[0],
                $text
            );
            if (!is_string($next) || $next === $text) {
                break;
            }
            $text = $next;
        }
        return $text;
    }
}
