<?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 PrettyLinks\Pro\PostOptions\PostOptions;
use PrettyLinks\Repositories\Links as LinksRepo;

/**
 * URL replacement filter. Runs AFTER the keyword engine on `the_content`.
 *
 * Two modes:
 *   A. Explicit URL list per link — the "URL Replacements" field on each
 *      Pretty Link's edit page. Stored in `prli_link_metas` with
 *      meta_key `prli-url-replacements` (v3 location, preserved in v4).
 *      Matches any <a href="…"> whose target equals one of the configured
 *      URLs and swaps the href.
 *   B. Blanket (controlled by `replace_urls_with_pretty_links` option).
 *      Every <a href> whose target matches a known link's `url` is
 *      swapped. v3 parity: if the target isn't already a pretty link,
 *      one is minted on the spot so "Replace ALL URLs" genuinely
 *      replaces all of them (the "auto" in "URL Auto Replacement").
 *
 * Extra scopes when the matching `replace_keywords_in_*` value is
 * `'urls'` or `'both'`: comment bodies, feeds, BBPress replies,
 * WooCommerce short descriptions. Pro Bootstrap wires the registration.
 *
 * `$linkIndex` and `$explicitUrlMap` are per-request in-memory caches that
 * avoid re-querying the link tables for every filter invocation within a
 * single page load.
 */
class UrlReplacer
{
    /**
     * The Pro options store.
     *
     * @var OptionsStore
     */
    private OptionsStore $options;

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

    /**
     * The per-post URL replacement cache, if available.
     *
     * @var PostUrlCache|null
     */
    private ?PostUrlCache $cache;

    /**
     * Memoized blanket URL map (every link's url column).
     *
     * @var array<string, array<string, mixed>>|null
     */
    private ?array $linkIndex = null;

    /**
     * Memoized explicit per-link URL replacement map.
     *
     * @var array<string, array<string, mixed>>|null
     */
    private ?array $explicitUrlMap = null;

    /**
     * Memoized parsed block list (urls and hosts).
     *
     * @var array{urls: array<int, string>, hosts: array<int, string>}|null
     */
    private ?array $cachedBlockList = null;

    /**
     * Set of existing link slugs, loaded once per request and used to
     * detect when an incoming href is already a pretty URL on this site.
     * Without this guard, blanket-mode auto-create would happily mint a
     * pretty link pointing at another pretty link, producing redirect
     * chains and phantom rows in the admin list.
     *
     * @var array<string, true>|null
     */
    private ?array $knownSlugs = null;

    /**
     * Per-post, per-request render cache keyed on content length.
     * v3 parity: `the_content` can fire multiple times per page load
     * (SEO previews, share-bar plugins, etc.); caching prevents
     * redundant passes and the double-wrapping they'd cause.
     *
     * @var array<int, array{len: int, out: string}>
     */
    private array $renderCache = [];

    /**
     * Constructor.
     *
     * @param OptionsStore      $options     The Pro options store.
     * @param Disclosures|null  $disclosures The optional disclosure injector.
     * @param PostUrlCache|null $cache       The optional per-post URL cache.
     */
    public function __construct(
        OptionsStore $options,
        ?Disclosures $disclosures = null,
        ?PostUrlCache $cache = null
    ) {
        $this->options     = $options;
        $this->disclosures = $disclosures;
        $this->cache       = $cache;
    }

    /**
     * The `the_content` URL replacement filter.
     *
     * @param string $content The post content to filter.
     *
     * @return string
     */
    public function filter(string $content): string
    {
        // Skip automatic excerpt generation — `get_the_excerpt` is still on
        // `$wp_current_filter` during the inner `the_content` pass that
        // `wp_trim_excerpt()` runs. See KeywordEngine::filter() for the full
        // reasoning; both engines share the condition.
        if (doing_filter('get_the_excerpt') || !$this->postTypeAllowed()) {
            return $content;
        }

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

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

        // V3 parity: bail on password-protected posts whose cookie is
        // missing — we'd otherwise cache rewritten markup behind the
        // password form.
        if ($postId > 0) {
            $post = get_post($postId);
            if ($post && post_password_required($post)) {
                return $content;
            }
        }

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

        $replaced = $this->runReplacementForPost($postId, $this->preNormalize($content));
        // V4 additive: the inline (aff) disclosure used to be keyword-only in
        // v3, but v4 shares it across both engines. Keys stay on the original
        // v3 names (`enable_keyword_link_disclosures` / `keyword_link_disclosure`)
        // so stored options map 1:1; the engine just extends them to URL
        // anchors after the rewrite has stamped its marker class.
        $replaced = $this->appendInlineDisclosure($replaced);
        $out      = $this->maybeInjectDisclosure($content, $replaced);
        if ($postId > 0) {
            $this->renderCache[$postId] = [
                'len' => $len,
                'out' => $out,
            ];
        }
        return $out;
    }

    /**
     * Cache-aware replacement for a known post. Falls back to the
     * non-cached path when `$postId <= 0` or the cache service isn't
     * injected (e.g. tests).
     *
     * On cache hit: uses the stored map directly — no `prli_links` query.
     * On cache miss: extracts hrefs from the content, queries only for
     * those URLs, runs replacement, and persists the result so future
     * visits skip the lookup entirely.
     *
     * @param integer $postId  The current post id.
     * @param string  $content The content to run replacement on.
     *
     * @return string
     */
    private function runReplacementForPost(int $postId, string $content): string
    {
        if ($postId <= 0 || $this->cache === null) {
            return $this->runReplacement($content);
        }

        if (!$this->options->getPro('url_replacement_is_on', false)) {
            return $content;
        }

        if ($content === '' || strpos($content, '<a') === false) {
            return $content;
        }

        if ($this->cache->hasEntries($postId)) {
            $cachedMap = $this->cache->getReplacedMap($postId);
            if (empty($cachedMap)) {
                return $content;
            }
            return $this->applyMap($content, $cachedMap);
        }

        $hrefs = $this->extractHrefs($content);
        if (empty($hrefs)) {
            $this->cache->store($postId, [], []);
            return $content;
        }

        $blanketOn  = (bool) $this->options->getPro('replace_urls_with_pretty_links', false);
        $autoCreate = $blanketOn;
        $blockList  = $blanketOn ? $this->blockList() : [
            'urls'  => [],
            'hosts' => [],
        ];

        $map       = $this->buildTargetedMap($hrefs, $blanketOn);
        $unmatched = [];

        foreach ($hrefs as $normalized => $rawHref) {
            if (isset($map[$normalized])) {
                continue;
            }
            if ($blanketOn) {
                if ($blockList['urls'] && in_array($normalized, $blockList['urls'], true)) {
                    $unmatched[] = $normalized;
                    continue;
                }
                if ($blockList['hosts']) {
                    $host = wp_parse_url($rawHref, PHP_URL_HOST);
                    if (is_string($host) && in_array(strtolower($host), $blockList['hosts'], true)) {
                        $unmatched[] = $normalized;
                        continue;
                    }
                }
                if ($autoCreate && !$this->isOwnPrettyUrl($rawHref)) {
                    $link = $this->autoCreateLink($rawHref);
                    if ($link !== null) {
                        $map[$normalized] = $link;
                        continue;
                    }
                }
            }
            $unmatched[] = $normalized;
        }

        $replacedEntries = [];
        foreach ($map as $url => $link) {
            $replacedEntries[$url] = (int) $link['id'];
        }
        $this->cache->store($postId, $replacedEntries, $unmatched);

        if (empty($map)) {
            return $content;
        }
        return $this->applyMap($content, $map);
    }

    /**
     * Rewrites `<a href>` tags against a precomputed URL map. No auto-
     * create path — the cached map is the authoritative set of matches.
     * Used by both the cache-hit path and the cache-miss path (after the
     * targeted scan has built the map).
     *
     * @param string                              $content The content to rewrite.
     * @param array<string, array<string, mixed>> $map     The URL to link map to apply.
     *
     * @return string
     */
    private function applyMap(string $content, array $map): string
    {
        if (empty($map)) {
            return $content;
        }
        $out = preg_replace_callback(
            '/<a\b([^>]*)\bhref=(["\'])(.*?)\2([^>]*)>/i',
            function (array $m) use ($map): string {
                $before = $m[1];
                $quote  = $m[2];
                $href   = html_entity_decode($m[3], ENT_QUOTES | ENT_HTML5, 'UTF-8');
                $after  = $m[4];

                $normalized = self::normalizeUrl($href);
                $link       = $map[$normalized] ?? null;
                if ($link === null) {
                    return $m[0];
                }

                $newHref = esc_url((string) $link['pretty_url']);
                $attrs   = $this->rebuildAttrs($before, $after, $link);

                return '<a' . $attrs . ' href=' . $quote . $newHref . $quote . '>';
            },
            $content
        );
        return is_string($out) ? $out : $content;
    }

    /**
     * Extract every `<a href>` target from the content. Returns
     * [normalized => raw] so callers can both key lookups on the
     * normalized form and still read raw hrefs (for host parsing, etc.).
     *
     * @param string $content The content to extract hrefs from.
     *
     * @return array<string, string>
     */
    private function extractHrefs(string $content): array
    {
        $out = [];
        if (preg_match_all('/<a\b[^>]*\bhref=(["\'])(.*?)\1/i', $content, $matches)) {
            foreach ($matches[2] as $raw) {
                $decoded    = html_entity_decode((string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8');
                $normalized = self::normalizeUrl($decoded);
                if ($normalized === '' || isset($out[$normalized])) {
                    continue;
                }
                $out[$normalized] = $decoded;
            }
        }
        return $out;
    }

    /**
     * Build a URL → link map limited to the hrefs actually present in
     * the post. Two queries: one for explicit `prli-url-replacements`
     * matches, one for blanket `prli_links.url` matches (if blanket
     * mode is on). Explicit wins on collision — same precedence as
     * the legacy full-table path.
     *
     * @param array<string, string> $hrefs     Normalized to raw href map.
     * @param boolean               $blanketOn Whether blanket mode is enabled.
     *
     * @return array<string, array<string, mixed>>
     */
    private function buildTargetedMap(array $hrefs, bool $blanketOn): array
    {
        if (empty($hrefs)) {
            return [];
        }
        global $wpdb;
        $keys         = array_keys($hrefs);
        $placeholders = implode(', ', array_fill(0, count($keys), '%s'));
        $variants     = $this->hrefVariants($keys);
        $varPH        = implode(', ', array_fill(0, count($variants), '%s'));

        $map = [];

        // Explicit per-link URL list — `prli-url-replacements` meta.
        // `LOWER(meta_value)` pairs with lowercased $variants so URL
        // replacement works consistently across CI and CS collations.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        $sql = "SELECT lm.meta_value AS url, l.id, l.slug, l.nofollow, l.sponsored, l.new_window
                 FROM {$wpdb->prefix}prli_link_metas lm
                 INNER JOIN {$wpdb->prefix}prli_links l ON l.id = lm.link_id
                 WHERE lm.meta_key = 'prli-url-replacements'
                   AND l.deleted_at IS NULL
                   AND LOWER(lm.meta_value) IN ({$varPH})";
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $variants), ARRAY_A);
        foreach ($rows as $row) {
            $normalized = self::normalizeUrl((string) $row['url']);
            if ($normalized === '' || isset($map[$normalized]) || !isset($hrefs[$normalized])) {
                continue;
            }
            $map[$normalized] = [
                'id'         => (int) $row['id'],
                'pretty_url' => self::prettyUrl((string) $row['slug']),
                'nofollow'   => (bool) $row['nofollow'],
                'sponsored'  => (bool) $row['sponsored'],
                'new_window' => (bool) $row['new_window'],
            ];
        }

        if (!$blanketOn) {
            return $map;
        }

        // Blanket `prli_links.url` fallback — same CI compare as above.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        $sql = "SELECT id, slug, url, nofollow, sponsored, new_window
                 FROM {$wpdb->prefix}prli_links
                 WHERE deleted_at IS NULL
                   AND LOWER(url) IN ({$varPH})";
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $rows = (array) $wpdb->get_results($wpdb->prepare($sql, $variants), ARRAY_A);
        foreach ($rows as $row) {
            $normalized = self::normalizeUrl((string) $row['url']);
            if ($normalized === '' || isset($map[$normalized]) || !isset($hrefs[$normalized])) {
                continue;
            }
            $map[$normalized] = [
                'id'         => (int) $row['id'],
                'pretty_url' => self::prettyUrl((string) $row['slug']),
                'nofollow'   => (bool) $row['nofollow'],
                'sponsored'  => (bool) $row['sponsored'],
                'new_window' => (bool) $row['new_window'],
            ];
        }

        unset($placeholders);
        return $map;
    }

    /**
     * Expand each normalized URL to the set of forms it may have been
     * stored as: with and without trailing slash. The on-disk values in
     * both `prli_links.url` and the `prli-url-replacements` meta may or
     * may not carry a trailing slash depending on the admin's entry, so
     * an exact IN() match needs both shapes.
     *
     * @param array<int, string> $urls The normalized URLs to expand.
     *
     * @return array<int, string>
     */
    private function hrefVariants(array $urls): array
    {
        $out = [];
        foreach ($urls as $u) {
            if ($u === '') {
                continue;
            }
            $out[$u]       = true;
            $out[$u . '/'] = true;
        }
        return array_keys($out);
    }

    /**
     * The comment URL replacement filter.
     *
     * @param string $content The comment content to filter.
     *
     * @return string
     */
    public function filterComment(string $content): string
    {
        if (!self::scopeAllowsUrls($this->options->getPro('replace_keywords_in_comments', 'none'))) {
            return $content;
        }
        return $this->runReplacement($this->preNormalize($content));
    }

    /**
     * V3 registers `comment_text_rss` only when BOTH scopes are non-none.
     *
     * @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::scopeAllowsUrls($comments)) {
            return $content;
        }
        return $this->runReplacement($this->preNormalize($content));
    }

    /**
     * The feed URL replacement filter.
     *
     * @param string $content The feed content to filter.
     *
     * @return string
     */
    public function filterFeed(string $content): string
    {
        if (!self::scopeAllowsUrls($this->options->getPro('replace_keywords_in_feeds', 'none'))) {
            return $content;
        }
        return $this->runReplacement($this->preNormalize($content));
    }

    /**
     * The BBPress/WooCommerce URL replacement filter.
     *
     * @param string $content The content to filter.
     *
     * @return string
     */
    public function filterBbpress(string $content): string
    {
        if (!$this->postTypeAllowed()) {
            return $content;
        }
        return $this->runReplacement($this->preNormalize($content));
    }

    /**
     * V3 parity: strip WP-mangled entities so URLs containing `&` match
     * after texturize/kses. `&nbsp;` normalization is behind the shared
     * `plp_keywords_replace_nbsp` filter v3 used (URL and keyword
     * replacers both honored it).
     *
     * @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);
    }

    /**
     * V3 parity: `url_replacement_cpts` is an allow-list; empty array =
     * no URL replacement anywhere (matches v3's `in_array` semantics).
     *
     * @return boolean
     */
    private function postTypeAllowed(): bool
    {
        $cpts = $this->options->getPro('url_replacement_cpts', []);
        if (!is_array($cpts) || !$cpts) {
            return false;
        }
        $post = get_post();
        if (!$post) {
            return false;
        }
        return in_array((string) $post->post_type, $cpts, true);
    }

    /**
     * Whether URL 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_url_replacements'];
    }

    /**
     * URL block list. The stored option key is the v3-canonical
     * `replace_urls_with_pretty_links_blacklist` (preserved verbatim so
     * v3 upgrades + downgrades keep reading the same slot) — this method
     * returns the parsed, normalized entries.
     *
     * Newline- or comma-separated URLs that should never be rewritten by
     * blanket mode. v3 matched by hostname (`wp_parse_url(..., PHP_URL_HOST)`)
     * so entering `https://example.com/any` would skip every link on
     * that host; we preserve that semantics alongside exact-URL matching.
     *
     * Empty stored value falls back to the current site's hostname
     * variants (with and without `www.`) so blanket replacement never
     * rewrites a site's own internal links without the admin opting in.
     * No DB write — this is a runtime default that yields to any
     * admin-supplied value.
     *
     * Result is memoized per instance — the option is request-stable.
     *
     * @return array{urls: array<int, string>, hosts: array<int, string>}
     */
    private function blockList(): array
    {
        if ($this->cachedBlockList !== null) {
            return $this->cachedBlockList;
        }
        $raw = (string) $this->options->getPro('replace_urls_with_pretty_links_blacklist', '');
        if ($raw === '') {
            return $this->cachedBlockList = [
                'urls'  => [],
                'hosts' => $this->currentSiteHosts(),
            ];
        }
        $urls  = [];
        $hosts = [];
        foreach (preg_split('/[\r\n,]+/', $raw) ?: [] as $entry) {
            $entry = trim((string) $entry);
            if ($entry === '') {
                continue;
            }
            $norm = self::normalizeUrl($entry);
            if ($norm !== '') {
                $urls[] = $norm;
            }
            $host = wp_parse_url($entry, PHP_URL_HOST);
            if (is_string($host) && $host !== '') {
                $host = strtolower($host);
                if (!in_array($host, $hosts, true)) {
                    $hosts[] = $host;
                }
            }
        }
        return $this->cachedBlockList = [
            'urls'  => $urls,
            'hosts' => $hosts,
        ];
    }

    /**
     * True if $href resolves to an existing pretty link on this site —
     * i.e. its path matches a known slug (after stripping the pretty-link
     * base path, when the alternate domain includes a path prefix). Used to
     * short-circuit blanket auto-create so we never pretty-link a pretty link.
     *
     * @param string $href The href to test.
     *
     * @return boolean
     */
    private function isOwnPrettyUrl(string $href): bool
    {
        $host = wp_parse_url($href, PHP_URL_HOST);
        if (!is_string($host) || $host === '') {
            return false;
        }
        $selfHosts = $this->currentSiteHosts();
        if (!in_array(strtolower($host), $selfHosts, true)) {
            return false;
        }
        $path = ltrim((string) (wp_parse_url($href, PHP_URL_PATH) ?? ''), '/');
        if ($path === '') {
            return false;
        }
        // Try the path with the pretty-link base prefix removed (alt domain
        // `…/klik` + slug `offer` → public `/klik/offer`), and also the
        // verbatim path so slugs that already bake in that prefix
        // (e.g. stored `klik/offer`) still match. This mirrors the intent of
        // Engine::resolveWithAltDomainFallback's dual lookup, though it does
        // not subtract a home-subdirectory path (rare alt-domain-over-subdir
        // combo) the way the redirect engine does.
        $stripped = $this->stripPrettyLinkBasePath($path);
        if ($this->knownSlugs === null) {
            global $wpdb;
            $slugs            = (array) $wpdb->get_col(
                "SELECT slug FROM {$wpdb->prefix}prli_links WHERE deleted_at IS NULL"
            );
            $this->knownSlugs = [];
            foreach ($slugs as $slug) {
                if ($slug !== null && $slug !== '') {
                    $this->knownSlugs[(string) $slug] = true;
                }
            }
        }
        if (isset($this->knownSlugs[$path])) {
            return true;
        }
        return $stripped !== '' && $stripped !== $path && isset($this->knownSlugs[$stripped]);
    }

    /**
     * Strip the configured pretty-link base path from a request path so the
     * remainder can be matched against stored slugs. Host matching alone is
     * not enough when `prettylink_url` is e.g. `https://go.example.com/klik`.
     *
     * @param string $path Path already trimmed of a leading slash.
     *
     * @return string
     */
    private function stripPrettyLinkBasePath(string $path): string
    {
        $basePath = trim((string) wp_parse_url($this->prettyLinkBaseUrl(), PHP_URL_PATH), '/');
        if ($basePath === '') {
            return $path;
        }
        if ($path === $basePath) {
            return '';
        }
        $prefix = $basePath . '/';
        if (strncmp($path, $prefix, strlen($prefix)) === 0) {
            return substr($path, strlen($prefix));
        }
        return $path;
    }

    /**
     * Effective pretty-link base URL (alternate shortlink domain when on).
     * Mirrors Pro's `prli_pretty_link_base` filter without requiring it to be
     * registered (unit tests construct UrlReplacer directly).
     *
     * @return string
     */
    private function prettyLinkBaseUrl(): string
    {
        if ($this->options->getPro('use_prettylink_url', false)) {
            $alt = trim((string) $this->options->getPro('prettylink_url', ''));
            if ($alt !== '') {
                return rtrim(self::ensureUrlScheme($alt), '/');
            }
        }
        return rtrim((string) home_url('/'), '/');
    }

    /**
     * Mint a fresh pretty link targeting $url. Returns the same shape
     * the explicit/blanket maps use, or null if creation failed. Reuses
     * an existing row when one is already pointing at this URL (e.g. a
     * concurrent request created it moments ago).
     *
     * @param string $url The target URL to mint a pretty link for.
     *
     * @return array<string, mixed>|null
     */
    private function autoCreateLink(string $url): ?array
    {
        $url = trim($url);
        if ($url === '') {
            return null;
        }
        $repo     = new LinksRepo();
        $existing = $repo->findByUrl($url);
        if (is_array($existing) && !empty($existing['id'])) {
            $row = $existing;
        } else {
            $created = $repo->create([
                'url'    => $url,
                'source' => 'admin',
            ]);
            if (!is_array($created) || empty($created['id'])) {
                return null;
            }
            $row = $created;
        }

        $slug = (string) ($row['slug'] ?? '');
        if ($slug === '') {
            return null;
        }
        // Track the new slug so a subsequent reference to the newly-
        // minted pretty URL within this same request doesn't get
        // auto-recreated as a link-to-itself.
        if ($this->knownSlugs !== null) {
            $this->knownSlugs[$slug] = true;
        }

        return [
            'id'         => (int) $row['id'],
            'pretty_url' => self::prettyUrl($slug),
            'nofollow'   => (bool) ($row['nofollow'] ?? false),
            'sponsored'  => (bool) ($row['sponsored'] ?? false),
            'new_window' => (bool) ($row['new_window'] ?? false),
        ];
    }

    /**
     * Hostname variants of this site — `home_url()` plus `www.` counterpart,
     * and (when enabled) Pro's alternate shortlink domain. Used as the
     * default block list so blanket URL replacement won't rewrite the
     * site's own internal links, and by `isOwnPrettyUrl()` so auto-create
     * never mints a pretty link that points at another pretty link on the
     * alternate host (see #786).
     *
     * @return array<int, string>
     */
    private function currentSiteHosts(): array
    {
        $hosts = self::hostVariants((string) home_url('/'));

        if ($this->options->getPro('use_prettylink_url', false)) {
            $alt = trim((string) $this->options->getPro('prettylink_url', ''));
            if ($alt !== '') {
                $hosts = array_merge($hosts, self::hostVariants($alt));
            }
        }

        return array_values(array_unique($hosts));
    }

    /**
     * Lowercased host ± `www.` twin for a URL (or empty when unparseable).
     * Only the host component is used — paths on `prettylink_url` are ignored
     * here (path handling lives in `stripPrettyLinkBasePath()`).
     *
     * @param string $url Absolute or scheme-less URL/host to take the host from.
     *
     * @return array<int, string>
     */
    private static function hostVariants(string $url): array
    {
        $host = wp_parse_url(self::ensureUrlScheme($url), PHP_URL_HOST);
        if (!is_string($host) || $host === '') {
            return [];
        }
        $host  = strtolower($host);
        $hosts = [$host];
        if (strncmp($host, 'www.', 4) === 0) {
            $hosts[] = substr($host, 4);
        } else {
            $hosts[] = 'www.' . $host;
        }
        return $hosts;
    }

    /**
     * Prefix `https://` when the value has no scheme so `wp_parse_url`
     * extracts a host (legacy/admin entries like `pted.tv`).
     *
     * Intentional divergence from Bootstrap's `prli_pretty_link_base` filter,
     * which returns the raw option for URL building — here we only need a
     * parseable host for skip/blocklist matching.
     *
     * @param string $url Raw URL or host.
     *
     * @return string
     */
    private static function ensureUrlScheme(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return $url;
        }
        // Protocol-relative (`//host`) or bare host/path — make absolute.
        if (strpos($url, '//') === 0) {
            return 'https:' . $url;
        }
        if (!preg_match('#^[a-z][a-z0-9+.-]*://#i', $url)) {
            return 'https://' . ltrim($url, '/');
        }
        return $url;
    }

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

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

    /**
     * Run URL replacement on content with no post-level caching.
     *
     * @param string $content The content to filter.
     *
     * @return string
     */
    private function runReplacement(string $content): string
    {
        if ($content === '' || strpos($content, '<a') === false) {
            return $content;
        }

        // V3 two-toggle model (see Store::defaults() comment):
        // `url_replacement_is_on` is the master — explicit per-link
        // aliases only run when on.
        // `replace_urls_with_pretty_links` gates the additional
        // blanket pass that rewrites any <a href> matching a known
        // pretty link's target.
        if (!$this->options->getPro('url_replacement_is_on', false)) {
            return $content;
        }

        $explicitMap = $this->explicitUrlMap();
        $blanketOn   = (bool) $this->options->getPro('replace_urls_with_pretty_links', false);
        $blanketMap  = $blanketOn ? $this->fullUrlMap() : [];

        // In blanket mode we also auto-create a pretty link for any
        // external URL that isn't already known. This is the "Auto" in
        // "URL Auto Replacement" — v3 did it via
        // PrliLink::get_target_to_pretty_urls($urls, true). Gate on the
        // option, not on the map having entries, so a fresh site with no
        // pretty links yet still gets the auto-minting.
        $autoCreate = $blanketOn;

        if (!$explicitMap && !$blanketMap && !$autoCreate) {
            return $content;
        }

        // Pre-filter bypass: when autoCreate is on, any external href is
        // a potential match (we'll mint one if we don't already have it),
        // so the substring probe would never short-circuit anyway. Skip
        // it for explicit-only / no-auto configs where it still helps.
        if (!$autoCreate && !self::anyMapUrlAppears($content, $explicitMap, $blanketMap)) {
            return $content;
        }

        // V3 parity: the block list is a blanket-mode concern. Explicit
        // aliases the admin configured per-link always run — it'd be
        // surprising to tell the plugin "alias X → Y" and have a
        // block-list entry silently drop it.
        $blockList = $blanketOn ? $this->blockList() : [
            'urls'  => [],
            'hosts' => [],
        ];

        $out = preg_replace_callback(
            '/<a\b([^>]*)\bhref=(["\'])(.*?)\2([^>]*)>/i',
            function (array $m) use (&$blanketMap, $explicitMap, $blockList, $autoCreate, $blanketOn): string {
                $before = $m[1];
                $quote  = $m[2];
                $href   = html_entity_decode($m[3], ENT_QUOTES | ENT_HTML5, 'UTF-8');
                $after  = $m[4];

                $normalized = self::normalizeUrl($href);

                // Explicit per-link alias takes priority and ignores the
                // blanket block list — admin configured it on purpose.
                $link = $explicitMap[$normalized] ?? null;
                if ($link === null && $blanketOn) {
                    if ($blockList['urls'] && in_array($normalized, $blockList['urls'], true)) {
                        return $m[0];
                    }
                    if ($blockList['hosts']) {
                        $host = wp_parse_url($href, PHP_URL_HOST);
                        if (is_string($host) && in_array(strtolower($host), $blockList['hosts'], true)) {
                            return $m[0];
                        }
                    }
                    $link = $blanketMap[$normalized] ?? null;

                    // V3 parity: mint a fresh pretty link for unknown
                    // targets so blanket mode truly covers every href.
                    // Skip when the href is itself one of our pretty
                    // URLs — otherwise we'd pretty-link a pretty link.
                    if ($link === null && $autoCreate && !$this->isOwnPrettyUrl($href)) {
                        $link = $this->autoCreateLink($href);
                        if ($link !== null) {
                            // Memoize within this request so subsequent
                            // matches to the same URL reuse the row
                            // instead of racing to create another.
                            $blanketMap[$normalized] = $link;
                        }
                    }
                }
                if ($link === null) {
                    return $m[0];
                }

                $newHref = esc_url((string) $link['pretty_url']);
                $attrs   = $this->rebuildAttrs($before, $after, $link);

                // Note the leading space on $attrs — without it `<a` would
                // fuse with the first attribute into an invalid token.
                return '<a' . $attrs . ' href=' . $quote . $newHref . $quote . '>';
            },
            $content
        );

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

    /**
     * Append the site-wide inline disclosure suffix (e.g. "(aff)") after
     * each URL-rewritten anchor. Mirrors the keyword engine's behavior —
     * a single leading space so "link(aff)" reads "link (aff)". Only
     * anchors carrying the `prli-url` marker class are touched, so
     * author-written anchors are untouched. No-op when the master
     * toggle is off or the suffix is blank.
     *
     * @param string $content The content to append disclosures within.
     *
     * @return string
     */
    private function appendInlineDisclosure(string $content): string
    {
        if (!(bool) $this->options->getPro('enable_keyword_link_disclosures', false)) {
            return $content;
        }
        $disclosure = trim((string) $this->options->getPro('keyword_link_disclosure', ''));
        if ($disclosure === '') {
            return $content;
        }
        $suffix  = ' ' . esc_html($disclosure);
        $pattern = '~(<a\b[^>]*\bclass=(?:"[^"]*\bprli-url\b[^"]*"|\'[^\']*\bprli-url\b[^\']*\')[^>]*>.*?</a>)~is';
        $out     = preg_replace_callback(
            $pattern,
            static function (array $m) use ($suffix): string {
                return $m[1] . $suffix;
            },
            $content
        );
        return is_string($out) ? $out : $content;
    }

    /**
     * Merge existing attrs (minus href/rel/target) with our computed
     * flags. Returns the full attribute string including a leading
     * space; the caller concatenates it immediately after `<a` so the
     * output is always well-formed.
     *
     * @param string               $before The attributes before the href.
     * @param string               $after  The attributes after the href.
     * @param array<string, mixed> $link   The resolved link row.
     *
     * @return string
     */
    private function rebuildAttrs(string $before, string $after, array $link): string
    {
        $combined = trim($before . ' ' . $after);
        $combined = preg_replace('/\brel=(["\'])(.*?)\1/i', '', $combined) ?? $combined;
        $combined = preg_replace('/\btarget=(["\'])(.*?)\1/i', '', $combined) ?? $combined;
        // Stamp marker classes on URL-replaced anchors, mirroring keyword
        // replacement's `pretty-link-keyword prli-keyword` pair. Merges
        // into any existing class attribute on the source `<a>` so
        // author-supplied classes are preserved.
        $marker = 'pretty-link pretty-link-url prli-url';
        if (preg_match('/\bclass=(["\'])(.*?)\1/i', $combined, $m)) {
            $existing = $m[2];
            $merged   = trim($existing . ' ' . $marker);
            $combined = str_replace($m[0], 'class="' . $merged . '"', $combined);
        } else {
            $combined = trim($combined . ' class="' . $marker . '"');
        }
        $combined = trim(preg_replace('/\s+/', ' ', $combined) ?? $combined);

        $relParts = [];
        if (!empty($link['nofollow']) || $this->options->getPro('url_replace_nofollow', true)) {
            $relParts[] = 'nofollow';
        }
        if (!empty($link['sponsored']) || $this->options->getPro('url_replace_sponsored', false)) {
            $relParts[] = 'sponsored';
        }
        $newWindow = !empty($link['new_window']) || (bool) $this->options->getPro('url_replace_new_window', false);
        if ($newWindow) {
            // Reverse-tabnabbing safety when target="_blank".
            $relParts[] = 'noopener';
        }

        $attrs = '';
        if ($combined !== '') {
            $attrs .= ' ' . $combined;
        }
        if ($relParts) {
            $attrs .= ' rel="' . esc_attr(implode(' ', array_unique($relParts))) . '"';
        }
        if ($newWindow) {
            $attrs .= ' target="_blank"';
        }
        return $attrs;
    }

    /**
     * Cheap pre-filter probe. True if any key of either map appears as a
     * substring somewhere in $content. Runs in O(urls × content-length)
     * using strpos, but stops at the first hit — on posts with matching
     * links it's essentially free, and on posts with none it saves the
     * expensive regex pass entirely.
     *
     * @param string                              $content  The content to probe.
     * @param array<string, array<string, mixed>> $explicit The explicit URL map.
     * @param array<string, array<string, mixed>> $blanket  The blanket URL map.
     *
     * @return boolean
     */
    private static function anyMapUrlAppears(string $content, array $explicit, array $blanket): bool
    {
        // URL comparison is case-insensitive (see `normalizeUrl`) — map
        // keys are already lowercased, so the pre-filter probe needs
        // `stripos` to also ignore case when scanning raw content.
        foreach ($explicit as $url => $_) {
            if ($url !== '' && stripos($content, (string) $url) !== false) {
                return true;
            }
        }
        foreach ($blanket as $url => $_) {
            if ($url !== '' && stripos($content, (string) $url) !== false) {
                return true;
            }
        }
        return false;
    }

    /**
     * Explicit per-link URL list — v3's "URL Replacements" field. Stored
     * in `prli_link_metas` with meta_key `prli-url-replacements`, one row
     * per URL. v4 reads from the same table v3 wrote to so upgrades are
     * lossless with no migration.
     *
     * Result is memoized for the request lifetime.
     *
     * @return array<string, array<string, mixed>>
     */
    private function explicitUrlMap(): array
    {
        if ($this->explicitUrlMap !== null) {
            return $this->explicitUrlMap;
        }
        global $wpdb;
        $rows = $wpdb->get_results(
            "SELECT lm.meta_value AS url, l.id, l.slug, l.nofollow, l.sponsored, l.new_window
             FROM {$wpdb->prefix}prli_link_metas lm
             INNER JOIN {$wpdb->prefix}prli_links l ON l.id = lm.link_id
             WHERE lm.meta_key = 'prli-url-replacements'
               AND lm.meta_value IS NOT NULL
               AND lm.meta_value <> ''
               AND l.deleted_at IS NULL",
            ARRAY_A
        ) ?: [];

        $map = [];
        foreach ($rows as $row) {
            $normalized = self::normalizeUrl((string) $row['url']);
            if ($normalized === '') {
                continue;
            }
            // First row wins on collision — if two links claim the same
            // URL, the first one registered in the DB owns it.
            if (isset($map[$normalized])) {
                continue;
            }
            $map[$normalized] = [
                'id'         => (int) $row['id'],
                'pretty_url' => self::prettyUrl((string) $row['slug']),
                'nofollow'   => (bool) $row['nofollow'],
                'sponsored'  => (bool) $row['sponsored'],
                'new_window' => (bool) $row['new_window'],
            ];
        }
        $this->explicitUrlMap = $map;
        return $map;
    }

    /**
     * Blanket URL map: every non-deleted link's `url` column.
     * Result is memoized for the request lifetime.
     *
     * @return array<string, array<string, mixed>>
     */
    private function fullUrlMap(): array
    {
        if ($this->linkIndex !== null) {
            return $this->linkIndex;
        }
        global $wpdb;
        $rows = $wpdb->get_results(
            "SELECT id, slug, url, nofollow, sponsored, new_window
             FROM {$wpdb->prefix}prli_links
             WHERE deleted_at IS NULL",
            ARRAY_A
        ) ?: [];
        $map  = [];
        foreach ($rows as $row) {
            $normalized = self::normalizeUrl((string) $row['url']);
            if ($normalized === '') {
                continue;
            }
            $map[$normalized] = [
                'id'         => (int) $row['id'],
                'pretty_url' => self::prettyUrl((string) $row['slug']),
                'nofollow'   => (bool) $row['nofollow'],
                'sponsored'  => (bool) $row['sponsored'],
                'new_window' => (bool) $row['new_window'],
            ];
        }
        $this->linkIndex = $map;
        return $map;
    }

    /**
     * Normalize a URL for comparison. Trims whitespace, strips a single
     * trailing slash, and lowercases so the replacer matches URLs
     * regardless of how a post author capitalized the href.
     *
     * URL replacement in 4.0 is always case-insensitive — there is no
     * per-link or global toggle. Storage (`prli_links.url` + the
     * `prli-url-replacements` meta) preserves the admin's typed case for
     * display; comparison lowercases on both sides.
     *
     * @param string $url The URL to normalize.
     *
     * @return string
     */
    public static function normalizeUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }
        return strtolower(rtrim($url, '/'));
    }

    /**
     * Resolve a slug to its full pretty-link URL.
     *
     * @param string $slug The stored link slug.
     *
     * @return string
     */
    private static function prettyUrl(string $slug): string
    {
        if ($slug === '') {
            return '';
        }
        return \PrettyLinks\Helpers\LinkUrl::build($slug);
    }
}
