<?php
/**
 * Access control for llms.txt generation.
 *
 * @package Website_LLMs_txt
 */

if (!defined('ABSPATH')) {
    exit;
}

final class LLMS_Access {

    /**
     * Bytes of meta_value the candidate query returns per row.
     */
    const META_VALUE_LIMIT = 100;

    /** Depth guard for ancestor walks, mirroring Members' own guard of 100. */
    const ANCESTOR_DEPTH = 100;

    /**
     * Where the two settings this class reads live, and what they are called.
     */
    const SETTINGS_OPTION = 'llms_generator_settings';
    const PRECISE_SETTING = 'precise_access';
    const INCLUDE_META    = '_llmstxt_force_include';

    /**
     * Depth guard for Content Control's condition tree.
     */
    const CC_FLATTEN_DEPTH = 20;

    /**
     * Every memo in this class is keyed by blog id.
     */

    /** @var array blog_id => array Request scoped memo of readers, options, meta and sets. */
    private static $ctx = array();

    /** @var array blog_id => bool Tier 2 site wide verdict, computed once per run. */
    private static $site_gated = array();

    /** @var array blog_id => string What made site_is_gated() true, for the admin summary. */
    private static $gate_source = array();

    /** @var array blog_id => bool Is the precise per plugin path switched on for this site. */
    private static $precise = array();

    /** @var array blog_id => [ key => label ] Gating plugins loaded, by symbol. Blunt path. */
    private static $blunt = array();

    /** @var array blog_id => array WooCommerce coming soon verdict. */
    private static $wc_gate = array();

    /** @var array blog_id => [ post_id => true ] Posts the owner asserted are public. */
    private static $forced = array();

    /** @var array blog_id => array Tier 2 + tier 3 post type verdicts, computed once per run. */
    private static $gated_types = array();

    /** @var array blog_id => [ post_id => post_parent ], filled in batches by prime_parents(). */
    private static $parents = array();

    /** @var array blog_id => array Parsed wc_memberships_rules, shared by the gate and the set builder. */
    private static $wc_rules = array();

    /** @var array blog_id => array Tier 3 verdicts. */
    private static $rua_detect = array();

    /** @var array blog_id => array Tier 3 verdicts. */
    private static $cc_detect = array();

    /** @var array blog_id => bool A read this class depends on failed, so nothing is known. */
    private static $failed = array();

    /**
     * Machine readable record of why content was withheld, for the admin summary.
     *
     * @var array blog_id => [ [ code, detail, count ] ]
     */
    private static $reasons = array();

    /**
     * Which post ids have already been counted under which reason.
     *
     * @var array blog_id => [ 'code|detail' => [ post_id => true ] ]
     */
    private static $reason_ids = array();

    /** @var int|null Blog the request was bootstrapped on, which is what decides plugin loading. */
    private static $boot_blog = null;

    /**
     * @var array|null Gating plugins active on the bootstrap blog, read once per request.
     */
    private static $boot_gating = null;

    /** @var bool Whether flush() has been hooked to switch_blog yet. */
    private static $switch_hooked = false;

    /**
     * Batch predicate. The only entry point the generator calls.
     *
     * @param int[] $post_ids
     * @return array [ post_id => bool ] true when an anonymous visitor can read it.
     */
    public static function filter_publicly_readable(array $post_ids)
    {
        self::hook_switch_blog();

        $ids = array();
        foreach ($post_ids as $raw) {
            $id = absint($raw);
            if ($id) {
                $ids[$id] = $id;
            }
        }
        $ids = array_values($ids);
        if (!$ids) {
            return array();
        }

        $out = array();
        foreach ($ids as $id) {
            $out[$id] = true;
        }

        $blocked = self::cannot_determine();
        if ($blocked) {
            self::record_reason($blocked['code'], $blocked['detail']);
            return self::withhold_all($out);
        }

        $precise = self::precise_access();

        // The DETERMINISTIC gate, and it runs before anything the include assertion can
        // reach. An ID we cannot resolve is not demonstrably public, so it fails closed.
        $posts = self::posts_for_ids($ids);
        foreach ($ids as $id) {
            if (!isset($posts[$id])) {
                $out[$id] = false;
            }
        }

        // Tier 2 site wide. Nothing on the site is publicly readable, whatever any post's
        // own meta says.
        $site_gated = self::site_is_gated();
        $exempt     = ($site_gated && $precise) ? self::site_gate_exempt() : array();

        $gated_types = self::gated_post_types();
        $wc          = self::wc_coming_soon();

        // The blunt fallback. Symbol detection only: no candidate query, no set builder,
        // no rule parsing, no scope narrowing.
        $blunt = $precise ? self::unreadable_gating_plugins() : self::blunt_gating_plugins();

        $ctx = array();
        if ($precise) {
            $ctx = self::context();

            if ($ctx['readers']) {
                self::prime_parents($ids, $posts);
            }
        }

        // Fail the WHOLE batch closed when any read this class depends on failed.
        if (!empty(self::$failed[self::blog_key()])) {
            return self::withhold_all($out);
        }

        // Defence in depth on the memo keying above. If the context was built for
        // another blog we do not know what this blog restricts.
        if ($precise) {
            $blog = self::blog_key();
            if (!isset($ctx['blog_id']) || $ctx['blog_id'] !== $blog) {
                self::record_reason('blog_mismatch', 'context built for blog ' . (isset($ctx['blog_id']) ? $ctx['blog_id'] : 'unknown') . ', called for blog ' . $blog);
                return self::withhold_all($out);
            }
        }

        $excluded = array();

        foreach ($ids as $id) {
            // Already gone, and gone deterministically. Not reachable by the assertion.
            if (!$out[$id]) {
                continue;
            }

            $post = $posts[$id];
            $type = $post['post_type'];

            if ($site_gated && !isset($exempt[$id])) {
                $excluded[$id] = array(
                    'site_gate_withheld',
                    isset(self::$gate_source[self::blog_key()]) ? self::$gate_source[self::blog_key()] : 'a site wide gate is configured',
                );
                continue;
            }

            if (isset($gated_types[$type])) {
                $excluded[$id] = array('post_type_gated', $type . ': ' . $gated_types[$type]);
                continue;
            }

            if (isset($wc['page_ids'][$id])) {
                $excluded[$id] = array('post_type_gated', $type . ': ' . $wc['detail'] . ' (store page)');
                continue;
            }

            if ($blunt) {
                $excluded[$id] = array(
                    'blunt_exclusion',
                    implode(', ', $blunt) . ($precise
                        ? ' is active and has no per post reader, so content is excluded by default'
                        : ' is active, so content is excluded by default'),
                );
                continue;
            }

            if (!$precise) {
                continue;
            }

            foreach ($ctx['readers'] as $reader) {
                if (empty($reader['callback']) || !is_callable($reader['callback'])) {
                    continue;
                }
                $restricted = false;
                try {
                    $restricted = (bool) call_user_func($reader['callback'], $id, $post, $ctx);
                } catch (\Throwable $e) {
                    $restricted = false;
                    self::record_reason(
                        'reader_threw',
                        (isset($reader['label']) ? $reader['label'] : 'reader') . ': ' . $e->getMessage(),
                        $id
                    );
                }
                if ($restricted) {
                    $excluded[$id] = array(
                        'reader_restricted',
                        (isset($reader['label']) ? $reader['label'] : 'reader') . ': restriction evidence on this post',
                    );
                    break;
                }
            }
        }

        if ($excluded) {
            $forced = self::forced_include_set();

            foreach ($excluded as $id => $why) {
                if (isset($forced[$id])) {
                    self::record_reason(
                        'force_included',
                        $posts[$id]['post_type'] . ': kept by the per post include setting',
                        $id
                    );
                    continue;
                }
                $out[$id] = false;
                self::record_reason($why[0], $why[1], $id);
            }
        }

        // AND only. This filter may turn true into false and never the reverse, so a
        // third party cannot use it to re-open drafts, password protected posts or
        // membership restricted bodies. Deliberate inclusion is a separate, differently
        // named opt in and is not this filter's job.
        foreach ($ids as $id) {
            if ($out[$id]) {
                $out[$id] = (bool) apply_filters(
                    'llms_post_is_publicly_readable',
                    true,
                    $id,
                    isset($posts[$id]) ? $posts[$id]['post_type'] : ''
                );
            }
        }

        return $out;
    }

    /**
     * Tier 2 site wide short circuit, computed once per generation run.
     *
     * @return bool
     */
    public static function site_is_gated()
    {
        self::hook_switch_blog();

        $blog = self::blog_key();
        if (isset(self::$site_gated[$blog])) {
            return self::$site_gated[$blog];
        }

        // Asked before the registries are complete. Do not memoize a verdict computed
        // against a half built registry, and fail closed.
        $blocked = self::cannot_determine();
        if ($blocked) {
            self::record_reason($blocked['code'], $blocked['detail']);
            return true;
        }

        try {
            $gated  = false;
            $source = '';

            if (self::plugin_active('members')) {
                $settings = self::option_array('members_settings');
                if (!empty($settings['private_blog'])) {
                    $gated  = true;
                    $source = 'Members: private site mode';
                }
            }

            if (!$gated && self::plugin_active('um')) {
                $opts = self::option_array('um_options');
                if (isset($opts['accessible']) && 2 == $opts['accessible']) {
                    $gated  = true;
                    $source = 'Ultimate Member: Global Site Access is set to logged in users';
                }
            }

            if (!$gated) {
                $wc = self::wc_coming_soon();
                if ('site' === $wc['mode']) {
                    $gated  = true;
                    $source = $wc['detail'];
                }
            }

            if ($gated) {
                self::record_reason('site_wide_gate', $source);
                self::$gate_source[$blog] = $source;
            }
        } catch (\Throwable $e) {
            self::record_reason('site_gate_threw', $e->getMessage());
            return true;
        }

        self::$site_gated[$blog] = $gated;
        return self::$site_gated[$blog];
    }

    /**
     * Post IDs that stay readable even though site_is_gated() is true.
     *
     * @return array [ post_id => true ]
     */
    private static function site_gate_exempt()
    {
        $exempt = array();

        if (!self::plugin_active('um')) {
            return $exempt;
        }

        $opts = self::option_array('um_options');
        if (!isset($opts['accessible']) || 2 != $opts['accessible']) {
            return $exempt;
        }

        $count = 0;

        if (!empty($opts['home_page_accessible'])) {
            $count++;
            if ('page' === get_option('show_on_front')) {
                $front = absint(get_option('page_on_front'));
                if ($front) {
                    $exempt[$front] = true;
                }
            }
        }

        if (!empty($opts['category_page_accessible'])) {
            $count++;
        }

        $uris = array();
        if (isset($opts['access_redirect'])) {
            $uris[] = $opts['access_redirect'];
        }
        if (!empty($opts['access_exclude_uris']) && is_array($opts['access_exclude_uris'])) {
            foreach ($opts['access_exclude_uris'] as $uri) {
                $uris[] = $uri;
            }
        }

        foreach ($uris as $uri) {
            $uri = trim((string) $uri);
            if ('' === $uri) {
                continue;
            }
            $count++;
            $id = absint(self::safe_call('url_to_postid', array($uri), 0));
            if ($id) {
                $exempt[$id] = true;
            }
        }

        $own = self::um_site_gate_exempt_ids();
        foreach ($own as $id => $unused) {
            $exempt[$id] = true;
        }

        if ($count || $own) {
            self::record_reason(
                'um_site_gate_exceptions',
                'Ultimate Member Global Site Access is on with ' . $count . ' exception(s) configured; '
                    . count($exempt) . ' resolved to a post id and stay readable, of which '
                    . count($own) . ' carry their own restriction settings and are decided per post'
            );
        }

        return $exempt;
    }

    /**
     * Post IDs whose own Ultimate Member restriction settings decide their access, so
     * Global Site Access never applies to them.
     *
     * @return array [ post_id => true ]
     */
    private static function um_site_gate_exempt_ids()
    {
        global $wpdb;

        $opts   = self::option_array('um_options');
        $exempt = array();

        // Post level. Truthy post types only, same polarity as the reader.
        $types = isset($opts['restricted_access_post_metabox']) && is_array($opts['restricted_access_post_metabox'])
            ? $opts['restricted_access_post_metabox']
            : array();
        $enabled = array();
        foreach ($types as $type => $on) {
            $type = (string) $type;
            if (!empty($on) && '' !== $type) {
                $enabled[$type] = $type;
            }
        }

        if ($enabled) {
            $custom = array(
                's:26:"_um_custom_access_settings";b:1;',
                's:26:"_um_custom_access_settings";i:1;',
                's:26:"_um_custom_access_settings";s:1:"1";',
            );

            $in  = "'" . implode("','", array_map('esc_sql', array_values($enabled))) . "'";
            $sql = "SELECT pm.post_id FROM {$wpdb->postmeta} pm
                      INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
                     WHERE pm.meta_key = 'um_content_restriction'
                       AND p.post_type IN ({$in})";
            $sql .= ' AND (' . self::like_clause('pm.meta_value', $custom) . ')';
            $sql .= ' AND (' . self::like_clause('pm.meta_value', array('s:14:"_um_accessible";')) . ')';

            $ids = self::db_col($sql, 'um_site_gate_exempt_ids.posts');
            if (false !== $ids) {
                $exempt = self::id_set($ids);
            }
        }

        // Term level. Not behind the post type option: the term scan is reached whenever
        // the post level branch did not return.
        $taxonomies = array();
        $enabled_tax = isset($opts['restricted_access_taxonomy_metabox']) && is_array($opts['restricted_access_taxonomy_metabox'])
            ? $opts['restricted_access_taxonomy_metabox']
            : array();
        foreach ($enabled_tax as $taxonomy => $on) {
            $taxonomy = (string) $taxonomy;
            if (empty($on) || '' === $taxonomy || !taxonomy_exists($taxonomy)) {
                continue;
            }
            $taxonomies[$taxonomy] = $taxonomy;
        }

        if ($taxonomies) {
            $in   = "'" . implode("','", array_map('esc_sql', array_values($taxonomies))) . "'";
            $rows = self::db_results(
                "SELECT tt.term_taxonomy_id AS term_taxonomy_id, tt.taxonomy AS taxonomy, tm.meta_value AS meta_value
                   FROM {$wpdb->termmeta} tm
                   INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = tm.term_id
                  WHERE tm.meta_key = 'um_content_restriction' AND tt.taxonomy IN ({$in})",
                'um_site_gate_exempt_ids.terms'
            );

            $by_taxonomy = array();
            foreach ((array) $rows as $row) {
                $restriction = self::safe_unserialize($row['meta_value']);
                if (!is_array($restriction) || empty($restriction['_um_custom_access_settings'])) {
                    continue;
                }
                if (!isset($restriction['_um_accessible'])) {
                    continue;
                }
                $by_taxonomy[(string) $row['taxonomy']][] = absint($row['term_taxonomy_id']);
            }

            $where = array();
            foreach ($by_taxonomy as $taxonomy => $tt_ids) {
                $tt_ids = array_values(array_unique(array_filter($tt_ids)));
                if (!$tt_ids) {
                    continue;
                }
                $object = self::safe_call('get_taxonomy', array($taxonomy), null);
                $otypes = $object ? array_values(array_filter((array) $object->object_type)) : array();
                if (!$otypes) {
                    continue;
                }
                $where[] = '(tr.term_taxonomy_id IN (' . implode(',', $tt_ids) . ")"
                    . " AND p.post_type IN ('" . implode("','", array_map('esc_sql', $otypes)) . "'))";
            }

            if ($where) {
                $ids = self::db_col(
                    "SELECT DISTINCT tr.object_id FROM {$wpdb->term_relationships} tr
                       INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
                      WHERE " . implode(' OR ', $where),
                    'um_site_gate_exempt_ids.objects'
                );
                if (false !== $ids) {
                    foreach (self::id_set($ids) as $id => $unused) {
                        $exempt[$id] = true;
                    }
                }
            }
        }

        foreach (array('login', 'register', 'account', 'logout', 'password-reset') as $page) {
            $id = isset($opts['core_' . $page]) ? absint($opts['core_' . $page]) : 0;
            if ($id) {
                unset($exempt[$id]);
            }
        }

        return $exempt;
    }

    /**
     * Post types excluded wholesale for this run. Tier 2 plus tier 3.
     *
     * @return array [ post_type => reason ]
     */
    public static function gated_post_types()
    {
        self::hook_switch_blog();

        $blog = self::blog_key();
        if (isset(self::$gated_types[$blog])) {
            return self::$gated_types[$blog];
        }

        $blocked = self::cannot_determine();
        if ($blocked) {
            self::record_reason($blocked['code'], $blocked['detail']);
            $all = array();
            foreach (self::public_post_types() as $type) {
                $all[$type] = 'Access could not be determined: ' . $blocked['code'];
            }
            return $all;
        }

        try {
            $gated = self::build_gated_post_types();
        } catch (\Throwable $e) {
            self::record_reason('gated_types_threw', $e->getMessage());
            $all = array();
            foreach (self::public_post_types() as $type) {
                $all[$type] = 'Access could not be determined: gated_types_threw';
            }
            return $all;
        }

        self::$gated_types[$blog] = $gated;
        return self::$gated_types[$blog];
    }

    /**
     * The body of gated_post_types(), so the memo, the registry guard and the throw
     * guard all live in one place.
     *
     * @return array [ post_type => reason ]
     */
    private static function build_gated_post_types()
    {
        $gated = array();

        // THE DEFAULT. A gating plugin is loaded and the precise path is off, so every
        // public post type comes out of the file and nothing below this branch runs: no
        // candidate query, no set builder, no rule parsing, no tier 3 detection.
        if (!self::precise_access()) {
            $blunt = self::blunt_gating_plugins();
            if ($blunt) {
                $detail = implode(', ', $blunt)
                    . ': content is left out by default while an access control plugin is active';
                foreach (self::public_post_types() as $type) {
                    $gated[$type] = $detail;
                }
            }

            return self::add_wc_coming_soon_types($gated);
        }

        // THE PRECISE PATH STILL EXCLUDES BLUNTLY WHERE IT HAS NO READER, and this
        // branch is the whole of that rule at post type scope.
        $unreadable = self::unreadable_gating_plugins();
        if ($unreadable) {
            $detail = implode(', ', $unreadable)
                . ': content is left out by default because per post evaluation has no reader for this plugin';
            foreach (self::public_post_types() as $type) {
                $gated[$type] = $detail;
            }
        }

        // Restrict Content 3.0 post type restriction. rcp_get_post_restrictions() takes
        // the post type branch first, so this overrides per post meta entirely, in both
        // directions: a post whose own meta says public is still withheld.
        if (self::plugin_active('rcp') && '3.0' === self::rcp_mode()) {
            $types = self::option_array('rcp_restricted_post_types');
            foreach ($types as $type => $rule) {
                if (!empty($rule)) {
                    $gated[$type] = 'Restrict Content: post type restriction';
                }
            }
        }

        // WooCommerce Memberships post type wide rules, but only when nothing on the
        // site can override them. _wc_memberships_force_public is the escape hatch and
        // it is the final AND in the plugin's own test, so if any post carries it the
        // rules have to be expanded to IDs by the set builder instead.
        if (self::plugin_active('wcm')) {
            $rules = self::wc_memberships_rules();
            if ($rules['post_type_wide'] && !$rules['has_force_public']) {
                foreach (array_keys($rules['post_type_wide']) as $type) {
                    $gated[$type] = 'WooCommerce Memberships: post type rule';
                }
            }
        }

        // Tier 3. Existence detection only, so the scope is always a superset.
        foreach (array('rua' => 'Restrict User Access', 'cc' => 'Content Control') as $key => $label) {
            $detect = ('rua' === $key) ? self::detect_rua() : self::detect_cc();
            if (empty($detect['gates_content'])) {
                continue;
            }
            $types = ('site' === $detect['scope']) ? self::public_post_types() : $detect['post_types'];
            foreach ($types as $type) {
                $gated[$type] = $label . ': ' . ('site' === $detect['scope'] ? 'rule scope could not be narrowed' : 'gates this post type');
            }
        }

        return self::add_wc_coming_soon_types($gated);
    }

    /**
     * Add the post types WooCommerce coming soon mode gates, on either path.
     *
     * @param array $gated
     * @return array
     */
    private static function add_wc_coming_soon_types(array $gated)
    {
        $wc = self::wc_coming_soon();

        if ('store' === $wc['mode']) {
            foreach ($wc['post_types'] as $type) {
                $gated[$type] = $wc['detail'];
            }
        }

        return $gated;
    }

    /**
     * Drop the request scoped memos.
     *
     * @return void
     */
    public static function flush()
    {
        self::$ctx         = array();
        self::$site_gated  = array();
        self::$gate_source = array();
        self::$precise     = array();
        self::$blunt       = array();
        self::$wc_gate     = array();
        self::$forced      = array();
        self::$gated_types = array();
        self::$parents     = array();
        self::$wc_rules    = array();
        self::$rua_detect  = array();
        self::$cc_detect   = array();
        self::$failed      = array();
    }

    /**
     * Drop the reason ledger. Separate from flush() on purpose, see its docblock.
     *
     * @return void
     */
    public static function flush_reasons()
    {
        self::$reasons    = array();
        self::$reason_ids = array();
    }

    /**
     * Is the precise per plugin path switched on for this site?
     *
     * @return bool
     */
    private static function precise_access()
    {
        $blog = self::blog_key();
        if (isset(self::$precise[$blog])) {
            return self::$precise[$blog];
        }

        $settings = get_option(self::SETTINGS_OPTION);
        $on       = is_array($settings) && !empty($settings[self::PRECISE_SETTING]);

        self::$precise[$blog] = $on;

        return self::$precise[$blog];
    }

    /**
     * Gating plugins loaded in this request, by symbol only. The blunt path.
     *
     * @return array [ internal key => label ]
     */
    private static function blunt_gating_plugins()
    {
        $blog = self::blog_key();
        if (isset(self::$blunt[$blog])) {
            return self::$blunt[$blog];
        }

        $found = array();
        foreach (self::gating_plugins() as $key => $label) {
            if (self::plugin_active($key)) {
                $found[$key] = $label;
            }
        }

        self::$blunt[$blog] = $found;

        return $found;
    }

    /**
     * The gating plugins the PRECISE path can actually evaluate.
     *
     * @return array [ internal key => true ]
     */
    private static function precise_covered_plugins()
    {
        return array(
            'members' => true,
            'rcp'     => true,
            'um'      => true,
            'wpmem'   => true,
            'wcm'     => true,
            'pmpro'   => true,
            'rua'     => true,
            'cc'      => true,
        );
    }

    /**
     * Loaded gating plugins the precise path has no way to read.
     *
     * @return array [ internal key => label ]
     */
    private static function unreadable_gating_plugins()
    {
        $covered = self::precise_covered_plugins();
        $found   = array();

        foreach (self::blunt_gating_plugins() as $key => $label) {
            if (!isset($covered[$key])) {
                $found[$key] = $label;
            }
        }

        return $found;
    }

    /**
     * WooCommerce coming soon mode.
     *
     * @return array mode ''|'site'|'store', detail, post_types, page_ids [ id => true ]
     */
    private static function wc_coming_soon()
    {
        $blog = self::blog_key();
        if (isset(self::$wc_gate[$blog])) {
            return self::$wc_gate[$blog];
        }

        $gate = array(
            'mode'       => '',
            'detail'     => '',
            'post_types' => array(),
            'page_ids'   => array(),
        );

        if (self::plugin_active('wc') && 'yes' === get_option('woocommerce_coming_soon')) {
            if ('yes' === get_option('woocommerce_store_pages_only')) {
                $gate['mode']       = 'store';
                $gate['detail']     = 'WooCommerce: coming soon mode is on for the store';
                $gate['post_types'] = array('product');

                foreach (array('shop', 'cart', 'checkout', 'terms', 'coming_soon') as $page) {
                    $id = absint(get_option('woocommerce_' . $page . '_page_id'));
                    if ($id) {
                        $gate['page_ids'][$id] = true;
                    }
                }
            } else {
                $gate['mode']   = 'site';
                $gate['detail'] = 'WooCommerce: coming soon mode is on for the whole site';
            }
        }

        self::$wc_gate[$blog] = $gate;

        return $gate;
    }

    /**
     * The owner's per post assertion that a post is publicly readable.
     *
     * @return array [ post_id => true ]
     */
    private static function forced_include_set()
    {
        global $wpdb;

        $blog = self::blog_key();
        if (isset(self::$forced[$blog])) {
            return self::$forced[$blog];
        }

        $rows = self::db_col(
            $wpdb->prepare(
                "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = '1'",
                self::INCLUDE_META
            ),
            'forced_include_set'
        );

        self::$forced[$blog] = (false === $rows) ? array() : self::id_set($rows);

        return self::$forced[$blog];
    }

    /**
     * Machine readable record of what this class withheld and why, for the admin
     * summary in spec 3.9.
     *
     * @return array [ [ code, detail, count ] ]
     */
    public static function reasons()
    {
        $blog = self::blog_key();

        return isset(self::$reasons[$blog]) ? array_values(self::$reasons[$blog]) : array();
    }

    /**
     * Memo key. Every datum this class reads is per site.
     *
     * @return int
     */
    private static function blog_key()
    {
        if (function_exists('get_current_blog_id')) {
            return (int) get_current_blog_id();
        }

        return 0;
    }

    /**
     * Record a machine readable reason once per code plus detail, with a count.
     *
     * @param string $code
     * @param string $detail
     * @param int    $post_id Post this reason is about, when it is about one post. 0
     *                        means the reason describes the run, not a post.
     * @return void
     */
    private static function record_reason($code, $detail = '', $post_id = 0)
    {
        $blog = self::blog_key();
        $key  = $code . '|' . $detail;

        $post_id = (int) $post_id;
        if ($post_id > 0) {
            if (isset(self::$reason_ids[$blog][$key][$post_id])) {
                return;
            }
            self::$reason_ids[$blog][$key][$post_id] = true;
        }

        if (!isset(self::$reasons[$blog])) {
            self::$reasons[$blog] = array();
        }
        if (isset(self::$reasons[$blog][$key])) {
            self::$reasons[$blog][$key]['count']++;
            return;
        }

        self::$reasons[$blog][$key] = array(
            'code'   => (string) $code,
            'detail' => (string) $detail,
            'count'  => 1,
        );
    }

    /**
     * Mark this blog's verdicts as unknown, so the batch fails closed.
     *
     * @param string $where
     * @param string $error
     * @return void
     */
    private static function note_failure($where, $error)
    {
        self::$failed[self::blog_key()] = true;
        self::record_reason('query_failed', $where . ': ' . $error);
    }

    /**
     * Turn every verdict in a batch into "not publicly readable".
     *
     * @param array $out
     * @return array
     */
    private static function withhold_all(array $out)
    {
        foreach ($out as $id => $unused) {
            $out[$id] = false;
        }

        return $out;
    }

    /**
     * Can this class answer at all right now? Returns a reason, or false when it can.
     *
     * @return array|false [ code, detail ]
     */
    private static function cannot_determine()
    {
        if (!did_action('wp_loaded')) {
            $active = self::gating_plugins_by_option();
            if ($active) {
                return array(
                    'code'   => 'called_before_registries',
                    'detail' => 'asked before wp_loaded with ' . implode(', ', array_keys($active)) . ' active',
                );
            }
        }

        $unloaded = self::unloaded_gating_plugins();
        if ($unloaded) {
            return array(
                'code'   => 'blog_plugins_unreadable',
                'detail' => 'active on this blog but not loaded by this request: ' . implode(', ', $unloaded),
            );
        }

        return false;
    }

    /**
     * The gating plugins this class knows how to read, as plugin directory slug =>
     * internal key.
     *
     * @return array
     */
    private static function gating_plugin_map()
    {
        $map = array(
            'members'                 => 'members',
            'restrict-content'        => 'rcp',
            'restrict-content-pro'    => 'rcp',
            'ultimate-member'         => 'um',
            'wp-members'              => 'wpmem',
            'woocommerce-memberships' => 'wcm',
            'paid-memberships-pro'    => 'pmpro',
            'restrict-user-access'    => 'rua',
            'content-control'         => 'cc',
        );

        foreach (self::detection_only_plugins() as $key => $plugin) {
            $map[$plugin['slug']] = $key;
        }

        return $map;
    }

    /**
     * Every gating plugin this class detects, as internal key => label.
     *
     * @return array [ internal key => label ]
     */
    private static function gating_plugins()
    {
        $plugins = array(
            'members' => 'Members',
            'rcp'     => 'Restrict Content',
            'um'      => 'Ultimate Member',
            'wpmem'   => 'WP-Members',
            'wcm'     => 'WooCommerce Memberships',
            'pmpro'   => 'Paid Memberships Pro',
            'rua'     => 'Restrict User Access',
            'cc'      => 'Content Control',
        );

        foreach (self::detection_only_plugins() as $key => $plugin) {
            $plugins[$key] = $plugin['label'];
        }

        return $plugins;
    }

    /**
     * Gating plugins carried by detection alone, with no reader behind them.
     *
     * @return array [ internal key => [ label, slug, symbols ] ]
     */
    private static function detection_only_plugins()
    {
        return array(
            'memberpress' => array('label' => 'MemberPress', 'slug' => 'memberpress', 'symbols' => array('MEPR_VERSION', 'MEPR_PLUGIN_NAME', 'MeprAppCtrl')),
            'lifterlms'   => array('label' => 'LifterLMS', 'slug' => 'lifterlms', 'symbols' => array('LifterLMS', 'llms')),
            'learndash'   => array('label' => 'LearnDash', 'slug' => 'sfwd-lms', 'symbols' => array('LEARNDASH_VERSION', 'SFWD_LMS')),
            'tutor'       => array('label' => 'Tutor LMS', 'slug' => 'tutor', 'symbols' => array('TUTOR_VERSION', 'TUTOR_FILE')),
        );
    }

    /**
     * Does a constant, class or function of this name exist right now?
     *
     * @param string $symbol
     * @return bool
     */
    private static function symbol_exists($symbol)
    {
        return defined($symbol) || class_exists($symbol, false) || function_exists($symbol);
    }

    /**
     * Gating plugins active on the CURRENT blog according to the stored plugin lists,
     * whether or not their code is loaded in this request.
     *
     * @return array [ internal_key => plugin path ]
     */
    private static function gating_plugins_by_option()
    {
        $map   = self::gating_plugin_map();
        $paths = array();

        $active = get_option('active_plugins');
        if (is_array($active)) {
            foreach ($active as $path) {
                $paths[] = (string) $path;
            }
        }

        if (is_multisite() && function_exists('get_site_option')) {
            $network = get_site_option('active_sitewide_plugins');
            if (is_array($network)) {
                foreach (array_keys($network) as $path) {
                    $paths[] = (string) $path;
                }
            }
        }

        return self::gating_keys_from_paths($paths);
    }

    /**
     * Resolve a list of plugin paths onto the gating plugins this class knows.
     *
     * @param string[] $paths
     * @return array [ internal_key => plugin path ]
     */
    private static function gating_keys_from_paths(array $paths)
    {
        $map   = self::gating_plugin_map();
        $found = array();

        foreach ($paths as $path) {
            $path = (string) $path;
            $slug = strtok($path, '/');
            if (!$slug || !isset($map[$slug])) {
                continue;
            }
            if (defined('WP_PLUGIN_DIR') && !file_exists(WP_PLUGIN_DIR . '/' . $path)) {
                continue;
            }
            $found[$map[$slug]] = $path;
        }

        return $found;
    }

    /**
     * The blog this request was BOOTSTRAPPED on, which is the blog whose active_plugins
     * decided what got loaded.
     *
     * @return int|null
     */
    private static function boot_blog_id()
    {
        $switched = function_exists('ms_is_switched') ? ms_is_switched() : false;
        if (!$switched) {
            return self::blog_key();
        }

        if (isset($GLOBALS['_wp_switched_stack'][0])) {
            $id = (int) $GLOBALS['_wp_switched_stack'][0];
            if ($id > 0) {
                return $id;
            }
        }

        return null;
    }

    /**
     * Gating plugins active on the blog this request was BOOTSTRAPPED on, which is the
     * blog whose plugin list decided what got loaded.
     *
     * @return array [ internal_key => plugin path ]
     */
    private static function boot_blog_gating_plugins()
    {
        global $wpdb;

        if (null !== self::$boot_gating) {
            return self::$boot_gating;
        }

        self::$boot_gating = array();
        $paths             = array();

        if (null !== self::$boot_blog && method_exists($wpdb, 'get_blog_prefix')) {
            $table = $wpdb->get_blog_prefix(self::$boot_blog) . 'options';
            $value = self::db_var(
                $wpdb->prepare("SELECT option_value FROM {$table} WHERE option_name = %s LIMIT 1", 'active_plugins'),
                'boot_blog_gating_plugins'
            );
            $value = (false === $value) ? null : self::safe_unserialize($value);
            if (is_array($value)) {
                foreach ($value as $path) {
                    $paths[] = (string) $path;
                }
            }
        }

        if (function_exists('get_site_option')) {
            $network = get_site_option('active_sitewide_plugins');
            if (is_array($network)) {
                foreach (array_keys($network) as $path) {
                    $paths[] = (string) $path;
                }
            }
        }

        self::$boot_gating = self::gating_keys_from_paths($paths);

        return self::$boot_gating;
    }

    /**
     * Gating plugins that are active on this blog but whose code this request never
     * loaded, which makes their restrictions unreadable from here.
     *
     * @return string[] internal keys
     */
    private static function unloaded_gating_plugins()
    {
        if (!is_multisite()) {
            return array();
        }

        $switched = function_exists('ms_is_switched') ? ms_is_switched() : false;
        if (!$switched && null !== self::$boot_blog && self::$boot_blog === self::blog_key()) {
            return array();
        }
        if (!$switched && null === self::$boot_blog) {
            self::$boot_blog = self::blog_key();
            return array();
        }

        $boot     = self::boot_blog_gating_plugins();
        $unloaded = array();
        foreach (self::gating_plugins_by_option() as $key => $path) {
            if (self::plugin_active($key)) {
                continue;
            }
            if (isset($boot[$key])) {
                self::record_reason(
                    'gating_plugin_never_loaded',
                    $key . ' is active but did not load in this request, so it restricts nothing'
                );
                continue;
            }
            $unloaded[] = $key;
        }

        return $unloaded;
    }

    /**
     * Hook flush() to switch_blog once.
     *
     * @return void
     */
    private static function hook_switch_blog()
    {
        if (self::$switch_hooked || !function_exists('add_action')) {
            return;
        }
        self::$switch_hooked = true;
        if (null === self::$boot_blog) {
            // boot_blog_id(), NOT blog_key(): a first call made inside switch_to_blog()
            // would otherwise record the switched blog as the bootstrap blog, which
            // excuses that blog's own unloaded gating plugins and publishes their bodies.
            self::$boot_blog = self::boot_blog_id();
        }
        add_action('switch_blog', array(__CLASS__, 'flush'));
    }

    /**
     * $wpdb->get_results() that distinguishes "no rows" from "the query failed".
     *
     * @param string $sql
     * @param string $where Label for the recorded reason.
     * @return array|false
     */
    private static function db_results($sql, $where)
    {
        global $wpdb;

        $suppress = $wpdb->suppress_errors(true);
        $rows     = $wpdb->get_results($sql, ARRAY_A);
        $error    = $wpdb->last_error;
        $wpdb->suppress_errors($suppress);

        if ($error) {
            self::note_failure($where, $error);
            return false;
        }

        return (array) $rows;
    }

    /**
     * $wpdb->get_col() that distinguishes "no rows" from "the query failed".
     *
     * @param string $sql
     * @param string $where
     * @return array|false
     */
    private static function db_col($sql, $where)
    {
        global $wpdb;

        $suppress = $wpdb->suppress_errors(true);
        $rows     = $wpdb->get_col($sql);
        $error    = $wpdb->last_error;
        $wpdb->suppress_errors($suppress);

        if ($error) {
            self::note_failure($where, $error);
            return false;
        }

        return (array) $rows;
    }

    /**
     * $wpdb->get_var() that distinguishes "no row" from "the query failed".
     *
     * @param string $sql
     * @param string $where
     * @return string|null|false false means the query failed.
     */
    private static function db_var($sql, $where)
    {
        global $wpdb;

        $suppress = $wpdb->suppress_errors(true);
        $value    = $wpdb->get_var($sql);
        $error    = $wpdb->last_error;
        $wpdb->suppress_errors($suppress);

        if ($error) {
            self::note_failure($where, $error);
            return false;
        }

        return $value;
    }

    /**
     * unserialize() on database controlled bytes, with objects refused.
     *
     * @param mixed $value
     * @return mixed
     */
    private static function safe_unserialize($value)
    {
        if (!is_string($value) || !is_serialized($value)) {
            return $value;
        }

        $out = @unserialize(trim($value), array('allowed_classes' => false));

        return (false === $out && 'b:0;' !== trim($value)) ? $value : $out;
    }

    /**
     * Active tier 1 readers.
     *
     * @return array
     */
    private static function readers()
    {
        $readers = array();

        if (self::plugin_active('members')) {
            $readers['members'] = array(
                'label'     => 'Members',
                'meta_keys' => array('_members_access_role', '_role'),
                'callback'  => array(__CLASS__, 'reader_members'),
            );
        }

        if (self::plugin_active('rcp')) {
            $readers['rcp'] = array(
                'label'     => 'Restrict Content',
                'meta_keys' => array('rcp_user_level', 'rcUserLevel', 'rcp_access_level', 'rcp_subscription_level', '_is_paid'),
                'callback'  => array(__CLASS__, 'reader_rcp'),
            );
        }

        if (self::plugin_active('um')) {
            $readers['um'] = array(
                'label'     => 'Ultimate Member',
                // um_content_restriction is deliberately absent. See um_restricted_ids().
                'meta_keys' => array(),
                'callback'  => array(__CLASS__, 'reader_um'),
            );
        }

        if (self::plugin_active('wpmem')) {
            $readers['wpmem'] = array(
                'label'     => 'WP-Members',
                'meta_keys' => array('_wpmem_block'),
                'callback'  => array(__CLASS__, 'reader_wpmem'),
            );
        }

        if (self::plugin_active('wcm')) {
            $readers['wcm'] = array(
                'label'     => 'WooCommerce Memberships',
                'meta_keys' => array(),
                'callback'  => array(__CLASS__, 'reader_wc_memberships'),
            );
        }

        if (self::plugin_active('pmpro')) {
            $readers['pmpro'] = array(
                'label'     => 'Paid Memberships Pro',
                'meta_keys' => array(),
                'callback'  => array(__CLASS__, 'reader_pmpro'),
            );
        }

        /**
         * Filter the tier 1 reader registry. ADDITIONS ONLY.
         *
         * @param array $readers Always empty. Add your descriptors and return them.
         */
        $added = apply_filters('llms_access_readers', array());
        $added = is_array($added) ? $added : array();

        // Built ins last, so they win every key collision.
        $readers = array_merge($added, $readers);

        foreach ($readers as $key => $reader) {
            if (!empty($reader['callback']) && is_callable($reader['callback'])) {
                continue;
            }
            unset($readers[$key]);
            self::record_reason(
                'reader_callback_invalid',
                (string) $key . ' (' . (isset($reader['label']) ? $reader['label'] : 'no label') . ')'
            );
        }

        return $readers;
    }

    /**
     * Is a gating plugin loaded in this request?
     *
     * @param string $which
     * @return bool
     */
    private static function plugin_active($which)
    {
        switch ($which) {
            case 'members':
                return function_exists('members_get_setting') || class_exists('Members_Plugin');

            case 'rcp':
                return function_exists('rcp_is_restricted_content')
                    || function_exists('rcp_user_can_access')
                    || class_exists('Restrict_Content_Pro')
                    || class_exists('Restrict_Content_Plugin')
                    || defined('RC_PLUGIN_VERSION')
                    || defined('RCP_PLUGIN_VERSION');

            case 'um':
                return function_exists('UM') || class_exists('UM') || defined('um_url');

            case 'wpmem':
                return function_exists('wpmem_is_blocked') || class_exists('WP_Members') || defined('WPMEM_VERSION');

            case 'wcm':
                return function_exists('wc_memberships') || class_exists('WC_Memberships');

            case 'pmpro':
                return defined('PMPRO_VERSION') || function_exists('pmpro_getOption') || function_exists('pmpro_has_membership_access');

            case 'rua':
                return post_type_exists('restriction');

            case 'cc':
                // Two shipping plugins, not one plugin with two data formats.
                return post_type_exists('cc_restriction')
                    || function_exists('jp_content_control')
                    || class_exists('JP_Content_Control');

            case 'wc':
                // WooCommerce itself, which is NOT a gating plugin and is not in
                // gating_plugins(). Only its coming soon option state gates anything.
                return defined('WC_VERSION') || class_exists('WooCommerce', false) || function_exists('WC');
        }

        $detect = self::detection_only_plugins();
        if (isset($detect[$which])) {
            foreach ($detect[$which]['symbols'] as $symbol) {
                if (self::symbol_exists($symbol)) {
                    return true;
                }
            }
            return false;
        }

        return false;
    }

    /**
     * Build the per run context once: readers, option snapshots, the candidate meta
     * map and the set builders.
     *
     * @return array
     */
    private static function context()
    {
        $blog = self::blog_key();
        if (isset(self::$ctx[$blog])) {
            return self::$ctx[$blog];
        }

        try {
            $ctx = self::build_context($blog);
        } catch (\Throwable $e) {
            self::record_reason('context_threw', $e->getMessage());
            $ctx = array(
                'blog_id' => $blog,
                'readers' => array(),
                'opt'     => array(),
                'meta'    => array(),
                'sets'    => array(),
            );
            self::$failed[$blog] = true;
        }

        self::$ctx[$blog] = $ctx;
        return self::$ctx[$blog];
    }

    /**
     * The body of context().
     *
     * @param int $blog
     * @return array
     */
    private static function build_context($blog)
    {
        $ctx = array(
            'blog_id' => $blog,
            'readers' => self::readers(),
            'opt'     => array(),
            'meta'    => array(),
            'sets'    => array(),
        );

        if (!$ctx['readers']) {
            return $ctx;
        }

        $ctx['meta'] = self::candidate_meta($ctx['readers']);

        if (isset($ctx['readers']['members'])) {
            $settings = self::option_array('members_settings');
            // Master switch. members_get_setting() defaults it to 1, so an absent key
            // means enabled. When it is off, Members publishes posts that still carry
            // restriction meta, and a reader that ignores it over-restricts.
            $ctx['opt']['members_content_permissions'] = !isset($settings['content_permissions'])
                ? true
                : (bool) $settings['content_permissions'];
        }

        if (isset($ctx['readers']['rcp'])) {
            $ctx['opt']['rcp_mode'] = self::rcp_mode();
            $ctx['sets']['rcp_term'] = self::rcp_term_restricted_ids();
        }

        if (isset($ctx['readers']['um'])) {
            $opts = self::option_array('um_options');
            // Post type enablement REVERSES polarity: with the type unticked, a post
            // carrying _um_accessible = 2 is publicly visible. UM reads the metabox
            // value only after this gate passes.
            $ctx['opt']['um_post_types'] = isset($opts['restricted_access_post_metabox']) && is_array($opts['restricted_access_post_metabox'])
                ? $opts['restricted_access_post_metabox']
                : array();
            $ctx['sets']['um'] = self::um_restricted_ids();

            $ctx['sets']['um_term'] = self::um_term_restricted_ids(
                isset($opts['restricted_access_taxonomy_metabox']) && is_array($opts['restricted_access_taxonomy_metabox'])
                    ? $opts['restricted_access_taxonomy_metabox']
                    : array()
            );

            $ctx['sets']['um_custom'] = $ctx['sets']['um_term']
                ? self::um_custom_access_ids()
                : array();
        }

        if (isset($ctx['readers']['wpmem'])) {
            $settings = self::option_array('wpmembers_settings');
            $ctx['opt']['wpmem_block'] = isset($settings['block']) && is_array($settings['block'])
                ? $settings['block']
                : array();
            $ctx['opt']['wpmem_post_types'] = isset($settings['post_types']) && is_array($settings['post_types'])
                ? $settings['post_types']
                : array();

            // The CACHED hidden post list, which is what actually decides an anonymous
            // visitor's 404 for a '2' ("Hide"). See reader_wpmem().
            $hidden = get_option('wpmem_hidden_posts');
            $ctx['opt']['wpmem_hidden'] = (is_array($hidden) && $hidden) ? self::id_set($hidden) : null;
        }

        if (isset($ctx['readers']['wcm'])) {
            $ctx['sets']['wcm'] = self::wc_memberships_restricted_ids();
        }

        if (isset($ctx['readers']['pmpro'])) {
            $ctx['sets']['pmpro'] = self::pmpro_restricted_ids();
        }

        return $ctx;
    }

    /**
     * The single site wide candidate query.
     *
     * @param array $readers
     * @return array [ post_id => [ meta_key => string[] ] ]
     */
    private static function candidate_meta(array $readers)
    {
        global $wpdb;

        $keys = array();
        foreach ($readers as $reader) {
            if (empty($reader['meta_keys'])) {
                continue;
            }
            foreach ((array) $reader['meta_keys'] as $key) {
                $key = (string) $key;
                if ('' !== $key) {
                    $keys[$key] = $key;
                }
            }
        }
        if (!$keys) {
            return array();
        }

        $in  = "'" . implode("','", array_map('esc_sql', array_values($keys))) . "'";
        $sql = "SELECT post_id, meta_key, LEFT(meta_value, " . (int) self::META_VALUE_LIMIT . ") AS meta_value"
            . " FROM {$wpdb->postmeta} WHERE meta_key IN ({$in})";

        $suppress = $wpdb->suppress_errors(true);
        $rows     = $wpdb->get_results($sql, ARRAY_A);
        $error    = $wpdb->last_error;

        if ($error) {
            $rows  = $wpdb->get_results(
                "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE meta_key IN ({$in})",
                ARRAY_A
            );
            $error = $wpdb->last_error;

            if ($error) {
                $wpdb->suppress_errors($suppress);
                self::note_failure('candidate_meta', $error);
                return array();
            }

            foreach ((array) $rows as $i => $row) {
                $rows[$i]['meta_value'] = substr((string) $row['meta_value'], 0, self::META_VALUE_LIMIT);
            }
        }

        $wpdb->suppress_errors($suppress);

        $map = array();
        foreach ((array) $rows as $row) {
            $map[(int) $row['post_id']][$row['meta_key']][] = (string) $row['meta_value'];
        }

        return $map;
    }

    /**
     * Post type, parent, status and password for a batch, in one primary key lookup.
     *
     * @param int[] $ids
     * @return array [ post_id => [ post_type, post_parent ] ]
     */
    private static function posts_for_ids(array $ids)
    {
        global $wpdb;

        $in   = implode(',', array_map('absint', $ids));
        $rows = self::db_results(
            "SELECT ID, post_type, post_parent, post_status, post_password FROM {$wpdb->posts} WHERE ID IN ({$in})",
            'posts_for_ids'
        );

        if (false === $rows) {
            return array();
        }

        $out = array();
        foreach ((array) $rows as $row) {
            if ('publish' !== $row['post_status'] || '' !== (string) $row['post_password']) {
                continue;
            }
            $out[(int) $row['ID']] = array(
                'post_type'   => (string) $row['post_type'],
                'post_parent' => (int) $row['post_parent'],
            );
        }

        return $out;
    }

    /**
     * Resolve the ancestor closure of a batch in at most ANCESTOR_DEPTH queries, each
     * a primary key IN. Members inherits restriction down the post_parent chain, and a
     * child page with no meta of its own under a restricted parent is withheld.
     *
     * @param int[] $ids
     * @param array $posts
     * @return void
     */
    private static function prime_parents(array $ids, array $posts)
    {
        global $wpdb;

        $blog = self::blog_key();
        if (!isset(self::$parents[$blog])) {
            self::$parents[$blog] = array();
        }

        $pending = array();
        foreach ($ids as $id) {
            if (!isset($posts[$id])) {
                continue;
            }
            self::$parents[$blog][$id] = $posts[$id]['post_parent'];
            if ($posts[$id]['post_parent'] > 0 && !isset(self::$parents[$blog][$posts[$id]['post_parent']])) {
                $pending[$posts[$id]['post_parent']] = $posts[$id]['post_parent'];
            }
        }

        $depth = 0;
        while ($pending && $depth < self::ANCESTOR_DEPTH) {
            $depth++;
            $in   = implode(',', array_map('absint', $pending));
            $rows = self::db_results("SELECT ID, post_parent FROM {$wpdb->posts} WHERE ID IN ({$in})", 'prime_parents');

            // A failed ancestor query would silently truncate the Members inheritance
            // walk, which is a leak: a child page under a restricted parent carries no
            // meta of its own. The reason is recorded and the batch fails closed.
            if (false === $rows) {
                return;
            }

            $next = array();
            foreach ((array) $rows as $row) {
                $id                        = (int) $row['ID'];
                $parent                    = (int) $row['post_parent'];
                self::$parents[$blog][$id] = $parent;
                if ($parent > 0 && !isset(self::$parents[$blog][$parent])) {
                    $next[$parent] = $parent;
                }
            }
            foreach ($pending as $id) {
                if (!isset(self::$parents[$blog][$id])) {
                    self::$parents[$blog][$id] = 0;
                }
            }
            $pending = $next;
        }
    }

    /**
     * Members.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_members($post_id, $post, $ctx)
    {
        if (empty($ctx['opt']['members_content_permissions'])) {
            return false;
        }
        return self::members_restricted($post_id, $ctx, 0);
    }

    /**
     * @param int   $post_id
     * @param array $ctx
     * @param int   $depth
     * @return bool
     */
    private static function members_restricted($post_id, $ctx, $depth)
    {
        if ($depth > self::ANCESTOR_DEPTH) {
            return false;
        }

        // Bare ! empty(), no normalising. Members itself tests ! empty( $roles ) and
        // array('') is not empty, so a post whose only _members_access_role row is ''
        // is WITHHELD. Confirmed by anonymous curl, repeatably. Running
        // array_filter( ..., 'strlen' ) here would publish a body Members is hiding.
        $roles = isset($ctx['meta'][$post_id]['_members_access_role'])
            ? $ctx['meta'][$post_id]['_members_access_role']
            : array();
        if (!empty($roles)) {
            return true;
        }

        // Legacy key. Members' own migration consumes it on the first anonymous view,
        // so reading it races a write and its absence is inconclusive rather than
        // public. Read, never write.
        $legacy = isset($ctx['meta'][$post_id]['_role']) ? $ctx['meta'][$post_id]['_role'] : array();
        if (!empty($legacy)) {
            return true;
        }

        $blog   = self::blog_key();
        $parent = isset(self::$parents[$blog][$post_id]) ? (int) self::$parents[$blog][$post_id] : 0;
        if ($parent > 0) {
            return self::members_restricted($parent, $ctx, $depth + 1);
        }

        return false;
    }

    /**
     * Restrict Content and Restrict Content Pro.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_rcp($post_id, $post, $ctx)
    {
        $mode = isset($ctx['opt']['rcp_mode']) ? $ctx['opt']['rcp_mode'] : '';

        // Engine could not be established at all. See rcp_mode().
        if ('' === $mode) {
            return false;
        }

        if (isset($ctx['sets']['rcp_term'][$post_id])) {
            return true;
        }

        if ('3.0' === $mode) {
            return self::rcp_restricted_30($post_id, $ctx);
        }

        return self::rcp_restricted_legacy($post_id, $ctx);
    }

    /**
     * Legacy engine. rcp_user_level is a plain string from a fixed six item list and
     * only the five role names withhold. 'None' means unrestricted.
     *
     * @param int   $post_id
     * @param array $ctx
     * @return bool
     */
    private static function rcp_restricted_legacy($post_id, $ctx)
    {
        $value = self::meta_single($ctx, $post_id, 'rcp_user_level');
        if ('' === $value) {
            // Pre 2.2 key, renamed wholesale by the plugin's own upgrade routine.
            $value = self::meta_single($ctx, $post_id, 'rcUserLevel');
        }

        $roles = array('administrator', 'editor', 'author', 'contributor', 'subscriber');

        return in_array(strtolower(trim($value)), $roles, true);
    }

    /**
     * 3.0 engine. Mirrors rcp_has_post_restrictions(), four keys.
     *
     * @param int   $post_id
     * @param array $ctx
     * @return bool
     */
    private static function rcp_restricted_30($post_id, $ctx)
    {
        if (!empty(self::meta_single($ctx, $post_id, '_is_paid'))) {
            return true;
        }

        $levels = self::safe_unserialize(self::meta_single($ctx, $post_id, 'rcp_subscription_level'));
        if (is_string($levels) && 'all' === strtolower($levels)) {
            $levels = false;
        }
        if (!empty($levels)) {
            return true;
        }

        $user_level = self::safe_unserialize(self::meta_single($ctx, $post_id, 'rcp_user_level'));
        if (!empty($user_level) && !is_array($user_level)) {
            $user_level = array($user_level);
        }
        if (!empty($user_level)) {
            $first = reset($user_level);
            if ('all' !== strtolower((string) $first)) {
                return true;
            }
        }

        // Exact case 'None' is the only sentinel the plugin honours at post level.
        // '' and '0' are public because ! empty() already rejects them.
        $access_level = self::meta_single($ctx, $post_id, 'rcp_access_level');
        if (!empty($access_level) && 'None' !== $access_level) {
            return true;
        }

        return false;
    }

    /**
     * Ultimate Member.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_um($post_id, $post, $ctx)
    {
        $type_enabled = !empty($ctx['opt']['um_post_types'][$post['post_type']]);

        if ($type_enabled && isset($ctx['sets']['um'][$post_id])) {
            return true;
        }

        // Post level settings beat the term, in this direction only. Once
        // get_post_privacy_settings() finds _um_custom_access_settings on the post at
        // :2010 it returns that array and never reads a single term, so a post
        // deliberately opened up inside a restricted category stays public.
        if ($type_enabled && isset($ctx['sets']['um_custom'][$post_id])) {
            return false;
        }

        // Term level, and deliberately NOT behind the post type gate above. The term scan
        // at :2077 is reached whenever the post level branch did not return, which
        // includes every post whose post type is unticked. Verified: a post type absent
        // from restricted_access_post_metabox is still withheld inside a restricted term.
        return isset($ctx['sets']['um_term'][$post_id]);
    }

    /**
     * WP-Members.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_wpmem($post_id, $post, $ctx)
    {
        $defaults = isset($ctx['opt']['wpmem_block']) ? $ctx['opt']['wpmem_block'] : array();
        $type     = $post['post_type'];
        $default  = isset($defaults[$type]) ? (int) $defaults[$type] : 0;
        $meta     = self::meta_single($ctx, $post_id, '_wpmem_block');

        if ($default >= 1) {
            // Blocked by default. Only an explicit '0' unblocks, so '1', '2' and an
            // absent row all stay blocked.
            return '0' !== $meta;
        }

        // Unblocked by default, and '2' ("Hide") still makes the URL 404.
        if ('2' === $meta) {
            // isset(), not a bare index: an unset key here would emit a warning, and the
            // generator buffers output into the file. Absent means "no cache was read",
            // which is the recompute path below.
            if (isset($ctx['opt']['wpmem_hidden']) && is_array($ctx['opt']['wpmem_hidden'])) {
                return isset($ctx['opt']['wpmem_hidden'][$post_id]);
            }

            return self::wpmem_handles_type($type, $ctx);
        }

        return '1' === $meta;
    }

    /**
     * Is this a post type WP-Members' hidden post scan covers?
     *
     * @param string $type
     * @param array  $ctx
     * @return bool
     */
    private static function wpmem_handles_type($type, $ctx)
    {
        if ('post' === $type || 'page' === $type) {
            return true;
        }

        $handled = isset($ctx['opt']['wpmem_post_types']) ? $ctx['opt']['wpmem_post_types'] : array();

        return is_array($handled) && array_key_exists($type, $handled);
    }

    /**
     * WooCommerce Memberships. Set builder, computed once per run.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_wc_memberships($post_id, $post, $ctx)
    {
        return isset($ctx['sets']['wcm'][$post_id]);
    }

    /**
     * Paid Memberships Pro. Set builder, computed once per run.
     *
     * @param int   $post_id
     * @param array $post
     * @param array $ctx
     * @return bool
     */
    private static function reader_pmpro($post_id, $post, $ctx)
    {
        return isset($ctx['sets']['pmpro'][$post_id]);
    }

    /**
     * Ultimate Member restricted post IDs.
     *
     * @return array
     */
    private static function um_restricted_ids()
    {
        global $wpdb;

        // Truthy _um_custom_access_settings. Without it UM never reads _um_accessible.
        $custom = array(
            's:26:"_um_custom_access_settings";b:1;',
            's:26:"_um_custom_access_settings";i:1;',
            's:26:"_um_custom_access_settings";s:1:"1";',
        );

        // The key has to be PRESENT. get_post_privacy_settings() falls straight through
        // to its term scan without it, exactly as if the post carried nothing.
        $present = array('s:14:"_um_accessible";');

        // The only values that CLEAR restriction for an anonymous visitor. 0 is
        // "private", which clears unconditionally, and 1 is "logged out users", which
        // clears for a visitor who is not logged in.
        $clearing = array(
            's:14:"_um_accessible";i:0;',
            's:14:"_um_accessible";s:1:"0";',
            's:14:"_um_accessible";i:1;',
            's:14:"_um_accessible";s:1:"1";',
            's:14:"_um_accessible";b:0;',
            's:14:"_um_accessible";b:1;',
            's:14:"_um_accessible";d:0;',
            's:14:"_um_accessible";d:1;',
        );

        $sql  = "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'um_content_restriction'";
        $sql .= ' AND (' . self::like_clause('meta_value', $custom) . ')';
        $sql .= ' AND (' . self::like_clause('meta_value', $present) . ')';
        $sql .= ' AND NOT (' . self::like_clause('meta_value', $clearing) . ')';

        $ids = self::db_col($sql, 'um_restricted_ids');

        return (false === $ids) ? array() : self::id_set($ids);
    }

    /**
     * Posts whose own Ultimate Member settings decide their access, whatever their terms
     * say.
     *
     * @return array
     */
    private static function um_custom_access_ids()
    {
        global $wpdb;

        $custom = array(
            's:26:"_um_custom_access_settings";b:1;',
            's:26:"_um_custom_access_settings";i:1;',
            's:26:"_um_custom_access_settings";s:1:"1";',
        );

        $sql  = "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = 'um_content_restriction'";
        $sql .= ' AND (' . self::like_clause('meta_value', $custom) . ')';
        $sql .= ' AND (' . self::like_clause('meta_value', array('s:14:"_um_accessible";')) . ')';

        $ids = self::db_col($sql, 'um_custom_access_ids');

        return (false === $ids) ? array() : self::id_set($ids);
    }

    /**
     * Ultimate Member term level restriction.
     *
     * @param array $restricted_taxonomies um_options['restricted_access_taxonomy_metabox'].
     * @return array
     */
    private static function um_term_restricted_ids(array $restricted_taxonomies)
    {
        global $wpdb;

        $taxonomies = array();
        foreach ($restricted_taxonomies as $taxonomy => $enabled) {
            if (empty($enabled)) {
                continue;
            }
            $taxonomy = (string) $taxonomy;
            if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
                continue;
            }
            $taxonomies[$taxonomy] = $taxonomy;
        }

        if (!$taxonomies) {
            return array();
        }

        $in = "'" . implode("','", array_map('esc_sql', array_values($taxonomies))) . "'";

        $rows = self::db_results(
            "SELECT tt.term_taxonomy_id AS term_taxonomy_id, tm.meta_value AS meta_value
               FROM {$wpdb->termmeta} tm
               INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = tm.term_id
              WHERE tm.meta_key = 'um_content_restriction' AND tt.taxonomy IN ({$in})",
            'um_term_restricted_ids.terms'
        );
        if (false === $rows) {
            return array();
        }

        $tt_ids = array();
        foreach ((array) $rows as $row) {
            $restriction = self::safe_unserialize($row['meta_value']);

            if (!is_array($restriction) || empty($restriction['_um_custom_access_settings'])) {
                continue;
            }

            if (!isset($restriction['_um_accessible'])) {
                continue;
            }

            // The tri-state, and it must be loose. UM writes '0' == / '1' == / '2' ==,
            // post meta holds integers, and term meta is not guaranteed to agree, so a
            // strict === against either type fails open on the other.
            $accessible = $restriction['_um_accessible'];
            if ('0' == $accessible || '1' == $accessible) {
                continue;
            }

            $tt_ids[] = absint($row['term_taxonomy_id']);
        }

        if (!$tt_ids) {
            return array();
        }

        $ids = self::db_col(
            "SELECT DISTINCT object_id FROM {$wpdb->term_relationships}
              WHERE term_taxonomy_id IN (" . implode(',', array_unique($tt_ids)) . ')',
            'um_term_restricted_ids.objects'
        );

        return (false === $ids) ? array() : self::id_set($ids);
    }

    /**
     * Restrict Content term level restriction, plus the post type option's own reach.
     *
     * @return array
     */
    private static function rcp_term_restricted_ids()
    {
        global $wpdb;

        if ('3.0' !== self::rcp_mode()) {
            return array();
        }

        $rows = self::db_results(
            "SELECT term_id, meta_value FROM {$wpdb->termmeta} WHERE meta_key = 'rcp_restricted_meta'",
            'rcp_term_restricted_ids.termmeta'
        );
        if (false === $rows) {
            return array();
        }

        $terms = array();
        foreach ((array) $rows as $row) {
            $restrictions = self::safe_unserialize($row['meta_value']);
            if (is_array($restrictions)
                && !empty($restrictions['access_level'])
                && 'none' === strtolower((string) $restrictions['access_level'])
            ) {
                unset($restrictions['access_level']);
            }
            if (!empty($restrictions)) {
                $terms[] = absint($row['term_id']);
            }
        }

        $legacy = self::db_results(
            "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'rcp\\_category\\_meta\\_%'",
            'rcp_term_restricted_ids.legacy_options'
        );
        if (false === $legacy) {
            return array();
        }
        foreach ((array) $legacy as $row) {
            $term_id = absint(str_replace('rcp_category_meta_', '', $row['option_name']));
            if (!$term_id || in_array($term_id, $terms, true)) {
                continue;
            }
            $restrictions = self::safe_unserialize($row['option_value']);
            if (is_array($restrictions)
                && !empty($restrictions['access_level'])
                && 'none' === strtolower((string) $restrictions['access_level'])
            ) {
                unset($restrictions['access_level']);
            }
            if (!empty($restrictions)) {
                $terms[] = $term_id;
            }
        }

        if (!$terms) {
            return array();
        }

        $in = implode(',', array_unique($terms));

        $ids = self::db_col(
            "SELECT DISTINCT tr.object_id
               FROM {$wpdb->term_relationships} tr
               INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
              WHERE tt.term_id IN ({$in})",
            'rcp_term_restricted_ids.objects'
        );

        return (false === $ids) ? array() : self::id_set($ids);
    }

    /**
     * Paid Memberships Pro restricted post IDs.
     *
     * @return array
     */
    private static function pmpro_restricted_ids()
    {
        global $wpdb;

        $pages  = $wpdb->prefix . 'pmpro_memberships_pages';
        $cats   = $wpdb->prefix . 'pmpro_memberships_categories';
        $levels = $wpdb->prefix . 'pmpro_membership_levels';

        $present = self::db_col(
            $wpdb->prepare("SHOW TABLES LIKE %s", $wpdb->esc_like($wpdb->prefix . 'pmpro_') . '%'),
            'pmpro_restricted_ids.show_tables'
        );
        if (false === $present) {
            return array();
        }
        $present = array_map('strval', (array) $present);
        if (!in_array($pages, $present, true) || !in_array($cats, $present, true) || !in_array($levels, $present, true)) {
            // PMPro is loaded but its schema is not there. Nothing is restricted, and
            // this is not a failure.
            return array();
        }

        $by_page = self::db_col("SELECT DISTINCT mp.page_id FROM {$pages} mp", 'pmpro_restricted_ids.pages');
        if (false === $by_page) {
            return array();
        }

        $by_term = self::db_col(
            "SELECT DISTINCT tr.object_id
               FROM {$wpdb->term_relationships} tr
               INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
               INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
              WHERE tt.taxonomy IN ('category','post_tag')
                AND p.post_type = 'post'
                AND tt.term_id IN (
                    SELECT mc.category_id FROM {$cats} mc
                    INNER JOIN {$levels} m ON m.id = mc.membership_id
                )",
            'pmpro_restricted_ids.terms'
        );
        if (false === $by_term) {
            return array();
        }

        $ids = array_merge((array) $by_page, (array) $by_term);
        $set = self::id_set($ids);
        if (!$set) {
            return array();
        }

        $in       = implode(',', array_keys($set));
        $children = self::db_col(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('attachment','revision') AND post_parent IN ({$in})",
            'pmpro_restricted_ids.children'
        );
        if (false === $children) {
            return $set;
        }

        foreach (self::id_set($children) as $id => $unused) {
            $set[$id] = true;
        }

        return $set;
    }

    /**
     * Parse wc_memberships_rules once. Shared by the post type gate and the set builder.
     *
     * @return array
     */
    private static function wc_memberships_rules()
    {
        $blog = self::blog_key();
        if (isset(self::$wc_rules[$blog])) {
            return self::$wc_rules[$blog];
        }

        global $wpdb;

        $parsed = array(
            'post_type_wide'   => array(),
            'explicit_ids'     => array(),
            'taxonomy_wide'    => array(),
            'term_ids'         => array(),
            'term_taxonomies'  => array(),
            // [ post_type => int[] ]. Seeds for the opt in ancestor walk, and nothing
            // else may seed it. See the walk in wc_memberships_restricted_ids().
            'inherit_seeds'    => array(),
            'has_force_public' => false,
        );

        $rules = get_option('wc_memberships_rules', array());
        if (!is_array($rules) || !$rules) {
            self::$wc_rules[$blog] = $parsed;
            return self::$wc_rules[$blog];
        }

        $plan_status = array();

        foreach ($rules as $rule) {
            if (!is_array($rule)) {
                continue;
            }

            $rule_type = isset($rule['rule_type']) ? $rule['rule_type'] : '';

            // content_restriction gates ordinary content. product_restriction blocks
            // viewing only when access_type is 'view'; with 'purchase' the product page
            // stays readable. purchasing_discount never restricts.
            if ('content_restriction' !== $rule_type) {
                if ('product_restriction' !== $rule_type) {
                    continue;
                }
                if ('view' !== (isset($rule['access_type']) ? $rule['access_type'] : '')) {
                    continue;
                }
            }

            $plan_id = isset($rule['membership_plan_id']) ? absint($rule['membership_plan_id']) : 0;
            if (!$plan_id) {
                continue;
            }
            if (!isset($plan_status[$plan_id])) {
                $plan_status[$plan_id] = self::safe_call('get_post_status', array($plan_id), '');
            }
            // Rules on a draft or trashed plan do not apply. The rule's own 'active'
            // flag is never consulted for content restriction.
            if ('publish' !== $plan_status[$plan_id]) {
                continue;
            }

            $type = isset($rule['content_type']) ? $rule['content_type'] : '';
            $name = isset($rule['content_type_name']) ? $rule['content_type_name'] : '';

            $named   = (!empty($rule['object_ids']) && is_array($rule['object_ids']));
            $objects = $named ? array_filter(array_map('absint', $rule['object_ids'])) : array();

            if ('post_type' === $type && $name) {
                if ($named) {
                    $parsed['explicit_ids'] = array_merge($parsed['explicit_ids'], $objects);
                    if ('content_restriction' === $rule_type) {
                        if (!isset($parsed['inherit_seeds'][$name])) {
                            $parsed['inherit_seeds'][$name] = array();
                        }
                        $parsed['inherit_seeds'][$name] = array_merge($parsed['inherit_seeds'][$name], $objects);
                    }
                } else {
                    $parsed['post_type_wide'][$name] = true;
                }
            } elseif ('taxonomy' === $type && $name) {
                if ($named) {
                    foreach ($objects as $term_id) {
                        $parsed['term_ids'][] = $term_id;
                        $children = self::safe_call('get_term_children', array($term_id, $name), array());
                        if (is_array($children)) {
                            $parsed['term_ids'] = array_merge($parsed['term_ids'], array_map('absint', $children));
                        }
                    }
                } else {
                    $parsed['taxonomy_wide'][$name] = true;
                }
                $parsed['term_taxonomies'][$name] = $name;
            }
        }

        $force_public = self::db_var(
            "SELECT post_id FROM {$wpdb->postmeta}
              WHERE meta_key = '_wc_memberships_force_public' AND meta_value = 'yes' LIMIT 1",
            'wc_memberships_rules.force_public'
        );
        $parsed['has_force_public'] = (false === $force_public) ? false : (bool) $force_public;

        self::$wc_rules[$blog] = $parsed;
        return self::$wc_rules[$blog];
    }

    /**
     * WooCommerce Memberships restricted post IDs.
     *
     * @return array
     */
    private static function wc_memberships_restricted_ids()
    {
        global $wpdb;

        $rules = self::wc_memberships_rules();
        $set   = self::id_set($rules['explicit_ids']);

        if ($rules['post_type_wide'] && $rules['has_force_public']) {
            $in   = "'" . implode("','", array_map('esc_sql', array_keys($rules['post_type_wide']))) . "'";
            $rows = self::db_col(
                "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ({$in}) AND post_status = 'publish'",
                'wc_memberships_restricted_ids.post_type_wide'
            );
            foreach (self::id_set(false === $rows ? array() : $rows) as $id => $unused) {
                $set[$id] = true;
            }
        }

        // Term rules, with the post type guard WCM itself applies.
        if ($rules['term_ids']) {
            $in   = implode(',', array_unique(array_map('absint', $rules['term_ids'])));
            $rows = self::db_results(
                "SELECT DISTINCT tr.object_id AS object_id, tt.taxonomy AS taxonomy, p.post_type AS post_type
                   FROM {$wpdb->term_relationships} tr
                   INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
                   INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
                  WHERE tt.term_id IN ({$in})",
                'wc_memberships_restricted_ids.term_ids'
            );
            foreach (self::wcm_guarded_ids($rows) as $id => $unused) {
                $set[$id] = true;
            }
        }

        if ($rules['taxonomy_wide']) {
            $in   = "'" . implode("','", array_map('esc_sql', array_keys($rules['taxonomy_wide']))) . "'";
            $rows = self::db_results(
                "SELECT DISTINCT tr.object_id AS object_id, tt.taxonomy AS taxonomy, p.post_type AS post_type
                   FROM {$wpdb->term_relationships} tr
                   INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
                   INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
                  WHERE tt.taxonomy IN ({$in})",
                'wc_memberships_restricted_ids.taxonomy_wide'
            );
            foreach (self::wcm_guarded_ids($rows) as $id => $unused) {
                $set[$id] = true;
            }
        }

        if ($rules['inherit_seeds'] && 'yes' === get_option('wc_memberships_inherit_restrictions')) {
            foreach ($rules['inherit_seeds'] as $post_type => $seed_ids) {
                $frontier = array_unique(array_map('absint', $seed_ids));
                $seen     = array_fill_keys($frontier, true);
                $depth    = 0;
                while ($frontier && $depth < self::ANCESTOR_DEPTH) {
                    $depth++;
                    $in   = implode(',', $frontier);
                    $rows = self::db_col(
                        $wpdb->prepare(
                            "SELECT ID FROM {$wpdb->posts}
                              WHERE post_parent IN ({$in}) AND post_type = %s AND post_status = 'publish'",
                            $post_type
                        ),
                        'wc_memberships_restricted_ids.inherit'
                    );
                    if (false === $rows) {
                        break;
                    }
                    $next = array();
                    // $seen, not $set. A descendant already restricted by some other rule
                    // still has to be walked through, or its own children are missed.
                    foreach (self::id_set($rows) as $id => $unused) {
                        if (!isset($seen[$id])) {
                            $seen[$id] = true;
                            $set[$id]  = true;
                            $next[$id] = $id;
                        }
                    }
                    $frontier = $next;
                }
            }
        }

        // The public override wins over everything.
        if ($set && $rules['has_force_public']) {
            $rows   = self::db_col(
                "SELECT post_id FROM {$wpdb->postmeta}
                  WHERE meta_key = '_wc_memberships_force_public' AND meta_value = 'yes'",
                'wc_memberships_restricted_ids.force_public'
            );
            $public = self::id_set(false === $rows ? array() : $rows);
            if ($public) {
                $in       = implode(',', array_keys($public));
                $rows     = self::db_col(
                    "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_parent IN ({$in})",
                    'wc_memberships_restricted_ids.variations'
                );
                $variants = self::id_set(false === $rows ? array() : $rows);
                foreach ($variants as $id => $unused) {
                    $public[$id] = true;
                }
            }
            $set = array_diff_key($set, $public);
        }

        return $set;
    }

    /**
     * Apply WooCommerce Memberships' own taxonomy to post type guard to a term query's
     * rows.
     *
     * @param array|false $rows [ object_id, taxonomy, post_type ]
     * @return array [ post_id => true ]
     */
    private static function wcm_guarded_ids($rows)
    {
        if (false === $rows) {
            return array();
        }

        $object_types = array();
        $set          = array();

        foreach ((array) $rows as $row) {
            $taxonomy = isset($row['taxonomy']) ? (string) $row['taxonomy'] : '';
            $type     = isset($row['post_type']) ? (string) $row['post_type'] : '';
            $id       = isset($row['object_id']) ? absint($row['object_id']) : 0;
            if (!$id || '' === $taxonomy) {
                continue;
            }

            if (!isset($object_types[$taxonomy])) {
                $object = self::safe_call('get_taxonomy', array($taxonomy), null);
                $object_types[$taxonomy] = ($object && isset($object->object_type))
                    ? array_map('strval', (array) $object->object_type)
                    : array();
            }

            if (in_array($type, $object_types[$taxonomy], true)) {
                $set[$id] = true;
            }
        }

        return $set;
    }

    /**
     * Restrict User Access.
     *
     * @return array [ gates_content, scope, post_types ]
     */
    private static function detect_rua()
    {
        $blog = self::blog_key();
        if (isset(self::$rua_detect[$blog])) {
            return self::$rua_detect[$blog];
        }

        global $wpdb;

        $memo = array('gates_content' => false, 'scope' => 'none', 'post_types' => array());
        if (!self::plugin_active('rua')) {
            self::$rua_detect[$blog] = $memo;
            return self::$rua_detect[$blog];
        }

        // Only an active level gates. A draft or trashed level does not.
        $levels = self::db_col(
            "SELECT ID FROM {$wpdb->posts} WHERE post_type='restriction' AND post_status='publish'",
            'detect_rua.levels'
        );
        if (false === $levels) {
            return $memo;
        }
        if (!$levels) {
            self::$rua_detect[$blog] = $memo;
            return self::$rua_detect[$blog];
        }

        $pt_options  = self::option_array('_ca_post_type_options');
        $use_negated = !empty($pt_options['restriction']['legacy']['negated_conditions']);
        $statuses    = array('publish', 'wpca_or', 'wpca_except');
        if ($use_negated) {
            $statuses[] = 'negated';
        }

        $in    = implode(',', array_map('absint', $levels));
        $slist = "'" . implode("','", array_map('esc_sql', $statuses)) . "'";
        // menu_order 2 is archive only and can never match a singular view. post_parent
        // is selected as well, because the except handling below is per LEVEL.
        $groups = self::db_results(
            "SELECT ID, post_status, post_parent FROM {$wpdb->posts}
              WHERE post_type='condition_group'
                AND post_parent IN ({$in})
                AND post_status IN ({$slist})
                AND menu_order <= 1",
            'detect_rua.groups'
        );
        if (false === $groups) {
            return $memo;
        }
        if (!$groups) {
            self::$rua_detect[$blog] = $memo;
            return self::$rua_detect[$blog];
        }

        $gin  = implode(',', array_map(function ($g) {
            return absint($g['ID']);
        }, $groups));
        $meta = self::db_results(
            "SELECT post_id, meta_key, meta_value FROM {$wpdb->postmeta}
              WHERE post_id IN ({$gin}) AND meta_key LIKE '\\_ca\\_%'",
            'detect_rua.group_meta'
        );
        if (false === $meta) {
            return $memo;
        }

        $by_group = array();
        foreach ((array) $meta as $row) {
            $by_group[(int) $row['post_id']][$row['meta_key']][] = $row['meta_value'];
        }

        // DENYLIST, not an allowlist. WPCA fires do_action('wpca/modules/init'), so an
        // add-on can register a module with an arbitrary id and an arbitrary _ca_<id>
        // key. An allowlist would silently ignore those and fail OPEN.
        $not_conditions = array('_ca_autoselect', '_ca_opt_drip');
        $narrowable     = array('_ca_post_type', '_ca_taxonomy');

        $site_wide  = false;
        $post_types = array();
        $saw        = false;

        foreach ($groups as $group) {
            $gid = (int) $group['ID'];

            if ('negated' === $group['post_status']) {
                $site_wide = true;
                $saw       = true;
                continue;
            }

            if ('wpca_except' === $group['post_status']) {
                $saw = true;
                continue;
            }

            $gm = isset($by_group[$gid]) ? $by_group[$gid] : array();
            foreach ($not_conditions as $key) {
                unset($gm[$key]);
            }

            if (empty($gm)) {
                $site_wide = true;
                $saw       = true;
                continue;
            }

            foreach ($gm as $key => $values) {
                if ('_ca_static' === $key) {
                    if (in_array('front-page', $values, true)) {
                        $saw          = true;
                        $post_types[] = 'page';
                    }
                    continue;
                }

                $saw = true;

                if (!in_array($key, $narrowable, true)) {
                    $site_wide = true;
                    continue;
                }

                if ('_ca_post_type' === $key) {
                    foreach ($values as $value) {
                        if (ctype_digit((string) $value)) {
                            $type = self::safe_call('get_post_type', array((int) $value), '');
                            if ($type) {
                                $post_types[] = $type;
                            }
                        } else {
                            $post_types[] = (string) $value;
                        }
                    }
                    continue;
                }

                foreach ($values as $value) {
                    if ('-1' === (string) $value) {
                        $taxonomies = self::safe_call('get_taxonomies', array(array(), 'names'), array());
                        $terms      = self::safe_call('wp_get_object_terms', array($gid, $taxonomies), array());
                        if (!is_array($terms) || !$terms) {
                            $site_wide = true;
                            continue;
                        }
                        foreach ($terms as $term) {
                            $object = self::safe_call('get_taxonomy', array($term->taxonomy), null);
                            foreach (($object ? (array) $object->object_type : array()) as $type) {
                                $post_types[] = $type;
                            }
                        }
                        continue;
                    }

                    $object = self::safe_call('get_taxonomy', array((string) $value), null);
                    $types  = $object ? (array) $object->object_type : array();
                    foreach ($types as $type) {
                        $post_types[] = $type;
                    }
                    if (!$types) {
                        $site_wide = true;
                    }
                }
            }
        }

        self::$rua_detect[$blog] = array(
            'gates_content' => $saw,
            'scope'         => !$saw ? 'none' : ($site_wide ? 'site' : 'post_types'),
            'post_types'    => array_values(array_unique($post_types)),
        );

        return self::$rua_detect[$blog];
    }

    /**
     * Content Control.
     *
     * @return array [ gates_content, scope, post_types ]
     */
    private static function detect_cc()
    {
        $blog = self::blog_key();
        if (isset(self::$cc_detect[$blog])) {
            return self::$cc_detect[$blog];
        }

        global $wpdb;

        $memo = array('gates_content' => false, 'scope' => 'none', 'post_types' => array());
        if (!self::plugin_active('cc')) {
            self::$cc_detect[$blog] = $memo;
            return self::$cc_detect[$blog];
        }

        $sets = array();

        if (post_type_exists('cc_restriction')) {
            $rows = self::db_results(
                "SELECT p.ID, pm.meta_value
                   FROM {$wpdb->posts} p
                   LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = 'restriction_settings'
                  WHERE p.post_type = 'cc_restriction' AND p.post_status = 'publish'",
                'detect_cc.restrictions'
            );
            if (false === $rows) {
                return $memo;
            }
            foreach ((array) $rows as $row) {
                $sets[] = self::safe_unserialize($row['meta_value']);
            }
        }

        $legacy = self::option_array('jp_cc_settings');
        if (!empty($legacy['restrictions'])) {
            foreach ((array) $legacy['restrictions'] as $old) {
                $sets[] = array(
                    'userStatus' => isset($old['who']) ? $old['who'] : '',
                    '_v1'        => $old,
                );
            }
        }

        $site_wide  = false;
        $post_types = array();
        $saw        = false;

        foreach ($sets as $settings) {
            $settings = is_array($settings) ? $settings : array();

            if ('logged_out' === (isset($settings['userStatus']) ? $settings['userStatus'] : '')) {
                continue;
            }

            $items = array();
            if (isset($settings['_v1'])) {
                $conditions = isset($settings['_v1']['conditions']) ? (array) $settings['_v1']['conditions'] : array();
                foreach ($conditions as $group) {
                    foreach ((array) $group as $condition) {
                        if (!is_array($condition) || !isset($condition['target'])) {
                            $saw       = true;
                            $site_wide = true;
                            continue;
                        }
                        $items[] = array('name' => self::cc_remap_v1_target((string) $condition['target']));
                    }
                }
                if (!$items) {
                    $saw       = true;
                    $site_wide = true;
                    continue;
                }
            } elseif (isset($settings['conditions']['items']) && is_array($settings['conditions']['items'])) {
                $items = $settings['conditions']['items'];
            }

            // A Content Control restriction with NO conditions restricts NOTHING.
            // Restriction::check_rules() short circuits before the query is ever asked.
            if (!$items) {
                continue;
            }

            $flat = array();
            if (!self::cc_flatten($items, $flat, 0)) {
                // The condition tree was deeper than CC_FLATTEN_DEPTH, so part of it was
                // never classified and the scope cannot be trusted. Fail closed.
                self::record_reason('cc_tree_too_deep', 'Content Control condition tree exceeded ' . self::CC_FLATTEN_DEPTH . ' levels');
                $saw       = true;
                $site_wide = true;
                continue;
            }

            foreach ($flat as $rule) {
                $name = isset($rule['name']) ? $rule['name'] : '';

                // A negated rule inverts the blast radius: NOT content_is_doc restricts
                // every post type EXCEPT doc. The boolean stays sound, narrowing does not.
                if (!empty($rule['notOperand'])) {
                    $saw       = true;
                    $site_wide = true;
                    continue;
                }

                $class = self::cc_classify_rule($name);
                if ('none' === $class['kind']) {
                    continue;
                }
                $saw = true;
                if ('site' === $class['kind']) {
                    $site_wide = true;
                }
                if (!empty($class['post_type'])) {
                    $post_types[] = $class['post_type'];
                }
            }
        }

        self::$cc_detect[$blog] = array(
            'gates_content' => $saw,
            'scope'         => !$saw ? 'none' : ($site_wide ? 'site' : 'post_types'),
            'post_types'    => array_values(array_unique($post_types)),
        );

        return self::$cc_detect[$blog];
    }

    /**
     * Flatten Content Control's nested condition groups into a flat rule list.
     *
     * @param array $items
     * @param array $flat
     * @param int   $depth
     * @return bool False when the depth cap was hit, so $flat is incomplete.
     */
    private static function cc_flatten($items, &$flat, $depth = 0)
    {
        if ($depth > self::CC_FLATTEN_DEPTH) {
            return false;
        }

        $complete = true;

        foreach ((array) $items as $item) {
            if (!is_array($item)) {
                continue;
            }
            if (isset($item['type']) && 'group' === $item['type']) {
                $nested = isset($item['query']['items']) ? $item['query']['items'] : array();
                if (!self::cc_flatten($nested, $flat, $depth + 1)) {
                    $complete = false;
                }
                continue;
            }
            $flat[] = $item;
        }

        return $complete;
    }

    /**
     * Classify one Content Control rule name.
     *
     * @param string $name
     * @return array [ kind, post_type ]
     */
    private static function cc_classify_rule($name)
    {
        $none = array('kind' => 'none');

        if (in_array($name, array('user_is_logged_in', 'user_has_role', 'content_is_search_results', 'content_is_404_page'), true)) {
            return $none;
        }

        if ('entire_site' === $name) {
            return array('kind' => 'site');
        }
        if ('content_is_front_page' === $name || 'content_is_blog_index' === $name) {
            return array('kind' => 'post_type', 'post_type' => 'page');
        }

        $objects = self::safe_call('get_post_types', array(array(), 'objects'), array());
        $objects = is_array($objects) ? $objects : array();
        $types   = array_keys($objects);
        $taxes   = self::safe_call('get_taxonomies', array(array(), 'names'), array());
        $taxes   = is_array($taxes) ? array_values($taxes) : array();

        // Archive rules first. They never withhold a singular body.
        foreach ($types as $type) {
            if ($name !== "content_is_{$type}_archive") {
                continue;
            }
            $object = isset($objects[$type]) ? $objects[$type] : null;
            if ('post' === $type || ($object && !empty($object->has_archive))) {
                return $none;
            }
        }
        foreach ($taxes as $tax) {
            if ($name === "content_is_{$tax}_archive"
                || $name === "content_is_selected_tax_{$tax}"
                || $name === "content_is_tax_{$tax}_with_id"
            ) {
                return $none;
            }
        }

        foreach ($types as $type) {
            foreach ($taxes as $tax) {
                if ($name === "content_is_{$type}_with_{$tax}") {
                    return array('kind' => 'post_type', 'post_type' => $type);
                }
            }
        }

        foreach ($types as $type) {
            $candidates = array(
                "content_is_{$type}",
                "content_is_selected_{$type}",
                "content_is_{$type}_with_id",
                "content_is_child_of_{$type}",
                "content_is_ancestor_of_{$type}",
                "content_is_{$type}_with_template",
            );
            if (in_array($name, $candidates, true)) {
                return array('kind' => 'post_type', 'post_type' => $type);
            }
        }

        // Unrecognised. "Fail safe" is not one direction here, so ask Content Control's
        // own registry which of the two cases this is.
        $registered = self::cc_rule_registered($name);
        if (false === $registered) {
            return $none;
        }

        return array('kind' => 'site');
    }

    /**
     * Is a Content Control rule name backed by a registered rule with a callable
     * callback?
     *
     * @param string $name
     * @return bool|null Null when the registry could not be reached, which is treated as
     *                   "assume it restricts".
     */
    private static function cc_rule_registered($name)
    {
        if ('' === (string) $name || !function_exists('content_control')) {
            return null;
        }

        $rules = self::safe_call('content_control', array('rules'), null);
        if (!is_object($rules) || !method_exists($rules, 'get_rule')) {
            return null;
        }

        $definition = self::safe_call(array($rules, 'get_rule'), array($name), null);
        if (!is_array($definition)) {
            return false;
        }

        $callback = isset($definition['callback']) ? $definition['callback'] : null;

        return (bool) ($callback && is_callable($callback));
    }

    /**
     * Map a Content Control v1 condition target onto a v2 rule name, mirroring the
     * plugin's own remap_condition_to_rule() for the name only.
     *
     * @param string $target
     * @return string
     */
    private static function cc_remap_v1_target($target)
    {
        if (preg_match('/^tax_(.+)_all$/', $target, $m)) {
            return 'content_is_' . $m[1] . '_archive';
        }
        if (preg_match('/^tax_(.+)_selected$/', $target, $m)) {
            return 'content_is_selected_tax_' . $m[1];
        }
        if (preg_match('/^tax_(.+)_ID$/', $target, $m)) {
            return 'content_is_tax_' . $m[1] . '_with_id';
        }

        // _w_ is tested here, second, because Content Control tests strpos( $target,
        // '_w_' ) > 0 BEFORE it splits off a generic modifier
        // (inc/functions/back-compat.php:90, :118, :127).
        if (preg_match('/^(.+)_w_(.+)$/', $target, $m)) {
            return 'content_is_' . $m[1] . '_with_' . $m[2];
        }

        if (preg_match('/^(.+)_index$/', $target, $m)) {
            return 'content_is_' . $m[1] . '_archive';
        }
        if (preg_match('/^(.+)_all$/', $target, $m)) {
            return 'content_is_' . $m[1];
        }
        if (preg_match('/^(.+)_selected$/', $target, $m)) {
            return 'content_is_selected_' . $m[1];
        }
        if (preg_match('/^(.+)_ID$/', $target, $m)) {
            return 'content_is_' . $m[1] . '_with_id';
        }
        if (preg_match('/^(.+)_children$/', $target, $m)) {
            return 'content_is_child_of_' . $m[1];
        }
        if (preg_match('/^(.+)_ancestors$/', $target, $m)) {
            return 'content_is_ancestor_of_' . $m[1];
        }
        if (preg_match('/^(.+)_template$/', $target, $m)) {
            return 'content_is_' . $m[1] . '_with_template';
        }

        return $target;
    }

    /**
     * Which Restrict Content engine is in force. '3.0', 'legacy', or ''.
     *
     * @return string
     */
    private static function rcp_mode()
    {
        $has_30 = function_exists('rcp_is_restricted_content')
            || function_exists('rcp_user_can_access')
            || class_exists('Restrict_Content_Pro')
            || defined('RCP_PLUGIN_VERSION');

        if ($has_30) {
            return '3.0';
        }

        if (defined('RC_PLUGIN_VERSION')) {
            return 'legacy';
        }

        $mode = get_option('restrict_content_chosen_version');
        if ('3.0' === $mode || 'legacy' === $mode) {
            return $mode;
        }

        if (!self::plugin_active('rcp')) {
            return '';
        }

        return 'legacy';
    }

    /**
     * @param string $name
     * @return array
     */
    private static function option_array($name)
    {
        $value = get_option($name);
        return is_array($value) ? $value : array();
    }

    /**
     * First value of a candidate meta key for a post, or ''.
     *
     * @param array  $ctx
     * @param int    $post_id
     * @param string $key
     * @return string
     */
    private static function meta_single($ctx, $post_id, $key)
    {
        if (empty($ctx['meta'][$post_id][$key])) {
            return '';
        }
        return (string) $ctx['meta'][$post_id][$key][0];
    }

    /**
     * @param array $ids
     * @return array [ post_id => true ]
     */
    private static function id_set($ids)
    {
        $set = array();
        foreach ((array) $ids as $id) {
            $id = absint($id);
            if ($id) {
                $set[$id] = true;
            }
        }
        return $set;
    }

    /**
     * OR-joined LIKE clause over a set of literal fragments.
     *
     * @param string   $column
     * @param string[] $fragments
     * @return string
     */
    private static function like_clause($column, array $fragments)
    {
        global $wpdb;

        $parts = array();
        foreach ($fragments as $fragment) {
            $parts[] = $wpdb->prepare("{$column} LIKE %s", '%' . $wpdb->esc_like($fragment) . '%');
        }

        return implode(' OR ', $parts);
    }

    /**
     * Public post types, used when a tier 3 verdict cannot be narrowed.
     *
     * @return string[]
     */
    private static function public_post_types()
    {
        $types = self::safe_call('get_post_types', array(array('public' => true), 'names'), array());
        $types = is_array($types) ? array_values($types) : array();

        return array_values(array_diff($types, array('attachment', 'llms_txt')));
    }

    /**
     * Call a core or third party function and fail open on anything it throws.
     *
     * @param callable $callback
     * @param array    $args
     * @param mixed    $fallback
     * @return mixed
     */
    private static function safe_call($callback, array $args, $fallback)
    {
        if (!is_callable($callback)) {
            return $fallback;
        }
        try {
            return call_user_func_array($callback, $args);
        } catch (\Throwable $e) {
            return $fallback;
        }
    }
}
