# metasync/2.7.1/otto/otto_pixel.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.7.1. 3,088 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.7.1/code/otto/otto_pixel.php
- Raw: https://pluginprobe.com/plugins/metasync/2.7.1/raw/otto/otto_pixel.php
- Modified: 2026-09-25T22:31:34+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/metasync/2.7.1/code/otto/otto_pixel.php#L10-L20`.

```php
<?php 
/**
 * This handles the OTTO SSR
 */
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY')) {
    define('METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY', 'metasync_otto_excluded_urls_manual');
}
if (!defined('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL')) {
    define('METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL', 60);
}

# uses the simple html dom library
use simplehtmldom\HtmlDocument;

# include the otto class file
#
# The vendor tree is generated content, unlike the plugin files beside it: an
# update that copies the plugin directory piece by piece can briefly leave this
# file beside an otto/vendor that is missing, half-written or holding a stale
# classmap. A bare require on a missing autoloader is a fatal during plugin
# bootstrap — the whole site, not just OTTO. Load it only when it is actually
# readable, and catch Throwable while doing so: the autoloader is a trampoline
# of bare requires into otto/vendor/composer/, so a tree that is present but
# still mid-copy (or a truncated autoload.php, which parses only to a
# ParseError) would otherwise fatal here too. Either way the parser class
# stays unloaded, and the DOM-parser guard in metasync_start_otto() degrades
# the request to OTTO-less rendering instead of fataling.
$metasync_otto_vendor_autoload = plugin_dir_path( __FILE__ ) . '/vendor/autoload.php';
if ( is_readable( $metasync_otto_vendor_autoload ) ) {
	try {
		require_once $metasync_otto_vendor_autoload;
	} catch ( \Throwable $e ) {
		# Deliberately swallowed: any exception raised while loading generated
		# autoloader code means this request runs without the DOM parser. The
		# guard below turns that into one OTTO-less request rather than a
		# bootstrap fatal on the whole site.
	}
}
require_once plugin_dir_path( __FILE__ ) . '/Otto_html_class.php';
require_once plugin_dir_path( __FILE__ ) . '/Otto_pixel_class.php';
require_once plugin_dir_path( __FILE__ ) . '/metasync-otto-seo-functions.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-transient-cache.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-job-status.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-render-strategy.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-config.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-detector.php';
require_once plugin_dir_path( __FILE__ ) . '/class-metasync-otto-bot-statistics-database.php';

/**
 * Is $method actually available on $class in this process right now?
 *
 * A partially updated install can leave a newer copy of one plugin file beside
 * an older copy of another — stale opcache bytecode for a single file is enough.
 * class_exists() is satisfied by the older copy, and calling a method it does
 * not declare is a fatal. Everything in this file runs on the front end, so that
 * fatal is a white screen on every page view rather than a degraded feature.
 *
 * Defined here rather than pulled from a shared utility class on purpose: a
 * helper loaded from another file could itself be the stale one.
 *
 * $class and $method are parameters rather than literals at the call site so the
 * check survives static analysis, which would otherwise narrow a literal
 * method_exists() on a known class to a constant true — the skew this guards
 * against exists only at runtime.
 *
 * @param string $class  Class about to be called.
 * @param string $method Method about to be called on it.
 * @return bool
 */
function metasync_otto_class_provides($class, $method) {
    return class_exists($class) && method_exists($class, $method);
}

# OPTIMIZED: get the metasync options (cached in static class)
$metasync_options = Metasync_Otto_Config::get_options();

# OTTO SSR is always enabled by default
$otto_enabled = true;

# add tag to wp head
add_action('wp_head', function(){
    # load globals
    global $metasync_options, $otto_enabled;

    # OTTO SSR is always enabled
    $string_enabled = 'true';

    # OPTIMIZED: check uuid set using cached config
    if(!Metasync_Otto_Config::is_otto_enabled()){
        return;
    }

    # Performance optimization: Add DNS prefetch and preconnect for OTTO API
    # This improves connection speed by resolving DNS and establishing connections early
    # Use endpoint manager to get the correct domain
    $otto_domain = 'sa.searchatlas.com'; # default
    if (class_exists('Metasync_Endpoint_Manager')) {
        $otto_api_domain = Metasync_Endpoint_Manager::get_endpoint('OTTO_API_DOMAIN');
        $parsed = wp_parse_url($otto_api_domain);
        if (!empty($parsed['host'])) {
            $otto_domain = $parsed['host'];
        }
    }
    echo '<link rel="dns-prefetch" href="//' . esc_attr($otto_domain) . '">' . "\n";
    echo '<link rel="preconnect" href="https://' . esc_attr($otto_domain) . '" crossorigin>' . "\n";

    # adding the otto tag to pages
    $plugin_version = defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown';
    # OPTIMIZED: use cached uuid
    $otto_tag = '<meta name="otto" content="uuid='.esc_attr(Metasync_Otto_Config::get_otto_uuid()).'; type=wordpress; enabled='.esc_attr($string_enabled).'; version='.esc_attr($plugin_version).'">';

    # out the otto tag
    echo $otto_tag;
}, 1); # Priority 1 to output early in head

/**
 * Scheduling seam for the OTTO pipeline.
 *
 * wp_schedule_single_event() returns false on failure and the old pipeline
 * ignored it, so a broken cron table acknowledged URLs that were never
 * queued. Every schedule in this pipeline goes through this seam so the
 * return value is honored (and tests can observe scheduling).
 */
function metasync_otto_schedule_single_event($timestamp, $hook, $args = array()) {
    return wp_schedule_single_event($timestamp, $hook, $args) === true;
}

/**
 * Dedupe seam: does this URL already have live per-URL work in flight?
 *
 * Combines the durable status store with the raw cron table so a re-delivered
 * webhook cannot stack a second job behind a live one.
 */
function metasync_otto_job_already_scheduled($route) {
    if (class_exists('Metasync_Otto_Job_Status', false)
        && Metasync_Otto_Job_Status::has_active($route)) {
        return true;
    }
    return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
}

/**
 * Cron-table dedupe seam: does a per-URL cron event already exist for this URL?
 *
 * The pending-queue drainer must dedupe against the scheduler table only.
 * Every URL it takes off the durable queue carries a fresh webhook
 * accepted/batch_overflow ledger row; that row exists so a re-delivered
 * webhook cannot stack a second job behind live work, and consulting it
 * from the drainer — after take_pending() has already removed the URL
 * from the queue — silently dropped every overflow URL instead of
 * draining it. Only a real, still-pending cron event may veto here.
 */
function metasync_otto_job_event_pending($route) {
    return wp_next_scheduled('metasync_process_otto_crawl_url_job', array($route)) !== false;
}

/**
 * Start end point to handle requests on page updates
 * The Otto Crawler will call this end point once a page is updated
 **/

# function to register the route
function metasync_otto_crawl_notify($request){

    # get request data params
    $data = $request->get_json_params();

    # fields
    $fields = ['domain', 'urls'];

    # validate the json request
    foreach ($fields as $key => $value) {

        # if the value is empty stop tehre
        if(empty($data[$value])){

            # Handle the POST request
            return new WP_REST_Response(array(
                'success' => false,
                'message' => 'Invalid Field : '. $value,
            ), 400);
        }

    }

    # load otto pixel
    $otto_pixel = new Metasync_otto_pixel(false);

    # save the otto data first (cheap local DB write — kept synchronous)
    $otto_pixel->save_crawl_data($data);

    # Defer the expensive per-URL work (OTTO API fetch, post meta sync,
    # per-URL cache purge) to background jobs. The webhook caller enforces a
    # strict response-time budget, so the response acknowledges SCHEDULING,
    # never execution. The per-URL status store records what actually
    # happened to each URL once the jobs run.
    #
    # Cap the number of per-URL cron jobs scheduled per webhook batch so a
    # large crawl cannot overwhelm WP-Cron on shared hosts. URLs past the cap
    # are queued durably and worked off by a self-rescheduling drainer job —
    # never silently dropped.
    $max_jobs_per_batch = defined('METASYNC_MAX_JOBS_PER_BATCH') ? METASYNC_MAX_JOBS_PER_BATCH : 25;
    $now = time();
    $routes_to_process = array();
    $accepted = 0;
    $deferred = 0;
    $rejected = 0;
    $scheduled_this_batch = 0;

    foreach($data['urls'] AS $key => $url){
        # prepare the route
        $route = $data['domain'] . $url;

        # validate the route
        $route = rtrim($route, '/');

        # Resolve redirect table: use final destination URL before excluded/404 checks and OTTO processing
        $route = metasync_otto_resolve_redirect_to_final_url($route);

        # OTTO has confirmed this URL is crawlable. Remove any auto-exclusion that was
        # previously set (e.g. because url_to_postid() returned 0 for a custom post type
        # and metasync_otto_is_url_available() incorrectly treated it as a 404).
        # Manual exclusions (auto_excluded = 0) are left untouched.
        metasync_otto_remove_auto_exclusion($route);

        # Skip manually excluded URLs - don't queue OTTO processing for them
        if (metasync_is_otto_url_excluded($route)) {
            continue;
        }

        # Every valid URL belongs to the batched cache-purge set even when its
        # per-URL meta-sync job is deduped or deferred: the crawler hit all of
        # them, so the caches for all of them must be refreshed.
        $routes_to_process[] = $route;

        # Duplicate delivery: an active job already owns this URL. Idempotent
        # no-op — not re-scheduled, not queued, not counted as new work.
        if (metasync_otto_job_already_scheduled($route)) {
            continue;
        }

        if ($scheduled_this_batch >= $max_jobs_per_batch) {
            # Batch overflow: queue durably for the drainer instead of dropping.
            if (Metasync_Otto_Job_Status::enqueue_pending($route)) {
                Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'batch_overflow');
                $deferred++;
            } else {
                $rejected++;
            }
            continue;
        }

        # Queue the per-URL processing for background execution. The handler
        # (metasync_handle_otto_crawl_url_job) performs transient warming,
        # SEO meta sync, and per-URL host cache purge.
        # Offset each event by $key seconds to avoid wp_schedule_single_event()
        # silently dropping duplicates when timestamp + hook + args collide.
        # A false return means cron refused the event — fall back to the
        # durable queue rather than acknowledging a URL nobody will process.
        if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, '');
            $accepted++;
            $scheduled_this_batch++;
        } elseif (Metasync_Otto_Job_Status::enqueue_pending($route)) {
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', 0, 'cron_refused');
            $deferred++;
        } else {
            $rejected++;
        }
    }

    # Schedule a single batch job for the edge CDN purge.
    # The batch waits for per-URL stragglers (retries) before purging, so a
    # retried URL's fresh content is what reaches the edge. Multi-URL purge
    # in a single API call keeps edge-provider request counts low.
    if (!empty($routes_to_process)) {
        $batch_time = $now + count($data['urls']) + 5; // run after all per-URL jobs
        metasync_otto_schedule_single_event($batch_time, 'metasync_process_otto_batch_cache_job', array($routes_to_process));
    }

    # Work off any overflow (or cron-refused URLs) from the durable queue.
    if ($deferred > 0) {
        metasync_otto_schedule_single_event($now + 120, 'metasync_otto_pending_drainer', array());
    }

    # Return 200 immediately so the webhook caller does not time out, with an
    # honest accounting of what happened to the batch.
    return new WP_REST_Response(array(
        'success' => true,
        'message' => 'OTTO crawl notification received',
        'accepted' => $accepted,
        'deferred' => $deferred,
        'rejected' => $rejected,
    ), 200);
}

/**
 * Background handler for a single OTTO crawl-notify URL.
 *
 * Pipeline per URL:
 *   1. Warm the OTTO transient cache — this is the ONE live API call the job
 *      makes; the warmed suggestions are handed to the meta sync so the
 *      endpoint is never fetched twice per URL.
 *   2. Sync SEO post meta (metasync_process_otto_seo_data) and branch on its
 *      outcome: only SUCCESS / NO_CHANGE reach the purge step.
 *   3. Clear the per-URL host cache and re-warm it.
 *
 * Retryable outcomes (API timeout / 5xx / 429 / lock contention / CPU deferral)
 * retry with exponential backoff up to METASYNC_OTTO_JOB_MAX_RETRIES; the
 * exhaustion event is recorded via metasync_record_failed_action() for
 * Site Health. Permanent outcomes (not connected, excluded/404 URL, API auth
 * or input error) never retry and never purge.
 *
 * @param string $route       Fully-qualified URL to process.
 * @param int    $retry_count Current retry attempt (0 = first run).
 */
function metasync_handle_otto_crawl_url_job($route = '', $retry_count = 0) {
    // WP-Cron events persist independently of plugin code. A stale or malformed
    // event may therefore invoke this callback without its required route.
    // Docblock type isn't enforced at runtime; a stale cron record can pass a non-string.
    // @phpstan-ignore-next-line function.alreadyNarrowedType
    if (!is_string($route) || $route === '') {
        error_log('MetaSync OTTO: skipping crawl-url job without a valid route.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        return;
    }

    $max_retries = defined('METASYNC_OTTO_JOB_MAX_RETRIES') ? METASYNC_OTTO_JOB_MAX_RETRIES : 3;

    # Shared retry/terminal bookkeeping so every failure path stays consistent.
    $schedule_retry = function ($reason) use ($route, $retry_count, $max_retries) {
        if ($retry_count < $max_retries) {
            # Exponential backoff (60s, 120s, 240s) plus jitter so a batch of
            # failures cannot stampede the API on the same tick.
            $delay = 60 * pow(2, $retry_count) + wp_rand(0, 15);
            $scheduled = metasync_otto_schedule_single_event(time() + $delay, 'metasync_process_otto_crawl_url_job', array($route, $retry_count + 1));
            if (!$scheduled && Metasync_Otto_Job_Status::enqueue_pending($route)) {
                Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'queue', $retry_count + 1, $reason . '/cron_refused');
            } else {
                Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_RETRYING, 'job', $retry_count + 1, $reason);
            }
            error_log('MetaSync OTTO: retrying crawl-url job for ' . $route . ' (attempt ' . ($retry_count + 1) . '/' . $max_retries . ') reason=' . $reason . ' in ' . $delay . 's'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        } else {
            metasync_record_failed_action('metasync_process_otto_crawl_url_job');
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'exhausted:' . $reason);
            error_log('MetaSync OTTO: background crawl-url job permanently failed for ' . $route . ' after ' . $max_retries . ' retries, last reason=' . $reason); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        }
    };

    try {
        # Not connected: retrying cannot help, and there is nothing to purge.
        $otto_uuid = Metasync_Otto_Config::get_otto_uuid();
        # A corrupted option row can hold a non-string UUID; the docblock
        # contract alone does not enforce that at runtime.
        # @phpstan-ignore-next-line function.alreadyNarrowedType
        if (!is_string($otto_uuid) || $otto_uuid === '') {
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'not_connected');
            return;
        }

        # Step 1: Warm OTTO transient cache (fetch fresh suggestions from OTTO
        # API into WP transient — the rate limiter, circuit breaker, and
        # request lock all live inside this call).
        $transient_cache = new Metasync_Otto_Transient_Cache($otto_uuid);
        $warmed = $transient_cache->warm_cache($route);
        if ($warmed === false) {
            # A 4xx refusal (bad key, revoked project) is permanent: retrying
            # cannot change the answer, so fail fast instead of burning the
            # attempt budget on warm calls that will keep being rejected.
            if ($transient_cache->last_failure_is_permanent()) {
                metasync_record_failed_action('metasync_process_otto_crawl_url_job');
                Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'job', $retry_count, 'permanent:warm_rejected');
                error_log('MetaSync OTTO: crawl-url job failed permanently for ' . $route . ' (warm request rejected by the API)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
                return;
            }
            # Transient API failure (timeout / 5xx / breaker open). No meta
            # write, no purge — straight to the retry path.
            $schedule_retry('warm_failed');
            return;
        }

        # Step 2: Write SEO post meta synchronously BEFORE the cache purge.
        # This ensures the DB is fully up-to-date when the host re-populates
        # the cache on the very next request. The warmed suggestions are
        # passed through so the sync makes NO second API call.
        # allow_defer=false: the crawl_url_job retry mechanism owns failure
        # handling here; letting the sync self-reschedule caused an
        # unbounded cron pile-up.
        # warm_cache() may hand back a non-array truthy on contract drift;
        # the sync treats that as "no prefetched suggestions".
        # @phpstan-ignore-next-line function.alreadyNarrowedType
        $prefetched = is_array($warmed) ? $warmed : null;
        $outcome = metasync_process_otto_seo_data($route, false, 0, $prefetched);

        if ($outcome === Metasync_Otto_Job_Status::OUTCOME_RETRYABLE
            || $outcome === Metasync_Otto_Job_Status::OUTCOME_DEFERRED) {
            $schedule_retry('outcome_' . $outcome);
            return;
        }

        if ($outcome !== Metasync_Otto_Job_Status::OUTCOME_SUCCESS
            && $outcome !== Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE) {
            # Permanent failure: no retry, no purge — caches must keep serving
            # the last good content.
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_FAILED, 'meta', $retry_count, 'permanent:' . $outcome);
            return;
        }

        # Step 3: meta is current (changed or confirmed unchanged) — clear the
        # per-URL host cache entry and re-warm it so OTTO-modified output is
        # what gets stored.
        $otto_pixel = new Metasync_otto_pixel(false);
        $otto_pixel->refresh_cache($route);
        Metasync_Cache_Purge::purge_single_url($route);
        Metasync_Cache_Purge::warm_urls(array($route));

        # A URL that completed on a retry missed its batch's edge purge (the
        # batch only waits so long). Late completers purge the edge themselves.
        if ($retry_count > 0) {
            Metasync_Edge_Cache_Purge::purge(array($route));
        }

        Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_COMPLETED, 'job', $retry_count, $outcome);

    } catch (Exception $e) {
        $schedule_retry('exception:' . $e->getMessage());
    }
}

/**
 * Batch handler for the edge CDN purge.
 *
 * Runs after all per-URL jobs have had time to complete. Per-URL host caches
 * are cleared and re-warmed by the individual jobs; this batched event only
 * handles the edge CDN purge, which benefits from multi-URL API calls
 * (Cloudflare, Fastly, Akamai, Sucuri, Sevella, etc.).
 *
 * URLs still retrying are stragglers: the batch waits (up to
 * METASYNC_OTTO_BATCH_MAX_WAITS reschedules 60s apart) so their freshly
 * synced content — not stale content — reaches the edge. URLs that complete
 * after the wait budget purge the edge themselves from the retry path.
 *
 * @param array $routes     List of fully-qualified URLs in this batch.
 * @param int   $wait_count How many times this batch has already waited.
 */
function metasync_handle_otto_batch_cache_job($routes = array(), $wait_count = 0) {
    // WP-Cron events persist independently of plugin code. A stale or malformed
    // event may therefore invoke this callback without its required route list.
    // Docblock type isn't enforced at runtime; a stale cron record can pass a non-array.
    // @phpstan-ignore-next-line function.alreadyNarrowedType
    if (!is_array($routes) || empty($routes)) {
        error_log('MetaSync OTTO: skipping batch cache job without valid routes.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        return;
    }

    $max_waits = defined('METASYNC_OTTO_BATCH_MAX_WAITS') ? METASYNC_OTTO_BATCH_MAX_WAITS : 3;
    // Docblock type isn't enforced at runtime; a stale cron record can pass a non-numeric.
    // @phpstan-ignore-next-line function.alreadyNarrowedType
    $wait_count = is_numeric($wait_count) ? (int) $wait_count : 0;

    # Split the batch: terminally-done URLs vs. URLs with work still in
    # flight. Unknown-state URLs (legacy events scheduled before the status
    # store existed, or deduped duplicates whose entry expired) count as done
    # — their per-URL job has either finished or never will, and both mean
    # the edge purge should not stall behind them.
    $ready = array();
    $stragglers = 0;
    foreach ($routes as $route) {
        $entry = Metasync_Otto_Job_Status::get($route);
        $state = is_array($entry) && isset($entry['state']) ? $entry['state'] : null;
        if ($state === Metasync_Otto_Job_Status::STATE_RETRYING
            || $state === Metasync_Otto_Job_Status::STATE_ACCEPTED) {
            $stragglers++;
        } else {
            # completed / failed / unknown: nothing left to wait for.
            $ready[] = $route;
        }
    }

    # Still live work in the batch and wait budget left: hold the edge purge.
    if ($stragglers > 0 && $wait_count < $max_waits) {
        metasync_otto_schedule_single_event(time() + 60, 'metasync_process_otto_batch_cache_job', array($routes, $wait_count + 1));
        return;
    }

    if (empty($ready)) {
        return;
    }

    try {
        # Purge edge CDN caches (Cloudflare, Fastly, Akamai, Sucuri, Sevalla, etc.)
        # Tag-based providers purge only the affected posts; full-flush providers fire once per batch.
        Metasync_Edge_Cache_Purge::purge($ready);
    } catch (Exception $e) {
        metasync_record_failed_action('metasync_process_otto_batch_cache_job');
        error_log('MetaSync OTTO: batch cache job failed for ' . count($ready) . ' URLs: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
    }
}

/**
 * Drainer: work off URLs that could not be scheduled inline (batch overflow
 * or a cron that refused the event) from the durable pending queue.
 *
 * Processes at most 25 URLs per run to stay friendly to WP-Cron, schedules
 * the same batched edge purge as the webhook path for the chunk it handled,
 * and reschedules itself while the queue is non-empty. URLs it takes off
 * the queue are scheduled unconditionally unless a real cron event already
 * exists for them — the webhook's overflow bookkeeping must never veto,
 * because by then the URL has already left the durable queue.
 */
function metasync_handle_otto_pending_drainer() {
    $chunk = Metasync_Otto_Job_Status::take_pending(25);
    if (empty($chunk)) {
        return;
    }

    $now = time();
    $scheduled = 0;
    foreach ($chunk as $key => $route) {
        # This run owns the chunk: take_pending() already removed these URLs
        # from the durable queue, so skipping here would drop them. Only a
        # real cron event vetoes — the webhook's overflow ledger row must
        # not (see metasync_otto_job_event_pending).
        if (metasync_otto_job_event_pending($route)) {
            continue;
        }
        if (metasync_otto_schedule_single_event($now + $key, 'metasync_process_otto_crawl_url_job', array($route))) {
            Metasync_Otto_Job_Status::record($route, Metasync_Otto_Job_Status::STATE_ACCEPTED, 'drain', 0, '');
            $scheduled++;
        } else {
            # Cron still refusing: put it back at the end of the queue.
            Metasync_Otto_Job_Status::enqueue_pending($route);
        }
    }

    if ($scheduled > 0) {
        metasync_otto_schedule_single_event($now + count($chunk) + 5, 'metasync_process_otto_batch_cache_job', array($chunk));
    }

    if (Metasync_Otto_Job_Status::pending_count() > 0) {
        metasync_otto_schedule_single_event($now + 60, 'metasync_otto_pending_drainer', array());
    }
}

// metasync_is_custom_or_lps_page() now lives in includes/metasync-helpers.php
// (loaded unconditionally before this file) so all SEO surfaces share one rule.

/**
 * Detect an Elementor editor / preview request that OTTO must never process.
 *
 * Elementor renders its editing canvas in a front-end <iframe> — a logged-in
 * GET request that is NOT is_admin(), so the admin/ajax/REST guards in
 * metasync_start_otto() do not catch it. If OTTO output-buffers and rewrites
 * that response (SimpleHtmlDom in Otto_html_class), the markup the editor's
 * JavaScript expects is altered and the canvas stays stuck on "loading" —
 * the editor never finishes initialising. The editor surface is for authoring
 * only; it is never crawled, so there is nothing for OTTO to optimise there.
 *
 * Locally this is masked because the OTTO API is unreachable (API_ERROR ⇒ no
 * suggestions ⇒ no rewrite); on a live site with real suggestions the rewrite
 * happens and the editor breaks. See the "Elementor editor fails to load while
 * the plugin is active" report.
 *
 * @return bool True when the current request is an Elementor editor/preview.
 */
function metasync_is_elementor_editor_request() {
    # The preview iframe loads the front end with ?elementor-preview=POST_ID.
    # This is the request OTTO would otherwise buffer and corrupt.
    if (isset($_GET['elementor-preview'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
        return true;
    }

    # Elementor editor / app entry points carried as an action on the front end.
    if (isset($_REQUEST['action']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
        && in_array($_REQUEST['action'], array('elementor', 'elementor_ajax'), true) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only editor request detection
    ) {
        return true;
    }

    # Authoritative check when Elementor is loaded: its own preview-mode flag.
    if (class_exists('\Elementor\Plugin')
        && isset(\Elementor\Plugin::$instance->preview)
        && is_object(\Elementor\Plugin::$instance->preview)
        && method_exists(\Elementor\Plugin::$instance->preview, 'is_preview_mode')
        && \Elementor\Plugin::$instance->preview->is_preview_mode()
    ) {
        return true;
    }

    return false;
}

function metasync_start_otto(){

    # PERFORMANCE FIX: Cache is now enabled for speed
    # Skip initial cache cleanup to preserve existing cache
    # Cache files are valuable for performance - only clear on OTTO updates
    # Periodic cache clearing can be configured in plugin settings if needed

    # exclude AJAX requests and WooCommerce transactional pages from OTTO SSR
    # SSR is now ENABLED for: single products, product categories, product tags
    # SSR is SKIPPED for: cart, checkout
    # Note: title/description filters (pre_get_document_title, wp_head meta desc)
    # run on ALL pages regardless — they are hooked unconditionally in seo-functions.php

    # ── WooCommerce-independent cart/checkout protection ──────────
    # The is_cart()/is_checkout() guards below only recognize WooCommerce. Carts
    # from other systems — e.g. the Point of Rental "Catalog" plugin on
    # venturarental.com — are invisible to them, so OTTO would process those
    # pages and let caching layers store them. A cached cart page corrupts the
    # live cart (added items vanish because a stale page is served). The checks
    # here do not depend on WooCommerce being active.

    # Never run OTTO on non-GET requests. SSR exists for crawlers, which only
    # issue GET; POST/PUT/etc. are form or cart submissions that must pass
    # through untouched. (OTTO's own internal fetches use GET.)
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'GET') {
        return;
    }

    # Never run OTTO on WordPress core draft/pending previews
    # (?p=<ID>&preview=true, with preview_id/preview_nonce on the full link).
    # Previews are per-user, non-cacheable, and routed purely by query string —
    # and OTTO's route identity ignores the query string, so a processed preview
    # is answered with the route's public page (the homepage for /?p=…) instead
    # of the draft the editor asked for. Skip early and let WP render it.
    if (
        (isset($_GET['preview']) && $_GET['preview'] === 'true') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        !empty($_GET['preview_id']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
    ) {
        if (!defined('DONOTCACHEPAGE')) {
            define('DONOTCACHEPAGE', true);
        }
        return;
    }

    # Never run OTTO on the Elementor editor/preview iframe. It is a logged-in
    # front-end GET (not is_admin()), so it slips past the admin guard below;
    # buffering and rewriting it breaks the editor canvas ("fails to load").
    if (metasync_is_elementor_editor_request()) {
        return;
    }

    # WP core file-editor self-check. When an admin saves a THEME file
    # via the Plugin/Theme Editor, wp_edit_theme_plugin_file() fires an internal
    # loopback GET to home_url('/') carrying wp_scrape_key/wp_scrape_nonce to
    # detect a white-screen. That request is NOT is_admin(), so — exactly like
    # the Elementor case above — it slips past the admin/AJAX/REST guards below.
    # OTTO must not output-buffer or rewrite it: the SimpleHtmlDom pass (and any
    # fatal thrown inside the ob_start() callback, e.g. under memory pressure)
    # corrupts the scrape and surfaces as the misleading
    # "preg_match(): Cannot use output buffering in output buffering display
    # handlers" fatal. Skipping OTTO lets WP render the page normally so the
    # scrape works. Returning here also avoids the HTTP-fallback path firing a
    # second loopback for an already-internal self-request.
    if (metasync_is_scrape_request()) {
        return;
    }

    # Skip OTTO on cart/checkout-type paths regardless of the cart plugin, and
    # signal caching plugins (WP Rocket/Kinsta/etc.) to never cache them — a
    # cached cart/checkout page is precisely what corrupts cart state.
    # Matched against the FIRST path segment (relative to the WP home path) so
    # subdirectory installs (/shop/cart) still work, while unrelated content pages
    # like /blog/cart or /features/checkout do NOT false-match.
    # Drop the query string with explode() (no shared internal pointer like strtok),
    # then lower-case and strip surrounding slashes.
    $otto_req_path = explode('?', (string) wp_unslash($_SERVER['REQUEST_URI'] ?? ''), 2)[0];
    $otto_req_path = strtolower(trim($otto_req_path, '/'));
    # Strip the site's base path so first-segment matching works on subdir installs.
    $otto_home_path = trim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/');
    if ($otto_home_path !== '' && strpos($otto_req_path, $otto_home_path . '/') === 0) {
        $otto_req_path = substr($otto_req_path, strlen($otto_home_path) + 1);
    }
    $otto_first_segment = explode('/', $otto_req_path, 2)[0];
    # Filterable so sites can add/remove transactional slugs without code changes.
    $otto_cart_segments = apply_filters('metasync_otto_cart_paths', array(
        'cart', 'checkout', 'basket', 'request-a-quote', 'quote-request',
    ));
    $otto_cart_segments = array_map('strtolower', (array) $otto_cart_segments);
    if ($otto_first_segment !== '' && in_array($otto_first_segment, $otto_cart_segments, true)) {
        if (!defined('DONOTCACHEPAGE')) {
            define('DONOTCACHEPAGE', true);
        }
        return;
    }
    # ──────────────────────────────────────────────────────────────────────

    if (
        # disable ajax calls
        isset($_GET['ucfrontajaxaction']) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        # OTTO Preview mode - skip OTTO when previewing original content
        (isset($_GET['otto_preview']) && $_GET['otto_preview'] === '1') || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        # WooCommerce shop archive page only (products and categories now use SSR)
        //(function_exists('is_shop') && is_shop()) ||
        # Cart page
        (function_exists('is_cart') && is_cart()) ||
        # Checkout page
        (function_exists('is_checkout') && is_checkout()) ||
        # My Account page
        //(function_exists('is_account_page') && is_account_page()) ||
        # Standard WordPress AJAX
        (function_exists('wp_doing_ajax') && wp_doing_ajax()) ||
        # check by constant
        (defined('DOING_AJAX') && DOING_AJAX) ||
        # WooCommerce AJAX endpoint (e.g., ?wc-ajax=update_cart)
        (isset($_REQUEST['wc-ajax']) && !empty($_REQUEST['wc-ajax'])) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        # AJAX requests via X-Requested-With header
        (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') ||
        # Gravity Forms submission detection - skip OTTO to allow form processing
        (isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
            is_array($_POST['gform_submit']) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
            (is_string($_POST['gform_submit']) && isset($_POST['is_submit_' . $_POST['gform_submit']]) && !empty($_POST['gform_submit'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        )) ||
        # Gravity Forms AJAX submission
        (isset($_POST['gform_ajax']) && isset($_POST['gform_submit']) && ( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
            is_array($_POST['gform_submit']) || !empty($_POST['gform_submit']) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        )) ||
        # Gravity Forms file upload
        (isset($_POST['gform_uploaded_files'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        # Any Gravity Forms POST parameter
        (isset($_POST['gform_submit']) || isset($_POST['gform_unique_id']) || isset($_POST['gform_field_values'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        # Formidable Forms AJAX submission detection - skip OTTO to allow form processing
        (isset($_POST['action']) && $_POST['action'] === 'frm_entries_create') || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        # Formidable Forms POST parameters
        (isset($_POST['form_id']) && !empty($_POST['form_id'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        # Formidable Forms action parameter
        (isset($_POST['frm_action']) && !empty($_POST['frm_action'])) || // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
        # Formidable Forms item_key (used in form submissions)
        (isset($_POST['item_key']) && !empty($_POST['item_key'])) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only front-end check that skips OTTO
    ) {
        return;
    }

    # fetch globals
    global $metasync_options, $otto_enabled;

    # OPTIMIZED: check for the disable otto for logged in users option using cached config
    if(Metasync_Otto_Config::is_disabled_for_loggedin()){

        # get user
        $current_user = wp_get_current_user();

        # check if user is logged in
        if( !empty($current_user->ID)){

            return;
        }
    }

    # check if current URL is manually excluded from OTTO
    # NOTE: auto-exclusions (false-positive 404 detections) are intentionally NOT checked
    # here — they must not block OTTO rendering. Use metasync_is_otto_url_excluded() only
    # in the webhook handler where we gate SEO meta writes.
    $request_uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
    $current_url = home_url(strtok($request_uri, '?') ?: $request_uri);
    if (metasync_is_otto_url_manually_excluded($current_url)) {
        return;
    }

    # Skip OTTO for XML endpoints (sitemaps, RSS-as-xml, etc.). OTTO has no
    # suggestions for machine-readable XML, and the upstream API returns
    # API_ERROR for these URLs — which then stamps misleading
    # X-MetaSync-OTTO-Cache: API_ERROR / X-MetaSync-OTTO-Method: NONE headers
    # onto otherwise-healthy sitemap responses and causes false-alarm bug
    # reports from customers.
    $request_path = strtok($request_uri, '?');
    if ($request_path && preg_match('#\.xml$#i', $request_path)) {
        return;
    }

    # Machine-readable core endpoints. Using WordPress's own conditionals rather
    # than matching the path: they are exact, and a path pattern here would also
    # match ordinary content whose slug merely starts with the same characters
    # (e.g. /robots.txt-explained/, /favicon.ico-vs-svg/).
    # is_favicon() is WP 5.4+; this plugin supports 5.2.
    if (is_robots() || (function_exists('is_favicon') && is_favicon())) {
        return;
    }

    # Protocol paths and static assets that are never OTTO-able content.
    # The guard fails open — if the strategy is unavailable we let the request
    # proceed rather than fatal. metasync_otto_class_provides() rather than a
    # bare class_exists(): a partially updated install can leave an older copy of
    # the strategy class loaded that predates is_non_content_path(), which
    # satisfies class_exists() and then fatals on every front-end request.
    if (metasync_otto_class_provides('Metasync_Otto_Render_Strategy', 'is_non_content_path')
        && Metasync_Otto_Render_Strategy::is_non_content_path($request_path)) {
        return;
    }

    # BOT DETECTION: Always detect bots so crawl data reaches the SA backend.
    # Real bots don't execute JS, so otto-tracker.js never fires for them.
    # push_crawl_log_to_sa() fires a non-blocking wp_remote_post here instead.
    $bot_detector = Metasync_Otto_Bot_Detector::get_instance();
    $detection    = $bot_detector->detect();
    if ( $detection['is_bot'] ) {
        $bot_detector->push_crawl_log_to_sa( $detection, $current_url );
    }

    # A headless frontend renders the public HTML, so stop after the cheap cache
    # protections and crawl telemetry, before any OTTO rendering work begins.
    #
    # Routed through metasync_headless_is_active() rather than calling the class
    # directly: Metasync_Headless_Config resolves only through the committed
    # Composer classmap, so a partially updated install can leave this newer
    # caller beside a missing or older copy with no fallback lookup path. This
    # function is hooked to `wp`, so an unguarded static call is a white screen
    # on every front-end page view. The helper treats an unresolvable class as
    # "not headless", which keeps OTTO behaving as it did before headless mode.
    # @phpstan-ignore-next-line function.alreadyNarrowedType
    $metasync_is_headless = function_exists('metasync_headless_is_active')
        ? metasync_headless_is_active()
        : (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active());

    if ($metasync_is_headless) {
        return;
    }

    # Throttle OTTO rendering for bots that are not exempt: at most one render
    # per URL+bot every 5 minutes. Humans, search engines (Googlebot, Bingbot,
    # Yahoo's Slurp, Sogou, Exabot, Applebot, ...), host cache warmers and
    # synthetic performance auditors are never throttled — their hits always get
    # full OTTO content, so indexing stays current, the page cache can actually
    # be filled, and audit tools measure what a real visitor sees. SEO tools,
    # social crawlers, archivers, AI scrapers, uptime monitors and unrecognized
    # crawlers are de-duplicated so repeat hits don't re-trigger the expensive
    # DOM-rewriting path.
    #
    # Two complementary gates, deliberately kept separate:
    #   - is_unthrottled_category() consults the classifier's explicit per-bot
    #     category map, so the exempt set is defined in exactly one place.
    #   - metasync_otto_is_unthrottled_infrastructure_agent() matches the raw
    #     user-agent (plus a query marker and a site filter), which catches
    #     preload agents that have no entry in the map at all.
    if ( $detection['is_bot']
        && ! Metasync_Otto_Bot_Detector::is_unthrottled_category( $detection['bot_type'] )
        && ! metasync_otto_is_unthrottled_infrastructure_agent( $detection ) ) {
        $normalized_url = strtok( $current_url, '?' );
        if ( $normalized_url === false ) {
            $normalized_url = $current_url;
        }
        // Lowercase + length cap so attacker-controlled bot_name variations
        // (EvilBota, EvilBotB, EvilBotc, ...) collapse to a bounded key space
        // and cannot flood wp_options with unique transient rows.
        $bot_name            = substr( strtolower( $detection['bot_name'] ?? 'unknown' ), 0, 32 );
        $render_throttle_key = 'metasync_otto_rendered_' . md5( $normalized_url . '|' . $bot_name );
        if ( get_transient( $render_throttle_key ) ) {
            // Prevent page caches (WP Rocket, W3TC, etc.) from saving the
            // un-OTTO'd response and serving it to subsequent human visitors.
            if ( ! defined( 'DONOTCACHEPAGE' ) ) {
                define( 'DONOTCACHEPAGE', true );
            }
            return;
        }
        set_transient( $render_throttle_key, 1, 5 * MINUTE_IN_SECONDS );
    }

    # Skip OTTO on 404s.
    #
    # OTTO suggestions are keyed to real, crawled pages. Asking the API about a URL
    # that returns 404 spends a full API_TIMEOUT of PHP-FPM worker time on a
    # guaranteed-empty answer. Not theoretical: in one production sample, 21 of 54
    # crawler requests were 404s on URLs that never existed on the site, each
    # costing ~2s of a worker.
    #
    # Deliberately placed AFTER bot detection so push_crawl_log_to_sa() still
    # reports the crawl to SearchAtlas — that telemetry is how the crawler side
    # can learn to stop requesting these URLs. Only the expensive suggestions
    # lookup is skipped.
    #
    # Filterable because "should a 404 page get OTTO treatment" is arguably a site
    # decision, even though there is no known case where it should.
    if (is_404() && apply_filters('metasync_otto_skip_on_404', true)) {
        return;
    }

    # Optionally skip OTTO processing for bot traffic (when the setting is enabled)
    if ($bot_detector->should_skip_otto()) {
        // Log the bot locally and count the saved API call
        $bot_detector->log_detection($detection);
        $bot_stats_db = Metasync_Otto_Bot_Statistics_Database::get_instance();
        $bot_stats_db->increment_api_calls_saved();

        // Skip OTTO processing for this bot
        return;
    }

    # OPTIMIZED: Check if Otto should be disabled for WP Rocket compatibility
    if (class_exists('WP_Rocket')) {
        $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();

        if ($wp_rocket_compat_mode === 'disable_otto') {
            return; # Exit early, Otto is disabled when WP Rocket is active
        }
    }

    # Skip OTTO for Divi AJAX pagination and paginated archive requests.
    # ?et_blog = Divi AJAX pagination callback
    # /page/N/ = paginated blog/archive pages — OTTO's buffer/HTTP render causes
    # module numbering mismatch between page 1 (with TB template) and page N
    # (without TB template), breaking Divi's JS pagination selector matching.
    if (isset($_GET['et_blog']) || (is_paged() && !is_singular())) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        return;
    }

    # Skip OTTO on search-results pages. A search request carries its
    # meaning entirely in the query string (e.g. /?s=term, or FiboSearch's
    # /?s=term&post_type=product&dgwt_wcas=1). get_route() intentionally strips
    # the query string to build a canonical, cache-key-stable route — which
    # collapses a query-only search URL to the site root ('/'). OTTO then finds
    # the HOME page's suggestions for that route and, on the HTTP render path,
    # serves the home page in place of the search results (the address bar keeps
    # the search URL — it is a silent content swap, not a redirect). This only
    # surfaces for logged-in / uncached visitors, since page caches serve the
    # correct pre-rendered search page to everyone else. Search-results pages
    # have no per-canonical-URL OTTO suggestions of their own, so OTTO must never
    # run here. The raw $_GET['s'] check is a defensive fallback for setups where
    # the main query is altered before this point.
    if (is_search() || !empty($_GET['s'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end check that skips OTTO
        return;
    }

    # Handle cache plugin compatibility early - before any caching happens
    metasync_otto_handle_cache_compatibility();

    # check if OTTO is disabled for this specific page/post
    # Gate on is_singular() for the same reason the custom/LPS check below does:
    # on an archive, search or term query get_the_ID() returns the first post of
    # the loop — WP::register_globals() seeds $GLOBALS['post'] with it — not the
    # page being rendered. Without the gate, one excluded or per-page-disabled
    # post silently takes OTTO off every archive that happens to list it first.
    $post_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
    if ($post_id && class_exists('Metasync_Otto_Frontend_Toolbar')) {
        if (Metasync_Otto_Frontend_Toolbar::is_otto_disabled($post_id)) {
            return;
        }
    }

    # Skip OTTO on MetaSync custom HTML / LPS-imported pages — they ship their
    # own complete, self-contained SEO and OTTO must not inject or overwrite it
    # with a different/older project's SEO. Resolve the queried object
    # id (with get_the_ID() fallback) so the static-front-page case — an LPS home
    # set as the WP front page, where is_page() is false — is still detected.
    # Applying the skip here, upstream of the single render_route_html() entry,
    # covers all three render paths (Rocket buffer, output buffer, HTTP fallback).
    # Only singular views (posts/pages, incl. a static front page) can be a
    # custom/LPS page; gate on is_singular() so an archive/search/term query
    # can never have its object id mistaken for a custom page's post id.
    $custom_page_id = is_singular() ? ( get_queried_object_id() ?: get_the_ID() ) : 0;
    if (metasync_is_custom_or_lps_page($custom_page_id)) {
        if (!headers_sent() && Metasync_Otto_Render_Strategy::diagnostics_enabled()) {
            header('X-MetaSync-OTTO-Method: EXCLUDED');
        }
        return;
    }

    # check if we are having an otto request
    #
    # Recognise our own internal fetch by header/User-Agent as well as by the
    # query parameter. A redirect that rebuilds the URL strips the parameter,
    # and without this the redirected request re-enters OTTO and fires another
    # internal fetch - an unbounded self-request loop.
    # method_exists() is redundant to static analysis - this MR adds the method,
    # so PHPStan proves the call always true. It is kept for the upgrade window:
    # during a plugin update an opcache can still hold the previous
    # Metasync_Otto_Render_Strategy, where class_exists() passes but the method
    # is absent, and an unguarded static call would fatal the page.
    # @phpstan-ignore-next-line function.alreadyNarrowedType
    $otto_has_fetch_detector = class_exists('Metasync_Otto_Render_Strategy') && method_exists('Metasync_Otto_Render_Strategy', 'is_internal_fetch');

    $otto_is_internal_fetch = $otto_has_fetch_detector
        ? Metasync_Otto_Render_Strategy::is_internal_fetch()
        : !empty($_GET['is_otto_page_fetch']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only

    if($otto_is_internal_fetch){

        # Block SEO plugins NOW for this internal fetch request
       # metasync_otto_block_seo_plugins();
       # $_SERVER['REQUEST_URI'] = remove_query_arg('is_otto_page_fetch', $_SERVER['REQUEST_URI']);
       $block_title = !empty($_GET['otto_block_title']) && $_GET['otto_block_title'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
        $block_description = !empty($_GET['otto_block_desc']) && $_GET['otto_block_desc'] === '1'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only loopback marker, request-scoped filters only
        
        # Block SEO plugins conditionally based on what Otto has
        if ($block_title || $block_description) {
            metasync_otto_block_seo_plugins($block_title, $block_description);
        }
        
        # Remove ALL Otto parameters from REQUEST_URI to prevent them from appearing in pagination, etc.
        $request_uri_raw = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'] ?? ''));
        $_SERVER['REQUEST_URI'] = remove_query_arg(
            ['is_otto_page_fetch', 'otto_block_title', 'otto_block_desc'],
            $request_uri_raw
        );
        
        # Also remove from $_GET to prevent WordPress from using them
        unset($_GET['is_otto_page_fetch']);
        unset($_GET['otto_block_title']);
        unset($_GET['otto_block_desc']);
        return;
    }

    # to avoid unnecessary processese
    # OPTIMIZED: check that otto is configured
    # And the UUID is properly set before running OTT

    # check that we have the option
    if(!Metasync_Otto_Config::is_otto_enabled()){
        return;
    }

    # check that otto is enabled
    if(!$otto_enabled){
        return;
    }

    # get the otto uuid
    $otto_uuid = Metasync_Otto_Config::get_otto_uuid();

    # Upgrade-window guard: the bundled DOM parser resolves only through the
    # otto/vendor autoloader required at the top of this file. While an update
    # is mid-copy (or a stale opcache classmap is still resident), that
    # dependency can briefly be undefined here, and constructing the render
    # stack below would throw a class-not-found Error and take the whole page
    # down with it. Degrade to an OTTO-less page for this request instead —
    # the next request, on the settled tree, renders normally.
    if ( ! class_exists( 'simplehtmldom\HtmlDocument' ) ) {
        return;
    }

    # start the class
    $otto = new Metasync_otto_pixel($otto_uuid);

    # call render
    $otto->render_route_html();
}

/**
 * Handle cache plugin compatibility with Otto
 * Controls DONOTCACHEPAGE constant based on active plugins and configuration
 * This function is called early in the WordPress lifecycle
 */
function metasync_otto_handle_cache_compatibility() {
    # Detect active plugins. SG Optimizer detection now lives in
    # metasync_otto_disable_sg_page_cache(), which is invoked later
    # once OTTO confirms suggestions for the URL.
    $brizy_active = class_exists('Brizy_Editor') || defined('BRIZY_VERSION');
    $wp_rocket_active = class_exists('WP_Rocket');

    # OPTIMIZED: Get configuration option using cached config
    $wp_rocket_compat_mode = Metasync_Otto_Config::get_wp_rocket_compat_mode();

    # Check for Brizy posts in database
    global $wpdb;
    $has_brizy_posts = false;

    if ($brizy_active) {
        # OPTIMIZED: Check cache first (1-hour TTL) to avoid querying on every page load
        $cached = get_transient('metasync_has_brizy_posts');
        if ($cached !== false) {
            $has_brizy_posts = ($cached === 'yes');
        } else {
            # Query database only if cache missed
            $has_brizy_posts = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- feature-detection probe for Brizy posts — postmeta flag scan has no WordPress API
                "SELECT COUNT(*) FROM {$wpdb->postmeta}
                 WHERE meta_key = 'brizy_post_uid'
                 AND meta_value != ''
                 LIMIT 1"
            );

            # Cache result for 1 hour
            $result = !empty($has_brizy_posts) ? 'yes' : 'no';
            set_transient('metasync_has_brizy_posts', $result, HOUR_IN_SECONDS);
        }
    }

    # Determine if DONOTCACHEPAGE should be set
    $should_set_donotcachepage = false;

    # Case 1: Brizy is active with posts - always needed. Brizy pages are
    # dynamically rendered regardless of OTTO, so caching must be disabled
    # whenever Brizy posts exist.
    if ($brizy_active && !empty($has_brizy_posts)) {
        $should_set_donotcachepage = true;
    }

    # Case 2: User explicitly disabled Otto for WP Rocket compatibility
    elseif ($wp_rocket_active && $wp_rocket_compat_mode === 'disable_otto') {
        $should_set_donotcachepage = true;
        return; # Exit early, Otto won't run
    }

    # Case 3: WP Rocket active with auto/buffer mode - DON'T set DONOTCACHEPAGE
    # This allows WP Rocket optimizations to continue working

    # NOTE: SiteGround SG Optimizer cache bypass is intentionally NOT
    # handled here. Emitting no-cache headers / disabling SG cache on this hook
    # fired on EVERY front-end page — including pages OTTO never modifies —
    # forcing SG to bypass its page cache site-wide and spiking CPU. The SG
    # bypass is now deferred to metasync_otto_disable_sg_page_cache(), called
    # from render_route_html() only after OTTO confirms it has suggestions for
    # the current URL.

    # Only set DONOTCACHEPAGE if needed
    if ($should_set_donotcachepage && !defined('DONOTCACHEPAGE')) {
        define('DONOTCACHEPAGE', true);
    }
}

/**
 * Disable SiteGround SG Optimizer page caching for the CURRENT request only.
 *
 * Called from Metasync_otto_pixel::render_route_html() once OTTO has confirmed
 * it has suggestions to apply to the requested URL. Scoping the no-cache
 * override to pages OTTO actually modifies — rather than emitting it on every
 * front-end page via the unconditional `wp` hook — keeps SG's page cache
 * working site-wide and avoids the CPU spike reported in.
 *
 * Only meaningful on SiteGround sites without WP Rocket; a no-op otherwise.
 */
function metasync_otto_disable_sg_page_cache() {
    # Only relevant when SG Optimizer is active and WP Rocket is not present.
    if (class_exists('WP_Rocket')) {
        return;
    }
    if (!is_plugin_active('sg-cachepress/sg-cachepress.php')) {
        return;
    }

    if (!defined('DONOTCACHEPAGE')) {
        define('DONOTCACHEPAGE', true);
    }
    if (!defined('SG_CachePress_SUPERCACHER')) {
        define('SG_CachePress_SUPERCACHER', false);
    }

    add_filter('sgo_html_cache_disable', '__return_true', 999);
    add_filter('sgo_css_combine_exclude', '__return_true', 999);
    add_filter('sgo_js_combine_exclude', '__return_true', 999);
    add_filter('sgo_cache_this_page', '__return_false', 999);

    if (!headers_sent()) {
        header('Cache-Control: no-cache, must-revalidate, max-age=0');
        header('X-Accel-Expires: 0');
    }
}

/**
 * How long a response that OTTO could not optimize may be cached, in seconds.
 *
 * Deliberately short rather than zero — see
 * metasync_otto_limit_response_cache() for why.
 */
if (!defined('METASYNC_OTTO_FALLBACK_CACHE_TTL')) {
    define('METASYNC_OTTO_FALLBACK_CACHE_TTL', 60);
}

/**
 * Cap how long the current response may be cached, because OTTO could not apply
 * its suggestions to it.
 *
 * OTTO fails open: when suggestions are unavailable, or when the HTML rewrite
 * fails, the site's own un-optimized markup is served instead. That markup is
 * valid — the harm is that a caching layer then stores it and serves it to
 * everyone for the rest of its TTL (a host edge cache with s-maxage=86400 pins
 * it for a day), which surfaces to customers as the OTTO title "reverting".
 *
 * A short TTL is used rather than `no-store`, which is the obvious choice but the
 * wrong one. Some failures look transient and are not: a page that always breaks
 * the rewriter, or a host that cannot fetch its own pages, fails identically on
 * every request. `no-store` would make those permanently uncacheable, sending
 * 100% of their traffic to the origin forever in exchange for markup that will
 * never improve. A one-minute ceiling fixes the reported bug just as completely —
 * a day-long pin becomes a minute — while capping the cost of being wrong about
 * which failures recover.
 *
 * Different layers read different signals, so all of them are emitted:
 *
 *  - Cache-Control          CDNs, host edge caches, browsers. `max-age=0` keeps
 *                           browsers revalidating while `s-maxage` lets shared
 *                           caches absorb a burst for the ceiling's duration.
 *  - X-Accel-Expires        Nginx's own directive. Evaluated with higher
 *                           precedence than Cache-Control, and not covered by
 *                           the `fastcgi_ignore_headers Cache-Control Expires`
 *                           recipe common on managed hosts — so it can survive
 *                           a config that discards Cache-Control. Nginx strips
 *                           X-Accel-* before the response reaches the client,
 *                           so there is no visitor-facing side effect.
 *  - Surrogate-Control      Varnish and Fastly. Note that on stock Varnish the
 *                           mere presence of this header suppresses its
 *                           Cache-Control check, so it must carry a real
 *                           lifetime rather than be left at some other value.
 *  - DONOTCACHEPAGE         WordPress-level page caches (WP Rocket, W3TC,
 *                           LiteSpeed, SG Optimizer). These run inside PHP and
 *                           never see response headers. They have no notion of a
 *                           short TTL — it is cache or do not — so they skip
 *                           entirely. That is safe here because callers only
 *                           invoke this for failures expected to resolve; a
 *                           permanent one (an oversized page, a host that cannot
 *                           reach itself) is left fully cacheable by the caller
 *                           and never reaches this function.
 *
 * Cache-tagging headers (Surrogate-Key, Cache-Tag, Edge-Cache-Tag) are
 * deliberately left in place. They are purge-targeting labels, not cacheability
 * directives — no CDN stores a response *because* a tag is present. On a layer
 * that stores this response anyway, those tags are the only handle a later purge
 * has on it, so removing them would discard the recovery path exactly where it
 * is needed most.
 *
 * Some layers may honour none of this; a managed host that overrides origin
 * cache headers will cache the response regardless. This bounds the damage where
 * it can and is inert where it cannot.
 *
 * @param string $reason Short machine-readable reason, surfaced as a response
 *                       header for debugging (e.g. 'API_ERROR', 'RENDER_FAILED').
 */
/**
 * Build the response headers that cap how long an un-optimized response may be
 * cached.
 *
 * Thin wrapper over Metasync_Otto_Render_Strategy::fallback_cache_headers(), which
 * owns the header set so it can be unit-tested (this file cannot be loaded in a
 * test context). Returns an empty array if the strategy class is somehow absent,
 * so a caller emits nothing rather than an untested divergent copy.
 *
 * @param int    $ttl    Cache ceiling in seconds.
 * @param string $reason Optional machine-readable reason for the debug header.
 * @return array Header name => value.
 */
function metasync_otto_fallback_cache_headers($ttl, $reason = '') {
    if (!class_exists('Metasync_Otto_Render_Strategy')) {
        return [];
    }

    return Metasync_Otto_Render_Strategy::fallback_cache_headers_for_response($ttl, $reason);
}

function metasync_otto_limit_response_cache($reason = '') {
    # This function must only ever make the response LESS cacheable. header()
    # replaces by default and the capped set carries X-Accel-Expires, which nginx
    # evaluates ahead of Cache-Control — so emitting it unconditionally could
    # override an upstream no-store and grant edge caching that nothing had
    # granted. The strategy reconciles existing cache directives before emitting
    # a capped set.

    $strategy_available = class_exists('Metasync_Otto_Render_Strategy');

    # 1. Logged-in users. OTTO renders for them unless the "disable on logged in"
    #    setting is switched on, which is off by default — so this is the common
    #    case, not an edge case. Their page may carry session-specific or
    #    membership-gated content, and capping at 60 seconds would still let a
    #    shared cache store and re-serve it to somebody else.
    if (function_exists('is_user_logged_in') && is_user_logged_in()) {
        if (!headers_sent() && $strategy_available) {
            foreach (Metasync_Otto_Render_Strategy::no_store_headers($reason) as $name => $value) {
                header($name . ': ' . $value);
            }
        }
        if (!defined('DONOTCACHEPAGE')) {
            define('DONOTCACHEPAGE', true);
        }
        return;
    }

    # 2. Something upstream already restricted this response. Leave its headers
    #    alone — a page deliberately marked no-store must not be handed a 60
    #    second shared-cache lifetime by us.
    # The strategy turns an existing no-store into a consistent no-store set and
    # uses the strictest known existing lifetime for every cache layer.
    $ttl = (int) METASYNC_OTTO_FALLBACK_CACHE_TTL;

    if (!headers_sent()) {
        foreach (metasync_otto_fallback_cache_headers($ttl, $reason) as $name => $value) {
            header($name . ': ' . $value);
        }
    }

    # Reaches page-cache plugins, which do not see the headers above.
    if (!defined('DONOTCACHEPAGE')) {
        define('DONOTCACHEPAGE', true);
    }
}

/**
 * Report an OTTO render failure to the local error log and to Sentry.
 *
 * These failures were invisible: the render path swallowed them and returned the
 * original HTML, so nobody knew how often they fired. Reporting them is what makes
 * a persistently-failing URL discoverable instead of silently costing origin work.
 *
 * Two constraints shape this:
 *
 *  1. It runs on the visitor render path, so it must not add latency. The Sentry
 *     report is therefore queued here and sent by
 *     metasync_otto_flush_render_failure_reports() after the response has been
 *     handed to the visitor, so the cost is invisible rather than merely small.
 *
 *     Two alternatives were tried and rejected. Sending inline with a blocking
 *     transport — cURL with a 5s connect and 5s read timeout — could add ten
 *     seconds to a page load, precisely when the request is already degraded.
 *     Sending inline non-blocking is far cheaper but still not free: opening the
 *     connection and writing the body costs the visitor something.
 *
 *     A WP-Cron single event was the other obvious option and is worse than both:
 *     every scheduled event is stored in the `cron` option, which is autoloaded on
 *     every request site-wide, and WP's own 10-minute duplicate suppression keys on
 *     the serialized args — which carry per-request byte counts here, so it would
 *     never engage. A site with many failing URLs would grow that option
 *     unboundedly and pay for it on every page load, which is the cost the deferral
 *     was meant to avoid in the first place.
 *
 *     The local log stays synchronous. It is cheaper than Sentry but not free —
 *     Metasync_Error_Logger::log() rewrites a summary option on each call — which is
 *     the second reason for the throttle below.
 *
 *  2. A failing page can be requested thousands of times an hour, so reports are
 *     throttled per reason+URL.
 *
 * @param string $reason  Machine-readable failure reason (e.g. 'RENDER_EXCEPTION').
 * @param string $message Human-readable description.
 * @param array  $context Extra key/value context.
 * @param string $level   Sentry level: 'error' or 'warning'.
 */
function metasync_otto_report_render_failure($reason, $message, $context = [], $level = 'error') {
    $url = '';
    if (!empty($_SERVER['HTTP_HOST']) && !empty($_SERVER['REQUEST_URI'])) {
        $url = (is_ssl() ? 'https://' : 'http://')
            . $_SERVER['HTTP_HOST']
            . strtok(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), '?');
    }

    # Throttle: at most one report per reason+URL per 5 minutes. Requests without a
    # resolvable URL (CLI, odd SAPIs) collapse onto one key, which is intended —
    # they cannot be attributed to a page anyway.
    $throttle_key = 'metasync_otto_fail_' . md5($reason . '|' . $url);
    if (get_transient($throttle_key)) {
        return;
    }
    set_transient($throttle_key, 1, 5 * MINUTE_IN_SECONDS);

    $context = array_merge([
        'reason' => $reason,
        'url'    => $url,
    ], $context);

    # Local structured log. Stays on the customer's site, so it is useful to
    # support but not visible to us — hence the Sentry dispatch below.
    if (class_exists('Metasync_Error_Logger')) {
        Metasync_Error_Logger::log(
            Metasync_Error_Logger::CATEGORY_OTTO_RENDER,
            $level === 'warning'
                ? Metasync_Error_Logger::SEVERITY_WARNING
                : Metasync_Error_Logger::SEVERITY_ERROR,
            $message,
            $context
        );
    }

    # Sentry. Queued rather than sent here: even a non-blocking dispatch costs the
    # visitor something (opening the connection and writing the body), and this runs
    # on a request that is already degraded. The queue is flushed after the response
    # has been handed to the visitor — see metasync_otto_flush_render_failure_reports().
    if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
        return;
    }

    $GLOBALS['metasync_otto_pending_failure_reports'][] = [
        'message' => $message,
        'level'   => $level,
        'context' => $context,
    ];

    if (empty($GLOBALS['metasync_otto_failure_flush_hooked'])) {
        $GLOBALS['metasync_otto_failure_flush_hooked'] = true;
        # PHP_INT_MAX so this runs after everything else registered on shutdown —
        # WordPress flushes its own output buffers and closes the object cache at the
        # default priority, and this handler ends output for the request.
        add_action('shutdown', 'metasync_otto_flush_render_failure_reports', PHP_INT_MAX);
    }
}

/**
 * Send any queued render-failure reports after the response has been delivered.
 *
 * fastcgi_finish_request() (PHP-FPM) and litespeed_finish_request() (LiteSpeed)
 * flush the response and close the connection to the visitor while leaving PHP
 * running. Calling one of them here means the page is already on its way before
 * any telemetry work starts, so the reporting cost is invisible to the visitor
 * rather than merely small.
 *
 * Neither exists under mod_php or the CLI SAPI. There the reports are still sent
 * at the very end of the request, after all page output has been generated and
 * flushed — the same ordering, just without the connection being closed first.
 *
 * The dispatch itself stays non-blocking. Now that the visitor is served, the only
 * remaining cost is PHP worker occupancy, and a blocking transport with a 5s
 * connect and 5s read timeout could tie up a worker for ten seconds per report on
 * a site that is failing often.
 */
function metasync_otto_flush_render_failure_reports() {
    $reports = $GLOBALS['metasync_otto_pending_failure_reports'] ?? [];
    $GLOBALS['metasync_otto_pending_failure_reports'] = [];

    if (empty($reports)) {
        return;
    }

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

    if (!function_exists('metasync_sentry_capture_message_nonblocking')) {
        return;
    }

    foreach ($reports as $report) {
        try {
            metasync_sentry_capture_message_nonblocking(
                $report['message'],
                $report['level'],
                $report['context']
            );
        } catch (Exception $e) {
            # Telemetry must never be able to break a request.
        } catch (Error $e) {
            # Same.
        }
    }
}

/**
 * Whether MetaSync is still emitting Open Graph / Twitter tags.
 *
 * The Social Media & Open Graph switch stops MetaSync writing those tags on
 * every render path, so the suppression below has to stand down with it —
 * removing another plugin's og:/twitter: tag while contributing none of our own
 * would leave the page with no social tags at all.
 *
 * The class_exists() guard keeps this usable if the flags file has not loaded
 * yet; treating that window as "enabled" preserves the previous behaviour.
 *
 * @return bool
 */
function metasync_otto_social_output_enabled() {
    if (!class_exists('Metasync_Feature_Flags')) {
        return true;
    }

    return Metasync_Feature_Flags::is_enabled(Metasync_Feature_Flags::SOCIAL_OG);
}

/**
 * Block SEO plugins conditionally based on what Otto is providing
 * Only blocks title if Otto has title, only blocks description if Otto has description
 * This prevents duplicate SEO tags while allowing fallback to SEO plugins when Otto has no data.
 * Supports Yoast SEO, Rank Math, and AIOSEO (free + pro).
 *
 * @param bool  $block_title       Whether to block title tags.
 * @param bool  $block_description Whether to block description tags.
 * @param array $description_tags  Optional. Granular list of OTTO-provided description tags
 *                                 (e.g. ['meta[name=description]', 'meta[property=og:description]']).
 *                                 When provided, only matching AIOSEO tags are suppressed.
 *                                 When empty, all AIOSEO description tags are suppressed (legacy behavior).
 */
function metasync_otto_block_seo_plugins($block_title = false, $block_description = false, $description_tags = []) {
    # Disable Yoast SEO (free and premium)
    if (is_plugin_active('wordpress-seo/wp-seo.php') ||
        is_plugin_active('wordpress-seo-premium/wp-seo-premium.php')) {

        # TITLE: Never block Yoast's title output during SSR fetch.
        # Yoast removes WordPress's native _wp_render_title_tag action and is the sole
        # renderer of the <title> tag. Returning false/empty from wpseo_title or removing
        # Title_Presenter leaves the page with NO <title> tag at all — OTTO's buffer
        # post-processing then has nothing to replace, producing a missing title.
        # Instead, let Yoast render its own title; OTTO's replace_title() will overwrite
        # it in the final HTML buffer. deduplicate_title_tags() cleans up any duplicates.

        # Block description only if Otto has description
        if ($block_description) {
            add_filter('wpseo_metadesc', '__return_false', 999);
            add_filter('wpseo_meta_description', '__return_false', 999);
            add_filter('wpseo_metakeywords', '__return_false', 999);
        }

        # Block Yoast's modern presenters — description only, never title
        add_filter('wpseo_frontend_presenters', function($presenters) use ($block_description) {
            if (!is_array($presenters)) return $presenters;

            $presenters_to_remove = [];


            # Remove description presenters only when OTTO has a description
            if ($block_description) {
                # The plain meta description belongs to the SEO title/description
                # feature, which has its own setting.
                $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Meta_Description_Presenter';

                # The OG/Twitter descriptions are social tags. With that feature
                # switched off MetaSync writes none of its own, so Yoast's must be
                # left alone rather than removed with nothing to replace them.
                if (metasync_otto_social_output_enabled()) {
                    $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Open_Graph\Description_Presenter';
                    $presenters_to_remove[] = 'Yoast\WP\SEO\Presenters\Twitter\Description_Presenter';
                }
            }

            foreach ($presenters as $key => $presenter) {
                // Safely get class name, suppressing autoload errors
                // This prevents warnings when Composer autoloader tries to load deprecated Yoast files
                $class_name = is_object($presenter) ? @get_class($presenter) : '';

                if (!empty($class_name) && in_array($class_name, $presenters_to_remove)) {
                    unset($presenters[$key]);
                }
            }
            return $presenters;
        }, 999);
    }
    
    # Disable Rank Math
    if (is_plugin_active('seo-by-rank-math/rank-math.php') ||
        is_plugin_active('seo-by-rankmath/rank-math.php')) {

        # TITLE: Never blank Rank Math's title output during SSR fetch.
        # rank_math/frontend/title also gates Rank Math's og:title/twitter:title
        # output, so forcing it empty blanks those social tags along with the
        # document title. Let Rank Math render its own title/og/twitter tags;
        # OTTO's replace_title() and the dedup/precedence pass clean up duplicates.

        if ($block_description) {
            add_filter('rank_math/frontend/description', '__return_false', 999);
            add_filter('rank_math/frontend/show_keywords', '__return_false', 999);
        }
    }

    # Disable AIOSEO (free and pro)
    if (is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') ||
        is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php')) {

        if ($block_title) {
            # The document title belongs to the SEO title feature, which has its
            # own setting; only the og:/twitter: unsets below are social.
            add_filter('aioseo_title', '__return_empty_string', 999);

            if (metasync_otto_social_output_enabled()) {
                add_filter('aioseo_facebook_tags', function($meta) {
                    if (is_array($meta)) { unset($meta['og:title']); }
                    return $meta;
                }, 999);
                add_filter('aioseo_twitter_tags', function($meta) {
                    if (is_array($meta)) { unset($meta['twitter:title']); }
                    return $meta;
                }, 999);
            }
        }

        if ($block_description) {
            # Use granular tag list when available to only block what OTTO provides
            $tags = !empty($description_tags) ? $description_tags : [];
            $block_standard  = empty($tags) || in_array('meta[name=description]', $tags);
            # Social tags only get blocked while MetaSync is still emitting them.
            $social_enabled  = metasync_otto_social_output_enabled();
            $block_og_desc   = $social_enabled && (empty($tags) || in_array('meta[property=og:description]', $tags));
            $block_tw_desc   = $social_enabled && (empty($tags) || in_array('meta[name=twitter:description]', $tags));

            if ($block_standard) {
                add_filter('aioseo_description', '__return_empty_string', 999);
            }
            if ($block_og_desc) {
                add_filter('aioseo_facebook_tags', function($meta) {
                    if (is_array($meta)) { unset($meta['og:description']); }
                    return $meta;
                }, 999);
            }
            if ($block_tw_desc) {
                add_filter('aioseo_twitter_tags', function($meta) {
                    if (is_array($meta)) { unset($meta['twitter:description']); }
                    return $meta;
                }, 999);
            }
        }
    }
}
# Check whether OTTO is ALSO injected via JavaScript on the site (a misconfiguration
# we warn admins about).
#
# The actual detection makes a loopback HTTP request to the site's OWN url. On hosts
# that disallow same-server loopback (e.g. SiteGround), that request blocks for the
# full timeout and then fails — and when it runs inline on admin_notices it makes
# every wp-admin page hang. It must therefore NEVER run inline on an admin request.
#
# metasync_check_otto_js() is now read-only: it returns the cached result and, on a
# cache miss, schedules a one-off BACKGROUND job (WP-Cron) to compute it. The notice
# simply shows nothing until the background result is available.
function metasync_check_otto_js(){

    $cache_key = 'metasync_otto_js_detected';
    $cached = get_transient($cache_key);

    if ($cached !== false) {
        return $cached === 'yes';
    }

    # No cached result yet — compute it off the request so we never block admin
    # with a loopback call. Show nothing this load.
    metasync_schedule_otto_js_check();
    return false;
};

# Queue the background loopback detection if it isn't already scheduled.
function metasync_schedule_otto_js_check(){
    if (!wp_next_scheduled('metasync_otto_js_check_event')) {
        wp_schedule_single_event(time() + 5, 'metasync_otto_js_check_event');
    }
}

# Background worker (runs in WP-Cron context, NOT on the admin request): performs the
# blocking loopback request and caches the result. Hooked to metasync_otto_js_check_event.
function metasync_run_otto_js_check(){
    $cache_key = 'metasync_otto_js_detected';

    # the site url with the internal-fetch marker so OTTO does not re-process it
    $site_url = site_url() . '?is_otto_page_fetch=1';

    $page_data = wp_remote_get($site_url, array('timeout' => 5, 'sslverify' => false));

    if (is_wp_error($page_data)) {
        # Loopback failed (host likely blocks same-server requests). Cache "no" so we
        # don't keep re-queuing the check on every admin page load.
        set_transient($cache_key, 'no', HOUR_IN_SECONDS);
        return;
    }

    $body = wp_remote_retrieve_body($page_data);

    # Detect the OTTO script via a lightweight regex instead of parsing
    # the entire fetched page into a SimpleHtmlDom tree (which exhausted memory
    # on large pages — this check runs on admin page loads). We only need to
    # confirm a <script id="sa-dynamic-optimization" ... data-uuid="..."> exists.
    $found = preg_match(
        '/<script\b[^>]*\bid=["\']sa-dynamic-optimization["\'][^>]*\bdata-uuid=["\'][^"\']+["\']/i',
        (string) $body
    )
    || preg_match(
        # Attribute order may vary (data-uuid before id)
        '/<script\b[^>]*\bdata-uuid=["\'][^"\']+["\'][^>]*\bid=["\']sa-dynamic-optimization["\']/i',
        (string) $body
    );

    if ($found) {
        set_transient($cache_key, 'yes', 12 * HOUR_IN_SECONDS);
        return;
    }

    set_transient($cache_key, 'no', 12 * HOUR_IN_SECONDS);
}

# Register the background worker hook.
add_action('metasync_otto_js_check_event', 'metasync_run_otto_js_check');

# Handle AJAX Clear Cache request
# NOTE: Cache system removed - this is now a no-op
function metasync_clear_otto_cache_handler() {
    # State-changing admin action: verify the nonce sent by admin/js
    # (clear_otto_caches) and confirm plugin access before clearing the
    # detection transient or queueing the loopback re-check.
    check_ajax_referer('metasync_clear_otto_cache', 'nonce');

    if (!Metasync::current_user_has_plugin_access()) {
        wp_send_json_error(['message' => 'Not authorized']);
    }

    if (!empty($_GET['clear_otto_cache'])) {
        delete_transient('metasync_otto_js_detected');
        # Re-run the JS detection in the background so the notice refreshes.
        metasync_schedule_otto_js_check();
        # Cache system has been removed - no cache to clear
        wp_send_json_success(['message' => 'Cache system removed - all pages processed in real-time']);
    }
    else {
        wp_send_json_error(['message' => 'Missing parameter']);
    }
}

# Clear cache hook
add_action('wp_ajax_metasync_clear_otto_cache', 'metasync_clear_otto_cache_handler');

# add admin action to check script
function metasync_show_otto_ssr_notice() {
    if (!Metasync::current_user_has_plugin_access()) {
        return; // Only show to admins
    }

    # Get the plugin name using centralized method
    $plugin_name = Metasync::get_effective_plugin_name();
    $whitelabel_otto_name = Metasync::get_whitelabel_otto_name();
    if (metasync_check_otto_js()) {

        # Show admin notice with plugin name included in the message
        echo '<div class="notice notice-error">
                 <p><b>Warning from ' . esc_html($plugin_name) . '</b>
                    <br>
                    ' . esc_html($whitelabel_otto_name) . ' JavaScript has been detected on your site. Please remove it and configure ' . esc_html($whitelabel_otto_name) . ' for Wordpress. Contact support for help
                </p>
         </div>';
    }
}

add_action('admin_notices', 'metasync_show_otto_ssr_notice');


# staging dummy change
# load otto in the wp hook
add_action('wp', 'metasync_start_otto');

  # ENHANCED OTTO SEO INTEGRATION
  # Register async SEO processing hook
  add_action('metasync_process_seo_job', 'metasync_process_otto_seo_data', 10, 4);
  add_action('metasync_process_otto_crawl_url_job', 'metasync_handle_otto_crawl_url_job', 10, 2);
  add_action('metasync_process_otto_batch_cache_job', 'metasync_handle_otto_batch_cache_job', 10, 2);
  add_action('metasync_otto_pending_drainer', 'metasync_handle_otto_pending_drainer');

  # Process OTTO SEO data and update WordPress meta fields for SEO plugins
  # This function now runs asynchronously via WordPress cron system
  #
  # Returns one of the Metasync_Otto_Job_Status::OUTCOME_* strings so callers
  # can distinguish success, confirmed no-change, retryable failure, permanent
  # failure, and CPU deferral — a bare boolean cannot drive correct retry and
  # purge decisions.
  #
  # @param string $route         Fully-qualified URL to process.
  # @param bool   $allow_defer   When false, skip CPU-deferral rescheduling (used when
  #                               called synchronously from crawl_url_job whose own retry
  #                               mechanism already handles failures).
  # @param int    $deferral_count How many times this job has already been deferred for
  #                               CPU load. Prevents infinite reschedule loops.
  # @param array|null $prefetched_seo_data Suggestions already fetched by the transient
  #                               warm step. When an array it is used as-is so the job
  #                               makes exactly ONE API call per URL; the endpoint is
  #                               only fetched here when no prefetch was provided.

function metasync_process_otto_seo_data($route, $allow_defer = true, $deferral_count = 0, $prefetched_seo_data = null) {
    # Maximum number of times a job can be deferred before it is dropped.
    $max_deferrals = defined('METASYNC_SEO_JOB_MAX_DEFERRALS') ? METASYNC_SEO_JOB_MAX_DEFERRALS : 5;
    $lock_key      = null;
    $lock_acquired = false;

    try {
        # Validate input
        if (empty($route) || !is_string($route)) {
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # CPU load check — defer if server is under load.
        # Only reschedule when called from the cron hook (allow_defer=true) and
        # we haven't exceeded the maximum deferral count.
        # Checked BEFORE acquiring the lock so that deferred jobs don't
        # acquire-then-immediately-release it (which defeats concurrency protection).
        if (!Metasync_CPU_Monitor::is_load_safe()) {
            if ($allow_defer) {
                if ($deferral_count < $max_deferrals) {
                    metasync_otto_schedule_single_event(
                        time() + 60,
                        'metasync_process_seo_job',
                        array($route, true, $deferral_count + 1)
                    );
                    return Metasync_Otto_Job_Status::OUTCOME_DEFERRED;
                }
                # deferral budget exhausted — RUN the job late instead
                # of dropping it. The write is idempotent and bounded, and a
                # late sync is strictly better than SEO data that never
                # arrives. No new cron events are created, so the
                # anti-pile-up guarantee is preserved. Fall through.
            } else {
                # allow_defer=false (sync path): report retryable without creating
                # cron events — the crawl-url job's retry mechanism (or OTTO's
                # next crawl) re-triggers this URL.
                return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
            }
        }

        # Concurrency lock: prevent multiple SEO jobs from running simultaneously.
        # Uses a per-URL transient lock with a 120s TTL as a safety net (the lock is
        # explicitly deleted on completion). If the lock exists another run is already
        # processing this URL — reschedule once with a short delay instead of stacking.
        $lock_key = 'metasync_seo_lock_' . md5($route);
        if (get_transient($lock_key) !== false) {
            if ($allow_defer && $deferral_count < $max_deferrals) {
                metasync_otto_schedule_single_event(
                    time() + 30,
                    'metasync_process_seo_job',
                    array($route, true, $deferral_count + 1)
                );
            }
            return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
        }
        set_transient($lock_key, true, 120);
        $lock_acquired = true;

        # Resolve redirect table: use final destination URL before 404 checks and OTTO processing
        $route = metasync_otto_resolve_redirect_to_final_url($route);

        # Skip excluded URLs - don't process SEO data for them
        if (metasync_is_otto_url_excluded($route)) {
            //error_log('MetaSync OTTO: Skipping SEO processing for excluded URL: ' . $route);
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # Pre-flight 404 check: exclude URLs that would return 404 before making API call
        if (!metasync_otto_is_url_available($route)) {
            metasync_otto_auto_exclude_404_url($route);
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # Get OTTO UUID from settings
        # OPTIMIZED: Use cached options
        $otto_uuid = Metasync_Otto_Config::get_otto_uuid();

        if (empty($otto_uuid)) {
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # Meta descriptions are always enabled by default - no check needed

        # Obtain the OTTO suggestions for this URL. When the caller already
        # warmed the transient cache (crawl-url job), those suggestions are
        # handed in and NO second API call is made — the endpoint, rate
        # limiter, and breaker are only hit once per URL.
        # An empty array is a legitimate answer ("OTTO holds nothing for this
        # URL") and must not be treated as a fetch failure; only a strict
        # false from the fetcher is a failure.
        if (is_array($prefetched_seo_data)) {
            $seo_data = $prefetched_seo_data;
        } else {
            $failure_class = null;
            $seo_data = metasync_fetch_otto_seo_data($route, $otto_uuid, $failure_class);
            if ($seo_data === false) {
                if ($failure_class === Metasync_Otto_Job_Status::OUTCOME_PERMANENT) {
                    # Auth/input-level rejection from the API — retrying the
                    # same request cannot help.
                    return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
                }
                # Timeout / 5xx / 429 / empty body / bad JSON: retryable.
                metasync_record_failed_action( 'metasync_process_seo_job' );
                return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
            }
        }

        # Mark this URL as crawled by OTTO for SSR
        # Extract domain and path from route
        $parsed_url = wp_parse_url($route);
        $domain_with_scheme = ($parsed_url['scheme'] ?? 'https') . '://' . ($parsed_url['host'] ?? '');
        $url_path = ($parsed_url['path'] ?? '/');

        # Create crawl data structure
        $crawl_data = array(
            'domain' => $domain_with_scheme,
            'urls' => array($url_path)
        );

        # Load Otto pixel class and save crawl data
        $otto_pixel = new Metasync_otto_pixel($otto_uuid);
        $otto_pixel->save_crawl_data($crawl_data);

        # Get WordPress post ID from URL
        $post_id = url_to_postid($route);

        # Special handling for WooCommerce shop page (url_to_postid doesn't work for it)
        if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
            # Check if this URL is the WooCommerce shop page
            $shop_page_id = wc_get_page_id('shop');
            if ($shop_page_id > 0) {
                $shop_url = get_permalink($shop_page_id);
                $route_normalized = rtrim($route, '/');
                $shop_url_normalized = rtrim($shop_url, '/');

                if ($route_normalized === $shop_url_normalized) {
                    $post_id = $shop_page_id;
                }
            }
        }

        # Try to find WooCommerce product by URL if url_to_postid failed
        if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
            # Extract product slug from URL
            $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));

            # Try to get product by slug
            $products = wc_get_products(array(
                'name' => $product_slug,
                'limit' => 1,
                'status' => 'publish',
            ));

            if (empty($products)) {
                # Fallback: try by slug using WP_Query
                $args = array(
                    'post_type' => 'product',
                    'name' => $product_slug,
                    'posts_per_page' => 1,
                    'post_status' => 'publish',
                );
                $query = new WP_Query($args);

                if ($query->have_posts()) {
                    $product_post = $query->posts[0];
                    $post_id = $product_post->ID;
                }
            } else {
                $product = $products[0];
                $post_id = $product->get_id();
            }
        }

        if (!$post_id || $post_id <= 0) {
            # Check if this is a category page
            if (strpos($route, '/category/') !== false) {
                # Extract category slug from URL
                $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
                $category = get_category_by_slug($category_slug);

                if ($category) {
                    # Check if category would return 404 before applying OTTO changes
                    if (metasync_would_term_return_404($category->term_id, 'category', $route)) {
                        metasync_otto_auto_exclude_404_url($route);
                        return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
                    }
                    
                    # Update comprehensive category SEO meta fields
                    $update_result = metasync_update_comprehensive_category_seo_fields($category->term_id, $seo_data);

                    if ($update_result['updated']) {
                        # Prepare trimmed values to 30 characters
                        $trim = function($value) {
                            if ($value === null) { return ''; }
                            $value = (string) $value;
                            $value = trim($value);
                            if (mb_strlen($value) > 30) {
                                return mb_substr($value, 0, 30);
                            }
                            return $value;
                        };

                        # Log individual field updates for category
                        foreach ($update_result['fields_updated'] as $field_type => $field_value) {
                            $short = '';
                            $title = '';

                            switch ($field_type) {
                                case 'meta_title':
                                    $short = $trim($field_value);
                                    $title = "Category Meta Title Update ({$short}...)";
                                    break;
                                case 'meta_description':
                                    $short = $trim($field_value);
                                    $title = "Category Meta Description Update ({$short}...)";
                                    break;
                                case 'meta_keywords':
                                    $short = $trim($field_value);
                                    $title = "Category Meta Keywords Update ({$short}...)";
                                    break;
                                case 'og_title':
                                    $short = $trim($field_value);
                                    $title = "Category Open Graph Title Update ({$short}...)";
                                    break;
                                case 'og_description':
                                    $short = $trim($field_value);
                                    $title = "Category Open Graph Description Update ({$short}...)";
                                    break;
                                case 'twitter_title':
                                    $short = $trim($field_value);
                                    $title = "Category Twitter Title Update ({$short}...)";
                                    break;
                                case 'twitter_description':
                                    $short = $trim($field_value);
                                    $title = "Category Twitter Description Update ({$short}...)";
                                    break;
                                case 'image_alt_data':
                                    $image_count = count($field_value);
                                    $title = "Category Image Alt Text Update ({$image_count} images)";
                                    break;
                                case 'headings_data':
                                    $heading_count = count($field_value);
                                    $title = "Category Headings Update ({$heading_count} headings)";
                                    break;
                                case 'structured_data':
                                    $title = 'Category Structured Data Update';
                                    break;
                            }

                            if (is_scalar($field_value) && trim((string) $field_value) === '') {
                                $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
                            }

                            if (!empty($title)) {
                                metasync_log_sync_history([
                                    'title' => $title,
                                    'source' => 'OTTO SEO',
                                    'status' => 'published',
                                    'content_type' => 'Category SEO',
                                    'url' => $route,
                                    'meta_data' => json_encode([
                                        'field' => $field_type,
                                        'field_value' => $field_value,
                                        'category_id' => $category->term_id,
                                        'category_name' => $category->name
                                    ])
                                ]);
                            }
                        }

                        return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
                    }

                    return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
                }
            }

            # Check if this is a WooCommerce product category
            if (strpos($route, '/product-category/') !== false) {
                # Extract product category slug from URL
                $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
                $term = get_term_by('slug', $category_slug, 'product_cat');

                if ($term && !is_wp_error($term)) {
                    # Check if product category would return 404 before applying OTTO changes
                    if (metasync_would_term_return_404($term->term_id, 'product_cat', $route)) {
                        metasync_otto_auto_exclude_404_url($route);
                        return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
                    }
                    
                    # Update comprehensive taxonomy SEO meta fields
                    $update_result = metasync_update_comprehensive_taxonomy_seo_fields($term->term_id, 'product_cat', $seo_data);
                    
                    if ($update_result['updated']) {
                        # Prepare trimmed values to 30 characters
                        $trim = function($value) {
                            if ($value === null) { return ''; }
                            $value = (string) $value;
                            $value = trim($value);
                            if (mb_strlen($value) > 30) {
                                return mb_substr($value, 0, 30);
                            }
                            return $value;
                        };

                        # Log individual field updates for product category
                        foreach ($update_result['fields_updated'] as $field_type => $field_value) {
                            $short = '';
                            $title = '';

                            switch ($field_type) {
                                case 'meta_title':
                                    $short = $trim($field_value);
                                    $title = "Product Category Meta Title Update ({$short}...)";
                                    break;
                                case 'meta_description':
                                    $short = $trim($field_value);
                                    $title = "Product Category Meta Description Update ({$short}...)";
                                    break;
                                case 'meta_keywords':
                                    $short = $trim($field_value);
                                    $title = "Product Category Meta Keywords Update ({$short}...)";
                                    break;
                                case 'og_title':
                                    $short = $trim($field_value);
                                    $title = "Product Category Open Graph Title Update ({$short}...)";
                                    break;
                                case 'og_description':
                                    $short = $trim($field_value);
                                    $title = "Product Category Open Graph Description Update ({$short}...)";
                                    break;
                                case 'twitter_title':
                                    $short = $trim($field_value);
                                    $title = "Product Category Twitter Title Update ({$short}...)";
                                    break;
                                case 'twitter_description':
                                    $short = $trim($field_value);
                                    $title = "Product Category Twitter Description Update ({$short}...)";
                                    break;
                                case 'image_alt_data':
                                    $image_count = count($field_value);
                                    $title = "Product Category Image Alt Text Update ({$image_count} images)";
                                    break;
                                case 'headings_data':
                                    $heading_count = count($field_value);
                                    $title = "Product Category Headings Update ({$heading_count} headings)";
                                    break;
                                case 'structured_data':
                                    $title = 'Product Category Structured Data Update';
                                    break;
                            }

                            if (is_scalar($field_value) && trim((string) $field_value) === '') {
                                $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
                            }

                            if (!empty($title)) {
                                metasync_log_sync_history([
                                    'title' => $title,
                                    'source' => 'OTTO SEO',
                                    'status' => 'published',
                                    'content_type' => 'WooCommerce Product Category SEO',
                                    'url' => $route,
                                    'meta_data' => json_encode([
                                        'field' => $field_type,
                                        'field_value' => $field_value,
                                        'term_id' => $term->term_id,
                                        'term_name' => $term->name,
                                        'taxonomy' => 'product_cat'
                                    ])
                                ]);
                            }
                        }
                        
                        return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
                    }
                    
                    return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
                }
            }
            
            # Check if this is the home page (landing page)
            $site_url = rtrim(site_url(), '/');
            $route_clean = rtrim($route, '/');
            
            if ($route_clean === $site_url) {
                # Get the home page (front page)
                $front_page_id = get_option('page_on_front');
                $home_page = null;
                
                if ($front_page_id && $front_page_id > 0) {
                    $home_page = get_post($front_page_id);
                } else {
                    # If no static front page is set, get the latest post
                    $home_page = get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null;
                }
                
                if ($home_page) {
                    # Check if home page would return 404 before applying OTTO changes
                    if (metasync_would_page_return_404($home_page->ID, $route)) {
                        metasync_otto_auto_exclude_404_url($route);
                        return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
                    }
                    
                    # Update comprehensive home page SEO meta fields
                    $update_result = metasync_update_comprehensive_seo_fields($home_page->ID, $seo_data);
                    
                    if ($update_result['updated']) {
                        # Clear relevant caches
                        metasync_clear_post_seo_caches($home_page->ID);
                        
                        # Prepare trimmed values to 30 characters
                        $trim = function($value) {
                            if ($value === null) { return ''; }
                            $value = (string) $value;
                            $value = trim($value);
                            if (mb_strlen($value) > 30) {
                                return mb_substr($value, 0, 30);
                            }
                            return $value;
                        };

                        # Log individual field updates for home page
                        foreach ($update_result['fields_updated'] as $field_type => $field_value) {
                            $short = '';
                            $title = '';
                            
                            switch ($field_type) {
                                case 'meta_title':
                                    $short = $trim($field_value);
                                    $title = "Home Page Meta Title Update ({$short}...)";
                                    break;
                                case 'meta_description':
                                    $short = $trim($field_value);
                                    $title = "Home Page Meta Description Update ({$short}...)";
                                    break;
                                case 'meta_keywords':
                                    $short = $trim($field_value);
                                    $title = "Home Page Meta Keywords Update ({$short}...)";
                                    break;
                                case 'og_title':
                                    $short = $trim($field_value);
                                    $title = "Home Page Open Graph Title Update ({$short}...)";
                                    break;
                                case 'og_description':
                                    $short = $trim($field_value);
                                    $title = "Home Page Open Graph Description Update ({$short}...)";
                                    break;
                                case 'twitter_title':
                                    $short = $trim($field_value);
                                    $title = "Home Page Twitter Title Update ({$short}...)";
                                    break;
                                case 'twitter_description':
                                    $short = $trim($field_value);
                                    $title = "Home Page Twitter Description Update ({$short}...)";
                                    break;
                                case 'image_alt_data':
                                    $image_count = count($field_value);
                                    $title = "Home Page Image Alt Text Update ({$image_count} images)";
                                    break;
                                case 'headings_data':
                                    $heading_count = count($field_value);
                                    $title = "Home Page Headings Update ({$heading_count} headings)";
                                    break;
                                case 'structured_data':
                                    $title = 'Home Page Structured Data Update';
                                    break;
                            }

                            if (is_scalar($field_value) && trim((string) $field_value) === '') {
                                $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
                            }

                            if (!empty($title)) {
                                metasync_log_sync_history([
                                    'title' => $title,
                                    'source' => 'OTTO SEO',
                                    'status' => 'published',
                                    'content_type' => 'Home Page SEO',
                                    'url' => $route,
                                    'meta_data' => json_encode([
                                        'field' => $field_type,
                                        'field_value' => $field_value,
                                        'post_id' => $home_page->ID
                                    ])
                                ]);
                            }
                        }
                        
                        return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
                    }
                    
                    return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
                }
            }

            # Check if this is the blog/posts page (page_for_posts)
            $posts_page_id = intval(get_option('page_for_posts'));
            if ($posts_page_id > 0) {
                $posts_page = get_post($posts_page_id);
                $posts_page_url = rtrim(get_permalink($posts_page_id), '/');

                if ($posts_page && $route_clean === $posts_page_url) {
                    # Check if blog page would return 404
                    if (metasync_would_page_return_404($posts_page->ID, $route)) {
                        metasync_otto_auto_exclude_404_url($route);
                        return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
                    }

                    # Update comprehensive blog page SEO meta fields
                    $update_result = metasync_update_comprehensive_seo_fields($posts_page->ID, $seo_data);

                    if ($update_result['updated']) {
                        # Clear relevant caches
                        metasync_clear_post_seo_caches($posts_page->ID);

                        # Prepare trimmed values to 30 characters
                        $trim = function($value) {
                            if ($value === null) { return ''; }
                            $value = (string) $value;
                            $value = trim($value);
                            if (mb_strlen($value) > 30) {
                                return mb_substr($value, 0, 30);
                            }
                            return $value;
                        };

                        # Log individual field updates for blog page
                        foreach ($update_result['fields_updated'] as $field_type => $field_value) {
                            $short = '';
                            $title = '';

                            switch ($field_type) {
                                case 'meta_title':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Meta Title Update ({$short}...)";
                                    break;
                                case 'meta_description':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Meta Description Update ({$short}...)";
                                    break;
                                case 'meta_keywords':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Meta Keywords Update ({$short}...)";
                                    break;
                                case 'og_title':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Open Graph Title Update ({$short}...)";
                                    break;
                                case 'og_description':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Open Graph Description Update ({$short}...)";
                                    break;
                                case 'twitter_title':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Twitter Title Update ({$short}...)";
                                    break;
                                case 'twitter_description':
                                    $short = $trim($field_value);
                                    $title = "Blog Page Twitter Description Update ({$short}...)";
                                    break;
                                case 'image_alt_data':
                                    $image_count = count($field_value);
                                    $title = "Blog Page Image Alt Text Update ({$image_count} images)";
                                    break;
                                case 'headings_data':
                                    $heading_count = count($field_value);
                                    $title = "Blog Page Headings Update ({$heading_count} headings)";
                                    break;
                                case 'structured_data':
                                    $title = 'Blog Page Structured Data Update';
                                    break;
                            }

                            if (is_scalar($field_value) && trim((string) $field_value) === '') {
                                $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
                            }

                            if (!empty($title)) {
                                metasync_log_sync_history([
                                    'title' => $title,
                                    'source' => 'OTTO SEO',
                                    'status' => 'published',
                                    'content_type' => 'Blog Page SEO',
                                    'url' => $route,
                                    'meta_data' => json_encode([
                                        'field' => $field_type,
                                        'field_value' => $field_value,
                                        'post_id' => $posts_page->ID
                                    ])
                                ]);
                            }
                        }

                        return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
                    }

                    return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;
                }
            }

            # URL didn't resolve to any supported entity (post, category, home page, blog page)
            # Treat as 404 and auto-exclude (e.g. deleted post, non-existent page)
            if (!metasync_otto_is_url_available($route)) {
                metasync_otto_auto_exclude_404_url($route);
            }
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }
        
        # Verify this is actually a post, page, or WooCommerce product
        $post = get_post($post_id);

        # Get supported post types dynamically
        $supported_post_types = metasync_get_supported_post_types();

        if (!$post || !in_array($post->post_type, $supported_post_types)) {
            # Skip unsupported post types
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # Check if page would return 404 before applying OTTO changes
        if (metasync_would_page_return_404($post_id, $route)) {
            metasync_otto_auto_exclude_404_url($route);
            return Metasync_Otto_Job_Status::OUTCOME_PERMANENT;
        }

        # Update comprehensive SEO meta fields
        $update_result = metasync_update_comprehensive_seo_fields($post_id, $seo_data);

        if ($update_result['updated']) {
            # Clear relevant caches
            metasync_clear_post_seo_caches($post_id);

            # Prepare trimmed values to 30 characters
            $trim = function($value) {
                if ($value === null) { return ''; }
                $value = (string) $value;
                $value = trim($value);
                if (mb_strlen($value) > 30) {
                    return mb_substr($value, 0, 30);
                }
                return $value;
            };

            # Log individual field updates
            foreach ($update_result['fields_updated'] as $field_type => $field_value) {
                $short = '';
                $title = '';
                
                switch ($field_type) {
                    case 'meta_title':
                        $short = $trim($field_value);
                        $title = 'Meta Title Update (' . $short . '...)';
                        break;
                    case 'meta_description':
                        $short = $trim($field_value);
                        $title = 'Meta Description Update (' . $short . '...)';
                        break;
                    case 'meta_keywords':
                        $short = $trim($field_value);
                        $title = 'Meta Keywords Update (' . $short . '...)';
                        break;
                    case 'og_title':
                        $short = $trim($field_value);
                        $title = 'Open Graph Title Update (' . $short . '...)';
                        break;
                    case 'og_description':
                        $short = $trim($field_value);
                        $title = 'Open Graph Description Update (' . $short . '...)';
                        break;
                    case 'twitter_title':
                        $short = $trim($field_value);
                        $title = 'Twitter Title Update (' . $short . '...)';
                        break;
                    case 'twitter_description':
                        $short = $trim($field_value);
                        $title = 'Twitter Description Update (' . $short . '...)';
                        break;
                    case 'image_alt_data':
                        $image_count = count($field_value);
                        $title = "Image Alt Text Update ({$image_count} images)";
                        break;
                    case 'headings_data':
                        $heading_count = count($field_value);
                        $title = "Headings Update ({$heading_count} headings)";
                        break;
                    case 'structured_data':
                        $title = 'Structured Data Update';
                        break;
                }

                if (is_scalar($field_value) && trim((string) $field_value) === '') {
                    $title = str_replace(' Update (...)', ' Cleanup (previous value removed)', $title);
                }

                if (!empty($title)) {
                    metasync_log_sync_history([
                        'title' => $title,
                        'source' => 'OTTO SEO',
                        'status' => 'published',
                        'content_type' => 'SEO Meta',
                        'url' => $route,
                        'meta_data' => json_encode([
                            'field' => $field_type,
                            'field_value' => $field_value,
                            'post_id' => $post_id
                        ])
                    ]);
                }
            }

            return Metasync_Otto_Job_Status::OUTCOME_SUCCESS;
        }

        return Metasync_Otto_Job_Status::OUTCOME_NO_CHANGE;

    } catch (Exception $e) {
        metasync_record_failed_action( 'metasync_process_seo_job' );
        return Metasync_Otto_Job_Status::OUTCOME_RETRYABLE;
    } finally {
        # Release the concurrency lock only if we actually acquired it.
        # Early returns (CPU deferral, lock contention) must NOT delete a lock
        # that another process may be holding.
        if ($lock_acquired && $lock_key) {
            delete_transient($lock_key);
        }
    }
}

/**
 * Log sync history entry
 * @param array $data Sync data to log
 */
function metasync_log_sync_history($data) {
    try {
        // Classes are now autoloaded, no need for manual require
        $sync_db = new Metasync_Sync_History_Database();

        // Minimal duplicate prevention within short time window
        if (!empty($data['title']) && !empty($data['source'])) {
            global $wpdb;
            $recent = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- sync-history duplicate guard over the plugin custom table — write-path freshness required
                "SELECT COUNT(*) FROM `{$wpdb->prefix}metasync_sync_history` WHERE title = %s AND source = %s AND created_at >= %s",
                $data['title'],
                $data['source'],
                gmdate('Y-m-d H:i:s', time() - 60)
            ));
            if ((int)$recent > 0) {
                return; // skip duplicate log within 60 seconds
            }
        }

        $sync_db->add($data);

    } catch (Exception $e) {
        error_log("MetaSync: Failed to log sync history: " . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
    }
}

/**
 * Get supported post types for OTTO SEO optimization
 * Includes WooCommerce products if WooCommerce is active
 *
 * @return array List of supported post types
 */
function metasync_get_supported_post_types() {
    # Start with default post types
    $post_types = ['post', 'page'];

    # Add WooCommerce product post type if WooCommerce is active
    if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
        $post_types[] = 'product';
    }

    # Include all public custom post types (e.g. 'location', 'service', 'team', etc.)
    # so OTTO can write post meta for them during metasync_process_otto_seo_data().
    $custom_post_types = get_post_types(['public' => true, '_builtin' => false], 'names');
    if (!empty($custom_post_types)) {
        $post_types = array_merge($post_types, array_values($custom_post_types));
    }

    # Allow developers to filter supported post types
    $post_types = apply_filters('metasync_otto_supported_post_types', $post_types);

    return $post_types;
}

/**
 * Get supported taxonomies for OTTO SEO optimization
 * Includes WooCommerce product categories and tags if WooCommerce is active
 *
 * @return array List of supported taxonomies
 */
function metasync_get_supported_taxonomies() {
    # Start with default taxonomies
    $taxonomies = ['category'];

    # Add WooCommerce taxonomies if WooCommerce is active
    if (class_exists('WooCommerce') || function_exists('is_woocommerce')) {
        $taxonomies[] = 'product_cat';  # WooCommerce product categories
        $taxonomies[] = 'product_tag';  # WooCommerce product tags
    }

    # Allow developers to filter supported taxonomies
    $taxonomies = apply_filters('metasync_otto_supported_taxonomies', $taxonomies);

    return $taxonomies;
}

/**
 * Resolve a URL through the Redirect Manager table to its final destination (follows redirect chains).
 * Used before 404 checks and OTTO processing so the final canonical URL is used, not intermediate redirects.
 *
 * @param string $url Full URL (e.g. https://example.com/old-page)
 * @return string Final destination URL, or original $url if no redirect matches
 */
function metasync_otto_resolve_redirect_to_final_url($url)
{
    if (empty($url) || !is_string($url)) {
        return $url;
    }
    try {
        $db_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-database.php';
        $class_path = plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection.php';
        if (!file_exists($db_path) || !file_exists($class_path)) {
            return $url;
        }
        require_once $db_path;
        require_once $class_path;
        $db = new Metasync_Redirection_Database();
        $redirect = new Metasync_Redirection($db);
        return $redirect->resolve_url_to_final_destination($url, 10);
    } catch (Exception $e) {
        error_log('MetaSync OTTO: Redirect resolution failed for ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        return $url;
    }
}

/**
 * Auto-exclude a URL from OTTO with description "Auto-excluded: 404"
 * Called when a URL is detected as returning 404 so it won't be sent to OTTO again
 *
 * @param string $url Full URL to exclude (e.g. https://example.com/404-page)
 * @return bool|string True on success, false on failure, 'duplicate'/'reactivated' if already exists
 */
function metasync_otto_auto_exclude_404_url($url)
{
    if (empty($url) || !is_string($url)) {
        return false;
    }
    $url = filter_var($url, FILTER_SANITIZE_URL);
    $url = esc_url_raw($url);
    if (empty($url) || mb_strlen($url) > 2048) {
        return false;
    }
    try {
        require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
        $db = new Metasync_Otto_Excluded_URLs_Database();
        return $db->add([
            'url_pattern' => $url,
            'pattern_type' => 'exact',
            'description' => 'Auto-excluded: 404',
            'status' => 'active',
            'auto_excluded' => 1,
        ]);
    } catch (Exception $e) {
        error_log('MetaSync OTTO: Failed to auto-exclude 404 URL: ' . $url . ' - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
        return false;
    }
}

/**
 * Remove a URL from the OTTO auto-exclusion list.
 * Called when OTTO sends a webhook for a URL, confirming it is valid and crawlable.
 * Only removes records where auto_excluded = 1 (never removes manual exclusions).
 *
 * @param string $url Full URL to un-exclude (e.g. https://example.com/location/page)
 * @return bool True on success
 */
function metasync_otto_remove_auto_exclusion($url)
{
    if (empty($url) || !is_string($url)) {
        return false;
    }
    try {
        require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
        $db  = new Metasync_Otto_Excluded_URLs_Database();
        global $wpdb;
        # Normalize the same way is_url_excluded() does
        $url_normalized = rtrim(trim($url), '/');
        $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- webhook-path cleanup of auto-exclusions over the plugin custom table
            $wpdb->prepare(
                "SELECT id FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE url_pattern = %s AND auto_excluded = 1 AND status = 'active'",
                $url_normalized
            )
        );
        if (!empty($records)) {
            $ids = array_map(function ($r) { return (int) $r->id; }, $records);
            $db->delete($ids);
        }
        return true;
    } catch (Exception $e) {
        return false;
    }
}

/**
 * Check if a URL is MANUALLY excluded from OTTO (auto_excluded = 0).
 * Used at render time (metasync_start_otto) — auto-exclusions must NOT block
 * rendering because they are often false positives (e.g. custom post types that
 * url_to_postid() can't resolve). Auto-exclusions are only used to gate the
 * SEO meta-writing webhook path.
 *
 * @param string $url URL to check
 * @return bool True if URL has a manual exclusion
 */
function metasync_is_otto_url_manually_excluded($url)
{
    if (empty($url) || !is_string($url)) {
        return false;
    }
    try {
        require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
        global $wpdb;
        $url_normalized = rtrim(trim($url), '/');

        $records = get_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY);

        if ($records === false) {
            $records = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- render-path read IS cached via get/set_transient with invalidation on every write — wp_cache_* is not used because the payload must survive object-cache-only setups
                "SELECT url_pattern, pattern_type FROM `{$wpdb->prefix}metasync_otto_excluded_urls` WHERE status = 'active' AND (auto_excluded = 0 OR auto_excluded IS NULL) ORDER BY created_at DESC"
            );

            // Graceful recovery: auto_excluded column missing on pre-v2.7.4 installs.
            // Schema changes belong to the upgrade routines, never the render path:
            // degrade to "not excluded" so OTTO keeps rendering, and schedule a
            // one-shot background repair that runs the same column migration off
            // the visitor request (handled by metasync_repair_otto_excluded_schema).
            if ($wpdb->last_error && strpos($wpdb->last_error, 'auto_excluded') !== false) {
                if (false === wp_next_scheduled('metasync_repair_otto_excluded_schema')) {
                    wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'metasync_repair_otto_excluded_schema');
                }
                return false;
            }

            set_transient(METASYNC_OTTO_EXCLUDED_TRANSIENT_KEY, $records ?: [], METASYNC_OTTO_EXCLUDED_TRANSIENT_TTL);
        }

        if (empty($records)) {
            return false;
        }

        foreach ($records as $excluded) {
            # Delegate to the shared matcher so this gate supports every pattern type the
            # add handler accepts (exact|contain|start|end|regex). Keeping a second switch
            # here is what let `end` and `regex` exclusions be silently ignored on the
            # visitor render path while still applying on the queue/SEO-write path.
            # The raw pattern is passed through - the matcher normalizes it per type,
            # because a regex must keep the trailing slash that closes its delimiters.
            if (Metasync_Otto_Excluded_URLs_Database::pattern_matches($url_normalized, $excluded->url_pattern, $excluded->pattern_type)) {
                return true;
            }
        }
        return false;
    } catch (Exception $e) {
        return false;
    }
}

/**
 * Check if a URL is excluded from OTTO
 * @param string $url URL to check
 * @return bool True if URL is excluded, false otherwise
 */
function metasync_is_otto_url_excluded($url)
{
    try {
        // Load database class
        require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
        $db = new Metasync_Otto_Excluded_URLs_Database();

        // Check if URL is excluded
        return $db->is_url_excluded($url);

    } catch (Exception $e) {
        return false;
    }
}

/**
 * Check if a URL is now available (would NOT return 404)
 * Uses same resolution logic as metasync_process_otto_seo_data
 * Used when rechecking auto-excluded 404 URLs after 7 days
 *
 * @param string $url Full URL to check (e.g. https://example.com/page)
 * @return bool True if URL is accessible, false if it would return 404
 */
function metasync_otto_is_url_available($url)
{
    if (empty($url) || !is_string($url)) {
        return false;
    }

    $route = metasync_otto_resolve_redirect_to_final_url($url);
    $post_id = url_to_postid($route);

    # WooCommerce shop page
    if ((!$post_id || $post_id <= 0) && function_exists('wc_get_page_id')) {
        $shop_page_id = wc_get_page_id('shop');
        if ($shop_page_id > 0) {
            $shop_url = get_permalink($shop_page_id);
            if (rtrim($route, '/') === rtrim($shop_url, '/')) {
                $post_id = $shop_page_id;
            }
        }
    }

    # WooCommerce product by slug
    if ((!$post_id || $post_id <= 0) && strpos($route, '/product/') !== false && function_exists('wc_get_products')) {
        $product_slug = basename(wp_parse_url($route, PHP_URL_PATH));
        $products = wc_get_products(array('name' => $product_slug, 'limit' => 1, 'status' => 'publish'));
        if (!empty($products)) {
            $post_id = $products[0]->get_id();
        } else {
            $query = new WP_Query(array(
                'post_type' => 'product',
                'name' => $product_slug,
                'posts_per_page' => 1,
                'post_status' => 'publish',
            ));
            if ($query->have_posts()) {
                $post_id = $query->posts[0]->ID;
            }
        }
    }

    if ($post_id && $post_id > 0) {
        $post = get_post($post_id);
        # Accept any post type — custom post types (e.g. 'location', 'service') are valid URLs.
        # The old in_array check against metasync_get_supported_post_types() caused CPT URLs
        # to be wrongly auto-excluded as "404" pages.
        if ($post) {
            return !metasync_would_page_return_404($post_id, $route);
        }
    }

    # Category
    if (strpos($route, '/category/') !== false) {
        $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
        $category = get_category_by_slug($category_slug);
        if ($category) {
            return !metasync_would_term_return_404($category->term_id, 'category', $route);
        }
    }

    # WooCommerce product category
    if (strpos($route, '/product-category/') !== false) {
        $category_slug = basename(wp_parse_url($route, PHP_URL_PATH));
        $term = get_term_by('slug', $category_slug, 'product_cat');
        if ($term && !is_wp_error($term)) {
            return !metasync_would_term_return_404($term->term_id, 'product_cat', $route);
        }
    }

    # Home page
    if (rtrim($route, '/') === rtrim(site_url(), '/')) {
        $front_page_id = get_option('page_on_front');
        $home_page = ($front_page_id && $front_page_id > 0)
            ? get_post($front_page_id)
            : (get_posts(['numberposts' => 1, 'post_status' => 'publish'])[0] ?? null);
        if ($home_page) {
            return !metasync_would_page_return_404($home_page->ID, $route);
        }
    }

    # Could not verify availability from local data (custom archive, paginated page, etc.).
    # Assume the URL IS available — OTTO only crawls reachable URLs, so if we can't
    # prove it's a 404, we should not auto-exclude it.
    return true;
}

/**
 * Recheck auto-excluded 404 URLs when recheck_after has passed; remove from exclusion if now available
 * Uses recheck_after timestamp (default 7 days from exclusion) to decide when to recheck
 * Mark as permanent after 30 days if still 404 (no further rechecks)
 * Called by daily cron job
 */
function metasync_otto_recheck_404_exclusions()
{
    try {
        require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
        $db = new Metasync_Otto_Excluded_URLs_Database();
        $records = $db->get_auto_excluded_404_urls_due_for_recheck();

        if (empty($records)) {
            return;
        }

        $removed = 0;
        $marked_permanent = 0;
        $thirty_days_ago = strtotime('-30 days');
        $next_recheck = gmdate('Y-m-d H:i:s', current_time('timestamp') + 7 * DAY_IN_SECONDS);

        foreach ($records as $record) {
            $url = trim($record->url_pattern);
            if (empty($url)) {
                continue;
            }
            if (metasync_otto_is_url_available($url)) {
                $db->delete([$record->id]);
                $removed++;
            } else {
                # Still 404: if excluded 30+ days ago, mark as permanent (no more rechecks)
                $created_ts = strtotime($record->created_at);
                if ($created_ts <= $thirty_days_ago) {
                    $db->update(['is_permanent' => 1], $record->id);
                    $marked_permanent++;
                } else {
                    # Schedule next recheck in 7 days
                    $db->update(['recheck_after' => $next_recheck], $record->id);
                }
            }
        }

    } catch (Exception $e) {
        error_log('MetaSync OTTO: Recheck 404 exclusions failed - ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- genuine failure path, bounded, no secrets
    }
}

add_action('metasync_otto_recheck_404_exclusions', 'metasync_otto_recheck_404_exclusions');

/**
 * Background repair of the metasync_otto_excluded_urls schema (cron).
 *
 * Scheduled by metasync_is_otto_url_manually_excluded() when a render-path
 * read fails because the auto_excluded column is missing (pre-2.7.4 install
 * whose version migration did not run). Runs the DB layer's self-healing
 * column migration off the visitor request instead of ALTERing inline.
 */
function metasync_repair_otto_excluded_schema()
{
    require_once plugin_dir_path(__FILE__) . 'class-metasync-otto-excluded-urls-database.php';
    Metasync_Otto_Excluded_URLs_Database::ensure_schema();
}

add_action('metasync_repair_otto_excluded_schema', 'metasync_repair_otto_excluded_schema');

/**
 * Check if a post/page would return 404 without making HTTP request
 * Uses WordPress database checks for fast validation
 * 
 * @param int $post_id WordPress post ID
 * @param string $url The URL being checked (optional, for logging)
 * @return bool True if page would return 404, false if accessible
 */
function metasync_would_page_return_404($post_id, $url = '') {
    if (!$post_id || $post_id <= 0) {
        return true; // No post ID = 404
    }
    
    # Get the post object
    $post = get_post($post_id);
    if (!$post) {
        return true; // Post doesn't exist = 404
    }
    
    # 1. Check post status - must be 'publish' to be publicly accessible
    if ($post->post_status !== 'publish') {
        return true; // Draft, pending, private, etc. = 404
    }
    
    # 2. Check if post is password protected (requires password to view)
    if (!empty($post->post_password)) {
        # Password protected posts are not publicly accessible without password
        return true; // Password protected = effectively 404 for public
    }
    
    # 3. Check if post is in trash
    if ($post->post_status === 'trash') {
        return true; // Trashed = 404
    }
    
    # 4. Check if post type is publicly queryable
    $post_type_object = get_post_type_object($post->post_type);
    if ($post_type_object && !$post_type_object->publicly_queryable) {
        # Some post types might not be publicly accessible
        # But we allow if it's in our supported types
        $supported_post_types = metasync_get_supported_post_types();
        if (!in_array($post->post_type, $supported_post_types)) {
            return true; // Not publicly queryable = 404
        }
    }
    
    # 5. WordPress 5.7+ has a built-in function for this
    if (function_exists('is_post_publicly_viewable')) {
        if (!is_post_publicly_viewable($post)) {
            return true; // Not publicly viewable = 404
        }
    }
    
    # 6. Check if post is scheduled for future (not yet published)
    if ($post->post_date > current_time('mysql')) {
        return true; // Future post = 404 until publish date
    }
    
    # All checks passed - page should be accessible
    return false;
}

/**
 * Check if a taxonomy term (category, tag, etc.) would return 404
 * Uses WordPress database checks for fast validation
 * 
 * @param int $term_id Term ID
 * @param string $taxonomy Taxonomy name (e.g., 'category', 'product_cat')
 * @param string $url The URL being checked (optional, for logging)
 * @return bool True if term would return 404, false if accessible
 */
function metasync_would_term_return_404($term_id, $taxonomy, $url = '') {
    if (!$term_id || $term_id <= 0 || empty($taxonomy)) {
        return true; // Invalid term = 404
    }
    
    # Get the term object
    $term = get_term($term_id, $taxonomy);
    if (is_wp_error($term) || !$term) {
        return true; // Term doesn't exist = 404
    }
    
    # Check if taxonomy is publicly queryable
    $taxonomy_object = get_taxonomy($taxonomy);
    if (!$taxonomy_object || !$taxonomy_object->public) {
        # Check if it's in our supported taxonomies
        $supported_taxonomies = metasync_get_supported_taxonomies();
        if (!in_array($taxonomy, $supported_taxonomies)) {
            return true; // Not publicly queryable = 404
        }
    }
    
    # Terms are generally always accessible if they exist and taxonomy is public
    # WordPress doesn't have a "draft" status for terms like posts do
    # But we can check if the term has a count (has posts assigned)
    # Empty terms might not be useful, but they're still accessible
    
    # All checks passed - term should be accessible
    return false;
}

/**
 * Invalidate Brizy posts cache when posts are saved
 * OPTIMIZATION: Clears transient cache to ensure accurate detection
 */
add_action('save_post', function($post_id) {
    # Check if this post has Brizy metadata
    if (get_post_meta($post_id, 'brizy_post_uid', true)) {
        delete_transient('metasync_has_brizy_posts');
    }
}, 10, 1);

/**
 * Identify infrastructure agents that must never hit the render throttle.
 *
 * Two classes of agent are covered:
 *
 *  - Page-cache warmers. A host preloader (WP Cloud PageCacheBot, WP Rocket preload,
 *    LiteSpeed, SG Optimizer, ...) exists purely to populate the page cache. Throttling
 *    one returns un-OTTO'd HTML *and* defines DONOTCACHEPAGE, so the cache can never be
 *    filled and every subsequent human visitor pays a full uncached render. Measured on
 *    the reporting site: desktop cache MISS on 17/17 requests at ~2.8s TTFB, versus
 *    ~0.56s with OTTO disabled.
 *
 *  - Synthetic performance auditors (Lighthouse / PageSpeed Insights / GTmetrix).
 *    These are what customers measure with, so they must receive the same fully
 *    rendered OTTO output a real visitor gets. Currently `/lighthouse/i` and
 *    `/pagespeed/i` are generic-bot patterns, so a second PSI run inside the 5-minute
 *    window is served the throttled, un-OTTO'd, uncacheable page.
 *
 * Matching is done on the raw user-agent, deliberately independent of the bot-name
 * categorisation in Metasync_Otto_Bot_Detector.
 *
 * @param mixed $detection Result of Metasync_Otto_Bot_Detector::detect(), or malformed input.
 * @return bool True when this request must bypass the render throttle.
 */
function metasync_otto_is_unthrottled_infrastructure_agent( $detection = array() ) {

	$ua = '';
	if ( is_array( $detection ) && ! empty( $detection['user_agent'] ) ) {
		$ua = (string) $detection['user_agent'];
	} elseif ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
		$ua = (string) $_SERVER['HTTP_USER_AGENT'];
	}

	$needles = array(
		// Page-cache preloaders.
		'pagecachebot',   // WP Cloud / Automattic (PageCacheBotDesktop|Mobile)
		'wp rocket',      // WP Rocket preload
		'wprocket',
		'lscache_runner', // LiteSpeed Cache crawler
		'sg-optimizer',   // SiteGround
		'sgoptimizer',
		'nitropack',
		'cache-warmer',
		'cachewarmer',
		'cache warmer',
		// Synthetic performance auditors.
		'lighthouse',
		'pagespeed',
		'gtmetrix',
		'pingdom',
		'webpagetest',
	);

	$is_infra = false;

	if ( '' !== $ua ) {
		$ua_lower = strtolower( $ua );
		foreach ( $needles as $needle ) {
			if ( false !== strpos( $ua_lower, $needle ) ) {
				$is_infra = true;
				break;
			}
		}
	}

	// WP Cloud's warmer also tags its requests with a query marker.
	if ( ! $is_infra && isset( $_GET['x-cache-engine'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only flag for render throttling
		$is_infra = true;
	}

	/**
	 * Allow a site or host to declare additional preload agents.
	 *
	 * @param bool   $is_infra Whether this request bypasses the OTTO render throttle.
	 * @param string $ua       The request user-agent.
	 */
	if ( function_exists( 'apply_filters' ) ) {
		$is_infra = (bool) apply_filters( 'metasync_otto_unthrottled_agent', $is_infra, $ua );
	}

	return $is_infra;
}

```
