<?php

/**
 * Plugin Name: Pretty Links Fast Dispatch (MU)
 * Description: Intercepts matching slugs before the rest of WordPress loads.
 *              Managed automatically by the Pretty Links plugin — do not edit.
 * Version: 4.0.0
 */

if (!defined('ABSPATH') || is_admin() || defined('DOING_CRON') || defined('WP_CLI')) {
    return;
}
if (empty($_SERVER['REQUEST_URI']) || !is_string($_SERVER['REQUEST_URI'])) {
    return;
}

/**
 * Self-contained fast dispatcher.
 *
 * All logic is inlined — no plugin autoloader, no plugin classes. Only WP core
 * globals ($wpdb, get_option, etc.) and PHP built-ins are used. The class name
 * is intentionally un-namespaced and prefixed to avoid collisions.
 *
 * SYNC CHECKLIST — these members are inlined copies of plugin code and MUST
 * be updated together with their originals:
 *   - extractSlug()        ← Engine::extractSlug() + Engine::stripHomePath()
 *     + the Engine::SLUG_CHAR_CLASS character set (hardcoded here — this stub
 *     loads before the plugin autoloader, so it cannot reference the constant)
 *   - resolveIp()          ← Geo::rawIp() ladder + ClickWriter anonymize/filter order
 *   - unwrapMappedIpv4()   ← Geo::unwrapMappedIpv4()
 *   - blockBase()          ← IpUtil::anonymize()
 *   - country()/lookupCountry() ← Geo::country() (header ladder, XX skip, filter)
 *   - readGeoCache()       ← GeoStore::get()
 *   - queueGeoLookup()     ← GeoStore::queue()
 *   - two-tier cache keys in lookupCountry() ← Geo::readCache()
 *     (key formula 'pl_locate_by_ip_' . md5(base . 'caseproof'))
 *
 * This path makes no outbound HTTP call. Links using geo targeting bypass the
 * turbo dispatcher entirely (see the Pro-feature bypass subselect below), so
 * the country is only ever wanted for the click row — an unresolved IP is
 * queued for GeoBackfillJob instead of being looked up inline.
 */
class PrliMuDispatch
{
    private const PLUGIN_BASENAME = '%%PRLI_PLUGIN_BASENAME%%';

    private const HANDLED_TYPES = ['301', '302', '307', 'metarefresh', 'javascript', 'cloak', 'pixel'];

    public static function run(): void
    {
        // Guard: only dispatch when Pretty Links is active.
        $activePlugins = (array) get_option('active_plugins', []);
        if (!in_array(self::PLUGIN_BASENAME, $activePlugins, true)) {
            if (is_multisite()) {
                $network = (array) get_site_option('active_sitewide_plugins', []);
                if (!array_key_exists(self::PLUGIN_BASENAME, $network)) {
                    return;
                }
            } else {
                return;
            }
        }

        global $wpdb;

        $slug = self::extractSlug((string) $_SERVER['REQUEST_URI']);
        if ($slug === null) {
            return;
        }

        // Detect Pro via a filesystem check — Lite must not reference any
        // `PrettyLinks\Pro\*` class here (that would trip Lite's classmap
        // autoloader when `pro/` is missing or renamed). This mu-plugin is a
        // Pro feature; if `pro/` is gone the drop-in is orphaned — e.g. Lite
        // was installed over Pro without deactivating, so the deactivation
        // cleanup (`prli_plugin_deactivating` → removeIfExists) never ran.
        // In that state stop dispatching and let WordPress load fully:
        // Engine.php is the single authoritative path and already degrades
        // every type correctly without Pro (301/302/307 redirect normally;
        // cloak/metarefresh/javascript fall through to a plain 302;
        // pixel/pay resolve to a clean 404). Re-implementing that here only
        // creates drift (an empty-target pixel coerced to 302 emits a blank
        // Location header, for one).
        $pluginDir = WP_PLUGIN_DIR . '/' . dirname(self::PLUGIN_BASENAME);
        if (!is_file($pluginDir . '/pro/pretty-link-pro.php')) {
            return;
        }

        // Pro-feature bypass subselect: when any of these advanced
        // features apply to the link, the dispatcher must fall through
        // to full WP so Engine.php + Pro hooks can resolve them
        // (rotations, geo/expiration metas, split tests, custom-report
        // goal links, and targeting rules — the v4 `targeting_rules`
        // store plus the legacy v3 `tech_url`/`time_url` device/time
        // metas; legacy geo is already covered by `geo_countries`).
        // The turbo path only does plain redirects, so a targeted link
        // must reach the full plugin to be evaluated. Rolled into the
        // main SELECT as a boolean column
        // to save a round-trip. Pro is guaranteed present here (the bail
        // above), so the referenced tables always exist.
        $metasTable    = $wpdb->prefix . 'prli_link_metas';
        $rotationsTbl  = $wpdb->prefix . 'prli_link_rotations';
        $reportsTable  = $wpdb->prefix . 'prli_reports';
        $proBypassExpr = "(
                EXISTS(
                    SELECT 1 FROM {$metasTable}
                      WHERE link_id = l.id
                        AND meta_key IN ('expire_date','expire_clicks','geo_countries','prli-enable-split-test','targeting_rules','tech_url','time_url')
                        AND meta_value <> '' AND meta_value <> '0'
                )
                OR EXISTS(
                    SELECT 1 FROM {$rotationsTbl} WHERE link_id = l.id
                )
                OR EXISTS(
                    SELECT 1 FROM {$reportsTable} WHERE goal_link_id = l.id
                )
                OR EXISTS(
                    SELECT 1 FROM {$metasTable}
                      WHERE meta_key = 'prli-split-test-goal-link'
                        AND meta_value = CAST(l.id AS CHAR)
                )
            )";

        $lookupSql = "SELECT l.id, l.url, l.name, l.redirect_type, l.track_me,
                        l.param_forwarding, l.nofollow, l.sponsored, l.prettypay_link,
                        {$proBypassExpr} AS has_pro_features
                   FROM {$wpdb->prefix}prli_links l
                  WHERE l.slug = %s
                    AND l.deleted_at IS NULL
                  LIMIT 1";
        /** @var array<string,mixed>|null $link */
        $link = $wpdb->get_row($wpdb->prepare($lookupSql, $slug), ARRAY_A);
        if (!is_array($link)) {
            // Verbatim miss: retry once with the alternate-domain path prefix
            // stripped — mirrors Engine::resolveWithAltDomainFallback() for
            // v3 sites that used an alt-domain path (e.g. `/klik/`) as a
            // base-slug prefix while storing slugs bare. See issue #752. If
            // this also misses we fall through to full WP as before, so turbo
            // mode is never worse than the Engine path.
            $stripped = self::stripAltDomainPath($slug);
            if ($stripped !== null && $stripped !== $slug) {
                $link = $wpdb->get_row($wpdb->prepare($lookupSql, $stripped), ARRAY_A);
            }
        }
        if (!is_array($link)) {
            return;
        }

        $type   = (string) ($link['redirect_type'] ?? '');
        $linkId = (int)    ($link['id']            ?? 0);

        // Pretty Bar disabled site-wide: the bar's renderer bails on the WP
        // side so the link degrades to a plain 302. Promote that decision up
        // to the fast-path here so we don't bounce through full WP just to
        // end up at wp_redirect() anyway.
        if ($type === 'prettybar') {
            if (!(bool) get_option('prlipro_prettybar_active', 1)) {
                $type = '302';
            }
        }

        // Bypass: type not handled here (prettybar, prettypay_link_stripe,
        // future gateways). Full WP loads and Engine.php picks up the rest.
        if (!in_array($type, self::HANDLED_TYPES, true)) {
            return;
        }

        // Bypass: PrettyPay link — needs Stripe session logic.
        if (!empty($link['prettypay_link'])) {
            return;
        }

        // Bypass (Pro only): the EXISTS chain above flagged one of the
        // advanced Pro features that only resolve on the full WP stack.
        if ((int) ($link['has_pro_features'] ?? 0) === 1) {
            return;
        }

        // Fetch options only when tracking is on — avoids the DB/cache read otherwise.
        $trackMe = !empty($link['track_me']);
        $opts    = $trackMe ? (array) get_option('prli_options', []) : [];
        $mode    = $trackMe ? (string) ($opts['extended_tracking'] ?? 'normal') : '';

        // Build target URL with param forwarding (data already in the link row).
        $target    = (string) ($link['url'] ?? '');
        $paramMode = (string) ($link['param_forwarding'] ?? '');
        if ($paramMode !== '' && $paramMode !== 'off' && $paramMode !== '0') {
            $incoming = (string) ($_SERVER['QUERY_STRING'] ?? '');
            if ($incoming !== '') {
                $existing = (string) parse_url($target, PHP_URL_QUERY);
                $sep      = ($existing !== '') ? '&' : '?';
                $ps       = $sep . str_ireplace(['%5B', '%5D'], ['[', ']'], $incoming);
                $hash     = strpos($target, '#');
                $target   = $hash === false
                    ? $target . $ps
                    : substr($target, 0, $hash) . $ps . substr($target, $hash);
            }
        }

        // Set tracking cookies before any header() call — setcookie() is
        // silently ignored after fastcgi_finish_request(). All three modes
        // need first-click resolution: count mode uses it to track
        // static-uniques, matching v3 behaviour.
        $vuid       = $trackMe ? self::resolveVisitorId()        : '';
        $firstClick = $trackMe ? self::resolveFirstClick($linkId) : 0;

        // Dedup: skip the write entirely if this visitor already clicked
        // within the last 10 seconds (browser retry / prefetch guard).
        // Applies to all three modes — cookie must be set before headers flush.
        if ($trackMe && self::resolveDedup($linkId)) {
            $trackMe = false;
        }

        // Schedule the click write to run after the response is sent.
        if ($trackMe) {
            register_shutdown_function(
                static function () use ($wpdb, $linkId, $target, $mode, $opts, $vuid, $firstClick): void {
                    PrliMuDispatch::writeClick($wpdb, $linkId, $target, $mode, $opts, $vuid, $firstClick);
                }
            );
        }

        // Common response headers.
        if (!headers_sent()) {
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0', true);
            header('Pragma: no-cache', true);
            header('Expires: 0', true);
            // v3 parity: signature header with edition + version.
            // Edition is baked in at mu-plugin install time (per-build
            // slug). Version is parsed at request time from the main
            // plugin file's `Version:` header via get_file_data() so
            // the mu-plugin always reports the currently-installed
            // version — bumping pretty-link.php is the single source
            // of truth. get_file_data() lives in wp-includes/functions.php
            // which is already loaded by the time mu-plugins fire.
            $prliMainFile = WP_PLUGIN_DIR . '/' . self::PLUGIN_BASENAME;
            $prliHeaders  = function_exists('get_file_data') && is_file($prliMainFile)
                ? get_file_data($prliMainFile, ['Version' => 'Version'])
                : [];
            $prliVersion  = (string) ($prliHeaders['Version'] ?? '');
            header('X-Redirect-Powered-By: %%PRLI_EDITION_LABEL%% ' . $prliVersion . ' http://prettylink.com', true);
            $robots = [];
            if (!empty($link['nofollow'])) {
                $robots[] = 'noindex';
                $robots[] = 'nofollow';
            }
            if (!empty($link['sponsored'])) {
                $robots[] = 'sponsored';
            }
            if ($robots !== []) {
                header('X-Robots-Tag: ' . implode(', ', $robots), true);
            }
        }

        // Fetch custom head scripts for HTML-page redirect types.
        // Global scripts come from prlipro_options; per-link from
        // prli_link_metas.
        $headScripts = '';
        if (in_array($type, ['metarefresh', 'javascript', 'cloak'], true)) {
            $proOpts = (array) get_option('prlipro_options', []);
            $global  = isset($proOpts['global_head_scripts']) ? (string) $proOpts['global_head_scripts'] : '';
            if ($global !== '') {
                $headScripts .= $global;
            }
            $perLink = (string) $wpdb->get_var(
                $wpdb->prepare(
                    "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                      WHERE link_id = %d AND meta_key = 'head_scripts' LIMIT 1",
                    $linkId
                )
            );
            if ($perLink !== '') {
                $headScripts .= stripslashes($perLink);
            }
        }

        // Issue the response.
        switch ($type) {
            case '301':
            case '302':
            case '307':
                $status = (int) $type;
                $scheme = (string) parse_url($target, PHP_URL_SCHEME);
                if (function_exists('wp_redirect') && in_array($scheme, ['http', 'https'], true)) {
                    wp_redirect($target, $status, 'Pretty Links');
                } else {
                    // Non-http(s) schemes (tel:, sms:, mailto:, etc.) force 302
                    // regardless of the configured 301/307 — permanent caching
                    // is inappropriate for these, and wp_redirect would mangle them.
                    // Strip CRLF at the emit boundary as defense-in-depth, matching
                    // Engine.php's raw header() path.
                    header('Location: ' . str_replace(["\r", "\n"], '', $target), true, 302);
                }
                break;

            case 'metarefresh':
                $delay = (int) $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                          WHERE link_id = %d AND meta_key = 'delay' LIMIT 1",
                        $linkId
                    )
                );
                header('Content-Type: text/html; charset=utf-8', true);
                echo '<!DOCTYPE html><html><head>'
                    . '<meta charset="utf-8"><meta name="robots" content="noindex">'
                    . '<title>' . esc_html((string) ($link['name'] ?? '')) . '</title>'
                    . '<meta http-equiv="refresh" content="' . $delay . '; URL=' . esc_url($target) . '">'
                    . $headScripts
                    . '</head><body></body></html>';
                break;

            case 'javascript':
                $delay = (int) $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                          WHERE link_id = %d AND meta_key = 'delay' LIMIT 1",
                        $linkId
                    )
                );
                $delayMs = (int) $delay * 1000;
                $urlJson = (string) wp_json_encode(esc_url_raw($target));
                header('Content-Type: text/html; charset=utf-8', true);
                echo '<!DOCTYPE html><html><head>'
                    . '<meta charset="utf-8"><meta name="robots" content="noindex">'
                    . '<title>' . esc_html((string) ($link['name'] ?? '')) . '</title>'
                    . '<script>setTimeout(function(){window.location=' . $urlJson . ';},' . $delayMs . ');</script>'
                    . $headScripts
                    . '</head><body></body></html>';
                break;

            case 'cloak':
                header('Content-Type: text/html; charset=utf-8', true);
                echo '<!DOCTYPE html><html><head>'
                    . '<meta charset="utf-8"><meta name="robots" content="noindex">'
                    . '<title>' . esc_html((string) ($link['name'] ?? '')) . '</title>'
                    . '<style>html,body,iframe{margin:0;padding:0;width:100%;height:100%;border:0;overflow:hidden}</style>'
                    . $headScripts
                    . '</head><body>'
                    . '<iframe src="' . esc_url($target) . '" frameborder="0" allowfullscreen></iframe>'
                    . '</body></html>';
                break;

            case 'pixel':
                // v3 parity: empty text/html body, 200 OK. Click is still
                // tracked in the shutdown function that was scheduled above.
                http_response_code(200);
                header('Content-Type: text/html', true);
                break;

            default:
                return;
        }

        if (function_exists('fastcgi_finish_request')) {
            fastcgi_finish_request();
        } elseif (function_exists('litespeed_finish_request')) {
            litespeed_finish_request();
        }
        exit;
    }

    // -------------------------------------------------------------------------
    // Click tracking — runs post-response in the shutdown function.
    // -------------------------------------------------------------------------

    /**
     * @param array<string,mixed> $opts
     */
    public static function writeClick(
        wpdb $wpdb,
        int $linkId,
        string $target,
        string $mode,
        array $opts,
        string $vuid,
        int $firstClick
    ): void {
        try {
            $ip = self::resolveIp($opts);

            $excluded = (string) ($opts['prli_exclude_ips'] ?? '');
            if ($excluded !== '' && self::ipMatchesAny($ip, $excluded)) {
                return;
            }

            $ua        = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
            $isBot     = self::isBot($ua, $opts);
            $allowList = (string) ($opts['whitelist_ips'] ?? '');
            $isAllowed = $allowList !== '' && self::ipMatchesAny($ip, $allowList);

            if ($isBot && !$isAllowed) {
                return;
            }

            if ($mode === 'count') {
                // Simple mode writes ONLY to prli_link_metas. No prli_clicks
                // row, no bump on prli_links.clicks/uniques — those stores
                // are for Normal/Extended and stay at zero in Simple mode.
                // Boundary crossings between Simple and Normal/Extended wipe
                // both stores (see ClickDataWiper) so they never mix.
                $metaTable = $wpdb->prefix . 'prli_link_metas';
                $hit       = (int) $wpdb->query(
                    $wpdb->prepare(
                        "UPDATE {$metaTable}
                            SET meta_value = CAST(CAST(meta_value AS UNSIGNED) + 1 AS CHAR)
                          WHERE link_id = %d AND meta_key = 'static-clicks'",
                        $linkId
                    )
                );
                if (!$hit) {
                    $wpdb->insert($metaTable, [
                        'link_id'    => $linkId,
                        'meta_key'   => 'static-clicks',
                        'meta_value' => '1',
                        'created_at' => current_time('mysql', true),
                    ]);
                }
                if ($firstClick === 1) {
                    $hit = (int) $wpdb->query(
                        $wpdb->prepare(
                            "UPDATE {$metaTable}
                                SET meta_value = CAST(CAST(meta_value AS UNSIGNED) + 1 AS CHAR)
                              WHERE link_id = %d AND meta_key = 'static-uniques'",
                            $linkId
                        )
                    );
                    if (!$hit) {
                        $wpdb->insert($metaTable, [
                            'link_id'    => $linkId,
                            'meta_key'   => 'static-uniques',
                            'meta_value' => '1',
                            'created_at' => current_time('mysql', true),
                        ]);
                    }
                }
                return;
            }

            // normal / extended — extended adds regex UA parsing.
            $device = ($mode === 'extended') ? self::detectDevice($ua) : [];

            // Extended heuristic: no btype after parse = bot (mirrors ClickWriter logic).
            if ($mode === 'extended' && ($device['btype'] ?? '') === '' && !$isAllowed) {
                return;
            }

            $userId = function_exists('get_current_user_id') ? get_current_user_id() : 0;

            $wpdb->insert(
                $wpdb->prefix . 'prli_clicks',
                [
                    'link_id'     => $linkId,
                    'created_at'  => current_time('mysql', true),
                    'ip'          => $ip,
                    'vuid'        => $vuid,
                    'host'        => $mode === 'extended' ? (string) gethostbyaddr($ip) : '',
                    'uri'         => isset($_SERVER['REQUEST_URI'])  ? (string) $_SERVER['REQUEST_URI']  : '',
                    'referer'     => isset($_SERVER['HTTP_REFERER']) ? (string) $_SERVER['HTTP_REFERER'] : '',
                    'country'     => self::country($ip, $opts, $wpdb),
                    'browser'     => $ua,
                    'btype'       => (string) ($device['btype']    ?? ''),
                    'bversion'    => (string) ($device['bversion'] ?? ''),
                    'os'          => (string) ($device['os']       ?? ''),
                    'device_type' => (string) ($device['type']     ?? ''),
                    'user_id'     => $userId > 0 ? $userId : null,
                    'robot'       => $isBot ? 1 : 0,
                    'first_click' => $firstClick,
                ],
                ['%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d']
            );

            // `clicks` bumps every click; `uniques` only on first_click=1.
            // Matches v3 Links-list semantics and the Lite ClickWriter path.
            $sql = $firstClick === 1
                ? "UPDATE {$wpdb->prefix}prli_links SET clicks = clicks + 1, uniques = uniques + 1 WHERE id = %d"
                : "UPDATE {$wpdb->prefix}prli_links SET clicks = clicks + 1 WHERE id = %d";
            $wpdb->query($wpdb->prepare($sql, $linkId));
        } catch (\Throwable $e) {
            // Never fatal — a missed click write must never affect the redirect.
            // Always log: runs post-response so silent failures are invisible.
            error_log(sprintf(
                '[PrettyLinks] MU click write failed: %s in %s:%d',
                $e->getMessage(),
                $e->getFile(),
                $e->getLine()
            ));
        }
    }

    // -------------------------------------------------------------------------
    // Helpers — pure PHP, no plugin dependencies.
    // -------------------------------------------------------------------------

    private static function extractSlug(string $requestUri): ?string
    {
        $path = (string) parse_url($requestUri, PHP_URL_PATH);
        if ($path === '' || $path === '/') {
            return null;
        }
        $slug = trim(rawurldecode($path), '/');
        if ($slug === '') {
            return null;
        }
        // Strip the WordPress subdirectory base path (the path component of
        // the home URL) so a request to /blog/<slug> resolves to <slug> on a
        // site installed at /blog. No-op on root installs. Mirrors
        // Engine::stripHomePath(). Uses get_option('home') rather than
        // home_url() by design: this stub runs before regular plugins load, so
        // home_url() filters would not fire here anyway, and get_option()
        // gives the raw stored base. If a plugin filters the home_url PATH,
        // the fast path may not strip while the full Engine does — but that
        // only makes turbo mode fall through to the full WP boot (which
        // resolves correctly); it never produces a wrong redirect.
        $homePath = trim(rawurldecode((string) parse_url((string) get_option('home'), PHP_URL_PATH)), '/');
        if ($homePath !== '') {
            if ($slug === $homePath) {
                return null;
            }
            $prefix = $homePath . '/';
            if (strpos($slug, $prefix) === 0) {
                $slug = substr($slug, strlen($prefix));
            }
        }
        // Almost-pretty permalinks route requests through /index.php/<slug>.
        // Strip the prefix so the slug matches what's stored in the DB.
        if (strpos($slug, 'index.php/') === 0) {
            $slug = substr($slug, strlen('index.php/'));
        }
        // Character set mirrors Engine::SLUG_CHAR_CLASS (the full RFC 3986
        // path-segment set plus a literal space for v3 slugs — v3 parity) —
        // hardcoded because this stub has no access to the plugin autoloader.
        // Keep in sync.
        if ($slug === '' || !preg_match('#^[A-Za-z0-9_\-/.~!$&\'()*+,;=:@\x20]+$#', $slug)) {
            return null;
        }
        return $slug;
    }

    /**
     * Strip the alternate-domain path prefix from a slug, or null when there
     * is nothing to strip. Mirrors Engine::stripAltDomainPath(), but reads the
     * raw `prlipro_options` blob rather than the `prli_pretty_link_base`
     * filter: this stub runs before regular plugins load, so the Pro filter
     * that publishes the alternate domain hasn't been registered yet. The raw
     * option is the same source that filter reads from. See issue #752.
     */
    private static function stripAltDomainPath(string $slug): ?string
    {
        $opts = (array) get_option('prlipro_options', []);
        if (empty($opts['use_prettylink_url']) || empty($opts['prettylink_url'])) {
            return null;
        }
        $altPath  = trim(rawurldecode((string) parse_url((string) $opts['prettylink_url'], PHP_URL_PATH)), '/');
        $homePath = trim(rawurldecode((string) parse_url((string) get_option('home'), PHP_URL_PATH)), '/');
        if ($altPath === '' || $altPath === $homePath) {
            return null;
        }
        // Reduce to the alt-domain path beyond the already-stripped home path.
        if ($homePath !== '' && strpos($altPath . '/', $homePath . '/') === 0) {
            $altPath = trim(substr($altPath, strlen($homePath)), '/');
        }
        if ($altPath === '') {
            return null;
        }
        $needle = $altPath . '/';
        if (strncmp($slug, $needle, strlen($needle)) !== 0) {
            return null;
        }
        $stripped = substr($slug, strlen($needle));
        return $stripped !== '' ? $stripped : null;
    }

    private static function resolveVisitorId(): string
    {
        if (!empty($_COOKIE['prli_visitor'])) {
            return sanitize_key((string) $_COOKIE['prli_visitor']);
        }
        $uid = uniqid();
        setcookie('prli_visitor', $uid, time() + YEAR_IN_SECONDS, '/', '', is_ssl(), true);
        $_COOKIE['prli_visitor'] = $uid;
        return $uid;
    }

    private static function resolveFirstClick(int $linkId): int
    {
        $name = 'prli_click_' . $linkId;
        if (isset($_COOKIE[$name])) {
            return 0;
        }
        setcookie($name, '1', time() + 30 * DAY_IN_SECONDS, '/', '', is_ssl(), true);
        $_COOKIE[$name] = '1';
        return 1;
    }

    private static function resolveDedup(int $linkId): bool
    {
        $name = 'prli_dedup_' . $linkId;
        if (isset($_COOKIE[$name])) {
            return true;
        }
        setcookie($name, '1', time() + 10, '/', '', is_ssl(), true);
        $_COOKIE[$name] = '1';
        return false;
    }

    /**
     * @param array<string,mixed> $opts
     */
    private static function resolveIp(array $opts): string
    {
        // Inlined copy of Geo::rawIp() — keep in sync (this file runs before
        // the plugin loads, so it cannot call into plugin classes).
        $ip = '';
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
            if (empty($_SERVER[$key])) {
                continue;
            }
            foreach (explode(',', (string) $_SERVER[$key]) as $part) {
                $part = trim($part);
                if ($part !== '') {
                    $ip = self::unwrapMappedIpv4($part);
                    break 2;
                }
            }
        }
        if ($ip !== '' && !empty($opts['anonymize_ips'])) {
            $base = self::blockBase($ip);
            if ($base !== '') {
                $ip = $base;
            }
        }
        // v3-compat override hook, post-anonymization like ClickWriter.
        // NOTE: the fast path runs before regular plugins load, so only
        // callbacks registered by other mu-plugins can fire here.
        if (function_exists('apply_filters')) {
            $ip = (string) apply_filters('pl_get_current_client_ip', $ip);
        }
        return $ip;
    }

    /**
     * Inlined copy of Geo::unwrapMappedIpv4() — keep in sync. Normalizes
     * IPv4-mapped IPv6 (::ffff:8.8.8.8) to plain IPv4 so anonymization
     * doesn't collapse mapped visitors to "::".
     */
    private static function unwrapMappedIpv4(string $ip): string
    {
        if (strpos($ip, ':') === false) {
            return $ip;
        }
        $packed = @inet_pton($ip);
        if (
            $packed !== false
            && strlen($packed) === 16
            && substr($packed, 0, 12) === str_repeat("\0", 10) . "\xff\xff"
        ) {
            return (string) inet_ntop(substr($packed, 12));
        }
        return $ip;
    }

    /**
     * The IP's /24 (IPv4) or /48 (IPv6) network base — same zeroing as the
     * plugin's IpUtil::anonymize(). Inlined copy — keep in sync.
     */
    private static function blockBase(string $ip): string
    {
        // Logic kept in sync with IpUtil::anonymize() (see SYNC CHECKLIST)
        // so the fast path and the plugin zero the same host bits. Statements
        // match; only comments and phpcs directives may differ.
        if ($ip === '') {
            return '';
        }
        $packed = @inet_pton($ip);
        if ($packed === false) {
            return '';
        }
        $len = strlen($packed);
        if ($len === 4) {
            $packed[3] = "\0";
        } elseif ($len === 16) {
            for ($i = 6; $i < 16; $i++) {
                $packed[$i] = "\0";
            }
        } else {
            return '';
        }
        return (string) inet_ntop($packed);
    }

    /**
     * v3-compat wrapper: applies the `plp_locate_by_ip` override filter to
     * the detected country, mirroring Geo::country(). NOTE: the fast path
     * runs before regular plugins load, so only callbacks registered by
     * other mu-plugins can fire here.
     *
     * @param array<string,mixed> $opts
     */
    private static function country(string $ip, array $opts, wpdb $wpdb): string
    {
        $country = self::lookupCountry($ip, $opts, $wpdb);
        if (function_exists('apply_filters')) {
            $lockey = $ip !== '' ? 'pl_locate_by_ip_' . md5($ip . 'caseproof') : '';
            $loc    = apply_filters('plp_locate_by_ip', (object) ['country' => $country], $lockey, null);
            if (is_object($loc) && isset($loc->country)) {
                $country = strtoupper((string) $loc->country);
            }
        }
        // XX pseudo-code is never a country, from any source.
        return $country === 'XX' ? '' : $country;
    }

    /**
     * @param array<string,mixed> $opts
     */
    private static function lookupCountry(string $ip, array $opts, wpdb $wpdb): string
    {
        // Inlined copy of Geo::country()'s header ladder — keep in sync.
        // XX is Cloudflare's unknown-country pseudo-code; fall through to the
        // API instead of treating it as a real country.
        foreach (['HTTP_CF_IPCOUNTRY', 'HTTP_X_VERCEL_IP_COUNTRY', 'HTTP_X_GEOIP_COUNTRY', 'HTTP_X_APPENGINE_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'HTTP_GEOIP_COUNTRY_CODE'] as $key) {
            if (!empty($_SERVER[$key])) {
                $code = strtoupper(substr((string) $_SERVER[$key], 0, 2));
                if (preg_match('/^[A-Z]{2}$/', $code) && $code !== 'XX') {
                    return $code;
                }
            }
        }

        if ($ip === '') {
            return '';
        }

        // Loopback/private/reserved ranges are not routable, so no lookup will
        // ever resolve them — don't cache-miss or queue on them.
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return '';
        }

        // Two-tier cache read against prli_geo_cache with raw SQL (WP isn't
        // loaded here, so no object cache and no get_transient). Inlined copy
        // of Geo::readCache()'s scheme — keep in sync: block key (/24 v4, /48
        // v6) when the API's network covers the whole block; per-IP key when
        // the block straddles networks, unless anonymize_ips forbids full-IP
        // keys.
        $anonymize = !empty($opts['anonymize_ips']);
        $base      = self::blockBase($ip);
        $blockKey  = 'pl_locate_by_ip_' . md5(($base !== '' ? $base : $ip) . 'caseproof');
        $ipKey     = 'pl_locate_by_ip_' . md5($ip . 'caseproof');

        $anonKey = 'pl_locate_by_ip_anon_' . md5(($base !== '' ? $base : $ip) . 'caseproof');

        // Block key holds only network-verified entries; anonymize-mode
        // straddle guesses live under $anonKey so they never leak into
        // per-IP mode after the option is toggled off.
        $cached = self::readGeoCache($blockKey, $wpdb);
        if ($cached === null) {
            $tier2  = $anonymize ? $anonKey : $ipKey;
            $cached = $tier2 !== $blockKey ? self::readGeoCache($tier2, $wpdb) : null;
        }
        if ($cached !== null) {
            return $cached;
        }

        // If the cache read itself errored, the geo tables aren't there yet —
        // this dispatcher exits before the migrator runs, so a site whose code
        // was updated out-of-band can serve a few redirects before
        // install_geo_tables completes. Bail rather than fire a second query we
        // already know will fail. No table-existence probe: that would cost a
        // query on every redirect forever to cover a window that normally lasts
        // one request, since whatever performed the update (wp-admin, WP-CLI,
        // auto-update cron) is itself a full WordPress load that migrates.
        if ($wpdb->last_error !== '') {
            return '';
        }

        // Deliberately no HTTP call on this path. The turbo dispatcher only
        // handles plain redirects — links with geo targeting bypass it entirely
        // and resolve through Engine.php — so the country is wanted purely for
        // the click row. Queue it for GeoBackfillJob, which resolves it on the
        // Resque worker and stamps the row afterwards.
        self::queueGeoLookup($ip, $wpdb);

        return '';
    }

    /**
     * Read prli_geo_cache directly. Inlined counterpart to GeoStore::get().
     *
     * @return string|null The cached code ('' for a cached "unknown"), or null
     *                     on a miss or an expired entry.
     */
    private static function readGeoCache(string $cacheKey, wpdb $wpdb): ?string
    {
        $table = $wpdb->prefix . 'prli_geo_cache';

        $country = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT country FROM {$table}
                  WHERE cache_key = %s
                    AND (expires_at IS NULL OR expires_at > %s)
                  LIMIT 1",
                $cacheKey,
                gmdate('Y-m-d H:i:s')
            )
        );

        return $country === null ? null : (string) $country;
    }

    /**
     * Queue an IP for background geolocation. Inlined counterpart to
     * GeoStore::queue() — INSERT IGNORE against the unique `ip` column, so
     * repeat hits from one visitor collapse into a single row.
     */
    private static function queueGeoLookup(string $ip, wpdb $wpdb): void
    {
        $table = $wpdb->prefix . 'prli_geo_pending';

        $wpdb->query(
            $wpdb->prepare(
                "INSERT IGNORE INTO {$table} (ip, created_at) VALUES (%s, %s)",
                $ip,
                gmdate('Y-m-d H:i:s')
            )
        );
    }


    /**
     * @param array<string,mixed> $opts
     */
    private static function isBot(string $ua, array $opts): bool
    {
        $enabled = true;
        if (array_key_exists('enable_bot_filter', $opts)) {
            $enabled = (bool) $opts['enable_bot_filter'];
        } elseif (array_key_exists('filter_robots', $opts)) {
            $enabled = (bool) $opts['filter_robots'];
        }
        if (!$enabled) {
            return false;
        }

        if ($ua === '') {
            return true;
        }

        if (preg_match(
            '~bot|crawl|spider|slurp|mediapartners|facebot|facebookexternalhit|'
            . 'googlebot|googleother|bingbot|duckduckbot|yandex|baiduspider|sogou|'
            . 'ahrefs|semrush|mj12bot|dotbot|rogerbot|applebot|applebot-extended|'
            . 'linkedinbot|twitterbot|discordbot|skypeuripreview|whatsapp|telegrambot|'
            . 'pingdom|uptimerobot|gtmetrix|headlesschrome|phantomjs|puppeteer|playwright|'
            . 'curl|wget|python-requests|node-fetch|axios|scrapy|'
            . 'gptbot|chatgpt-user|claudebot|claude-web|ccbot|anthropic-ai|perplexitybot|'
            . 'bytespider|amazonbot|meta-externalagent|cohere-ai|imagesiftbot|diffbot~i',
            $ua
        )) {
            return true;
        }

        $patterns = (string) ($opts['bot_patterns'] ?? '');
        if ($patterns !== '') {
            foreach (array_map('trim', explode("\n", $patterns)) as $p) {
                if ($p !== '' && strlen($p) >= 3 && !ctype_digit($p) && stripos($ua, $p) !== false) {
                    return true;
                }
            }
        }

        $cfScore = $_SERVER['HTTP_CF_BOT_SCORE'] ?? null;
        if ($cfScore !== null && is_numeric($cfScore) && (int) $cfScore < 30) {
            return true;
        }
        if (!empty($_SERVER['HTTP_CF_VERIFIED_BOT'])
            && filter_var((string) $_SERVER['HTTP_CF_VERIFIED_BOT'], FILTER_VALIDATE_BOOLEAN)
        ) {
            return true;
        }

        if (empty($_SERVER['HTTP_ACCEPT'])) {
            return true;
        }

        $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper((string) $_SERVER['REQUEST_METHOD']) : '';
        if ($method !== '' && $method !== 'GET') {
            return true;
        }

        if (!empty($_SERVER['HTTP_SEC_CH_UA_PLATFORM'])) {
            // Some server stacks deliver the header as `\"Windows\"` instead of
            // `"Windows"` — strip backslashes too so real browsers aren't
            // misclassified as bots when the quotes come through escaped.
            $ch = trim((string) $_SERVER['HTTP_SEC_CH_UA_PLATFORM'], "\\\"' \t");
            if ($ch !== '' && strcasecmp($ch, 'Unknown') !== 0) {
                $uap = self::uaPlatform($ua);
                if ($uap !== '' && strcasecmp($ch, $uap) !== 0) {
                    return true;
                }
            }
        }

        return false;
    }

    private static function uaPlatform(string $ua): string
    {
        if (stripos($ua, 'Android') !== false) {
            return 'Android';
        }
        if (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false || stripos($ua, 'iPod') !== false) {
            return 'iOS';
        }
        if (stripos($ua, 'Macintosh') !== false || stripos($ua, 'Mac OS X') !== false) {
            return 'macOS';
        }
        if (stripos($ua, 'Windows') !== false) {
            return 'Windows';
        }
        if (stripos($ua, 'CrOS') !== false) {
            return 'Chrome OS';
        }
        if (stripos($ua, 'Linux') !== false) {
            return 'Linux';
        }
        return '';
    }

    /**
     * Inline of DeviceDetector::detect() — same regex logic, no class load.
     *
     * @return array{type:string,btype:string,bversion:string,os:string}
     */
    private static function detectDevice(string $ua): array
    {
        if ($ua === '') {
            return ['type' => '', 'btype' => '', 'bversion' => '', 'os' => ''];
        }

        $type = preg_match('~mobi|android.*mobile|iphone|ipod~i', $ua)
            ? 'mobile'
            : (preg_match('~ipad|tablet|android(?!.*mobile)~i', $ua) ? 'tablet' : 'desktop');

        $btype    = '';
        $bversion = '';
        if (preg_match('~edg/([\d.]+)~i', $ua, $m)) {
            $btype    = 'Edge';
            $bversion = $m[1];
        } elseif (preg_match('~(?:opr|opera)/([\d.]+)~i', $ua, $m)) {
            $btype    = 'Opera';
            $bversion = $m[1];
        } elseif (preg_match('~chrome/([\d.]+)~i', $ua, $m) && !preg_match('~chromium/~i', $ua)) {
            $btype    = 'Chrome';
            $bversion = $m[1];
        } elseif (preg_match('~firefox/([\d.]+)~i', $ua, $m)) {
            $btype    = 'Firefox';
            $bversion = $m[1];
        } elseif (preg_match('~version/([\d.]+).*safari~i', $ua, $m)) {
            $btype    = 'Safari';
            $bversion = $m[1];
        }

        $os = '';
        if (preg_match('~windows~i', $ua)) {
            $os = 'Windows';
        } elseif (preg_match('~mac os x~i', $ua)) {
            $os = 'macOS';
        } elseif (preg_match('~iphone|ipad|ipod~i', $ua)) {
            $os = 'iOS';
        } elseif (preg_match('~android~i', $ua)) {
            $os = 'Android';
        } elseif (preg_match('~linux~i', $ua)) {
            $os = 'Linux';
        }

        return ['type' => $type, 'btype' => $btype, 'bversion' => $bversion, 'os' => $os];
    }

    private static function ipMatchesAny(string $ip, string $list): bool
    {
        foreach (array_map('trim', explode("\n", str_replace(',', "\n", $list))) as $entry) {
            if ($entry !== '' && self::ipMatches($ip, $entry)) {
                return true;
            }
        }
        return false;
    }

    private static function ipMatches(string $ip, string $pattern): bool
    {
        if (strpos($pattern, '*') !== false) {
            return (bool) preg_match(
                '~^' . str_replace(['.', '*'], ['\\.', '[0-9]+'], $pattern) . '$~',
                $ip
            );
        }
        if (strpos($pattern, '/') !== false) {
            return self::matchCidr($ip, $pattern);
        }
        return $ip === $pattern;
    }

    private static function matchCidr(string $ip, string $cidr): bool
    {
        $parts = explode('/', $cidr, 2);
        if (count($parts) !== 2 || !ctype_digit($parts[1])) {
            return false;
        }
        $subnet    = $parts[0];
        $bits      = (int) $parts[1];
        $ipBin     = @inet_pton($ip);
        $subnetBin = @inet_pton($subnet);
        if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
            return false;
        }
        $total = strlen($ipBin) * 8;
        if ($bits < 0 || $bits > $total) {
            return false;
        }
        if ($bits === 0) {
            return true;
        }
        $whole    = intdiv($bits, 8);
        $leftover = $bits % 8;
        if ($whole > 0 && substr($ipBin, 0, $whole) !== substr($subnetBin, 0, $whole)) {
            return false;
        }
        if ($leftover > 0) {
            $mask = (0xff << (8 - $leftover)) & 0xff;
            return (ord($ipBin[$whole]) & $mask) === (ord($subnetBin[$whole]) & $mask);
        }
        return true;
    }
}

try {
    PrliMuDispatch::run();
} catch (\Throwable $e) {
    // A dispatch crash must never white-screen the site. Flag the failure so
    // the main plugin can self-heal on init — remove this file and turn the
    // mu_dispatch setting off — then fall through to the normal request path.
    if (function_exists('update_option')) {
        update_option(
            'prli_mu_dispatch_error',
            [
                'message' => $e->getMessage(),
                'file'    => $e->getFile(),
                'line'    => $e->getLine(),
                'time'    => time(),
            ],
            false
        );
    }
    if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
        error_log('Pretty Links MU-plugin error: ' . $e->getMessage() . ' at ' . $e->getFile() . ':' . $e->getLine());
    }
}
