<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Dynamic filters for loops: state, URLs, public endpoint and runtime.
 *
 * WHAT IT SOLVES. The engine already knows how to query WordPress, but the result was frozen at
 * render time: a product grid stayed as it was, and changing it took another section. Here the
 * visitor filters — by taxonomy, by search, by sort order — and the list updates without a reload.
 *
 * THREE PRINCIPLES, because a public filter is an attack surface:
 *
 *  1. THE TEMPLATE COMMANDS THE QUERY, NOT THE CLIENT. WP_Query arguments never arrive from the
 *     browser: only term slugs, a search string and a sort order do, and the server re-reads the
 *     saved template and rebuilds the query from scratch. The post type, the limit and any fixed
 *     constraints stay the ones written in the section: the client can only NARROW within what the
 *     template has already granted.
 *
 *  2. ONLY PUBLIC TAXONOMIES ACTUALLY ATTACHED to the post type in question. A private taxonomy (or
 *     one belonging to another type) is ignored instead of becoming a way to probe content that
 *     should not show up.
 *
 *  3. THE STATE LIVES IN THE URL. Not in a cookie, not only in memory: that way a filtered result
 *     can be shared, reloaded and bookmarked — and whoever has no JavaScript still navigates,
 *     because the chips are real links with a working href.
 *
 * URL keys: `<filter-id>-<taxonomy>=slug1,slug2`, plus `-s` (search), `-sort`, `-page`. The filter
 * id prefix is what lets two filters live on the same page.
 */
class AIPB_Filters {

	/** Reserved suffixes: a taxonomy named like one of these would not be filterable. */
	const RESERVED = array( 's', 'sort', 'page' );

	/**
	 * Minimum search length. Below this threshold the term is IGNORED (the list stays complete)
	 * rather than rejected: someone still typing does not deserve an error.
	 *
	 * This is not usability pedantry, it is this endpoint's main defence. A search becomes
	 * `LIKE '%…%'` over post_title and post_content: no index helps it, and the database pays the
	 * cost. With a single letter, `?catalog-s=a`, anyone without credentials can request a full scan
	 * of the content table as often as they like.
	 */
	const MIN_SEARCH = 2;

	/**
	 * Ceiling on the requested page. `page` was only bounded from below, so
	 * `?catalog-page=999999999` reached WP_Query intact and became a huge OFFSET with
	 * SQL_CALC_FOUND_ROWS on — that is, full database work to return zero results. No real catalogue
	 * has two hundred pages to leaf through by hand.
	 */
	const MAX_PAGE = 200;

	/** Window and threshold of the brake on SEARCHES (not on chip clicks: those are cached). */
	const RATE_MAX    = 40;
	const RATE_WINDOW = 60;

	/** How long responses stay cached. Short: the key invalidates itself, see cache_key(). */
	const CACHE_TTL = 300;

	/** Post holding the filter being rendered: needed to build URLs and the endpoint. */
	protected static $source = 0;

	/** Address of the archive an AJAX request comes from: the WP query does not exist there. */
	protected static $archive_base = '';

	/** A page with at least one filter has to carry the runtime with it. */
	protected static $used = false;

	public static function init() {
		add_action( 'rest_api_init', array( __CLASS__, 'routes' ) );
		// The runtime goes AFTER the markup: by then we know whether a filter was rendered.
		add_action( 'wp_footer', array( __CLASS__, 'print_runtime' ), 20 );
	}

	/* -------------------------------- Context -------------------------------- */

	public static function set_source( $id ) {
		self::$source = (int) $id;
	}

	/**
	 * The post that CONTAINS the filter: the endpoint re-reads the template from it.
	 *
	 * On a single piece of content it is the content itself. On an ARCHIVE it is not: the queried
	 * object is a term, and its id used to end up in the attributes in place of a post's. The
	 * endpoint then asked for its publication status, found nothing and answered 404 — meaning a
	 * filter inside an archive template broke on the first click. There the template lives in the
	 * `wp_template` post that draws the request, and asking AIPB_Render is the only way to know it
	 * for certain.
	 */
	public static function source() {
		if ( self::$source ) {
			return self::$source;
		}

		$id = function_exists( 'get_queried_object_id' ) ? (int) get_queried_object_id() : 0;

		// A single piece of content built with Aura carries the filter in its own content.
		if ( $id && function_exists( 'is_singular' ) && is_singular() && 'publish' === get_post_status( $id ) ) {
			return $id;
		}

		// Otherwise — archives, home, search — the filter lives in Aura's FSE template.
		if ( class_exists( 'AIPB_Render' ) ) {
			$tpl = (int) AIPB_Render::aura_template_id();
			if ( $tpl ) {
				return $tpl;
			}
		}

		return $id;
	}

	public static function mark_used() {
		self::$used = true;
	}

	/* --------------------------------- State --------------------------------- */

	/** The URL key for one piece of a filter's state. */
	public static function key( $filter_id, $name ) {
		return sanitize_key( $filter_id ) . '-' . sanitize_key( $name );
	}

	/**
	 * Reads a filter's state.
	 *
	 * @param string     $filter_id Filter id (namespace of the keys).
	 * @param array|null $src       Where the parameters come from; defaults to $_GET.
	 * @return array { tax: [tax => [slug]], meta: [field => cond], s: string, sort: string, page: int }
	 */
	public static function state( $filter_id, $src = null ) {
		$id = sanitize_key( $filter_id );
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading public filters, no action taken.
		$src = ( null === $src ) ? wp_unslash( $_GET ) : $src;
		$src = is_array( $src ) ? $src : array();

		$prefix = $id . '-';
		$tax    = array();
		$meta   = array();

		foreach ( $src as $k => $v ) {
			if ( 0 !== strpos( (string) $k, $prefix ) || is_array( $v ) ) {
				continue;
			}
			$name = substr( (string) $k, strlen( $prefix ) );
			if ( '' === $name || in_array( $name, self::RESERVED, true ) ) {
				continue;
			}

			if ( self::read_meta_condition( $name, $v, $meta ) ) {
				continue;
			}

			$slugs = array_values( array_unique( array_filter( array_map(
				'sanitize_title',
				explode( ',', (string) $v )
			) ) ) );
			if ( $slugs ) {
				// Defensive cap: a URL with hundreds of terms is an attempt, not a use.
				$tax[ sanitize_key( $name ) ] = array_slice( $slugs, 0, 20 );
			}
		}

		return array(
			'tax'  => $tax,
			'meta' => $meta,
			's'    => self::clean_search( isset( $src[ $prefix . 's' ] ) ? $src[ $prefix . 's' ] : '' ),
			'sort' => isset( $src[ $prefix . 'sort' ] ) ? sanitize_key( (string) $src[ $prefix . 'sort' ] ) : '',
			'page' => isset( $src[ $prefix . 'page' ] ) ? self::clean_page( $src[ $prefix . 'page' ] ) : 1,
		);
	}

	/**
	 * Recognises a FIELD condition and files it under $meta.
	 *
	 * Fields have a prefix of their own — `m-` for equality, `min-`/`max-` for ranges. Without it a
	 * field named like a taxonomy would be indistinguishable from it, and the same key would mean
	 * two different things.
	 *
	 * @param string $name  Key name, already stripped of the filter prefix.
	 * @param mixed  $value Raw value from the URL.
	 * @param array  $meta  Conditions collected so far, by reference.
	 * @return bool True when the key was a field and has been consumed.
	 */
	protected static function read_meta_condition( $name, $value, &$meta ) {
		foreach ( array( 'm-' => 'eq', 'min-' => 'min', 'max-' => 'max' ) as $prefix => $role ) {
			if ( 0 !== strpos( $name, $prefix ) ) {
				continue;
			}

			$field = sanitize_key( substr( $name, strlen( $prefix ) ) );
			if ( '' !== $field ) {
				$meta[ $field ][ $role ] = ( 'eq' === $role )
					? array_slice( array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ) ) ), 0, 20 )
					: sanitize_text_field( (string) $value );
			}

			return true;
		}

		return false;
	}

	/**
	 * The search string, or empty when it is too short to be worth a table scan.
	 *
	 * The cut happens HERE and not only in the endpoint, deliberately: `state()` is used both by the
	 * page render and by the AJAX call, and if the two thresholds diverged the same search would
	 * give two different lists depending on how you got there.
	 */
	protected static function clean_search( $raw ) {
		$s   = trim( sanitize_text_field( (string) $raw ) );
		$min = (int) apply_filters( 'aipb_filter_min_search', self::MIN_SEARCH );
		if ( $min > 0 && mb_strlen( $s ) < $min ) {
			return '';
		}
		return $s;
	}

	/** The page number, within bounds that describe a catalogue and not an attempt. */
	protected static function clean_page( $raw ) {
		$max = max( 1, (int) apply_filters( 'aipb_filter_max_page', self::MAX_PAGE ) );
		return min( $max, max( 1, (int) $raw ) );
	}

	/** Does the state say anything, or is this the starting list? Used by the "All" button. */
	public static function is_filtered( $state ) {
		return ! empty( $state['tax'] ) || ! empty( $state['meta'] )
			|| '' !== (string) $state['s'] || '' !== (string) $state['sort'];
	}

	/* ---------------------------------- URLs --------------------------------- */

	/** The page the filter lives on: that is where links must point, not to the REST route. */
	public static function base_url() {
		// On an ARCHIVE the link is the term's, not that of the template drawing it: chips and
		// arrows have to lead back to the page being looked at. Taking the source post's permalink
		// here would give the address of a `wp_template`, which is not a page.
		if ( function_exists( 'get_queried_object' ) ) {
			$q = get_queried_object();
			if ( $q instanceof WP_Term ) {
				$link = get_term_link( $q );
				if ( ! is_wp_error( $link ) ) {
					return $link;
				}
			}
			if ( $q instanceof WP_Post && function_exists( 'is_singular' ) && is_singular() ) {
				$link = get_permalink( $q );
				if ( $link ) {
					return $link;
				}
			}
		}

		// From the endpoint the archive query does not exist: the term travels with the request,
		// already validated, and the same address is derived from it.
		if ( self::$archive_base ) {
			return self::$archive_base;
		}

		$id = self::source();
		if ( $id ) {
			$link = get_permalink( $id );
			if ( $link ) {
				return $link;
			}
		}
		return home_url( '/' );
	}

	/** State → query string (empty keys disappear, so the URL stays clean). */
	public static function args( $filter_id, $state ) {
		$id   = sanitize_key( $filter_id );
		$args = array();

		foreach ( (array) $state['tax'] as $tax => $slugs ) {
			if ( $slugs ) {
				$args[ $id . '-' . sanitize_key( $tax ) ] = implode( ',', $slugs );
			}
		}
		foreach ( (array) ( isset( $state['meta'] ) ? $state['meta'] : array() ) as $field => $cond ) {
			$field = sanitize_key( $field );
			if ( ! empty( $cond['eq'] ) ) {
				$args[ $id . '-m-' . $field ] = implode( ',', (array) $cond['eq'] );
			}
			foreach ( array( 'min', 'max' ) as $side ) {
				if ( isset( $cond[ $side ] ) && '' !== $cond[ $side ] ) {
					$args[ $id . '-' . $side . '-' . $field ] = (string) $cond[ $side ];
				}
			}
		}
		if ( '' !== (string) $state['s'] ) {
			$args[ $id . '-s' ] = (string) $state['s'];
		}
		if ( '' !== (string) $state['sort'] ) {
			$args[ $id . '-sort' ] = (string) $state['sort'];
		}
		if ( (int) $state['page'] > 1 ) {
			$args[ $id . '-page' ] = (int) $state['page'];
		}
		return $args;
	}

	/** Full URL for a state. Keeps the other parameters already present in the address. */
	public static function url( $filter_id, $state ) {
		$id   = sanitize_key( $filter_id );
		$keep = array();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		foreach ( (array) wp_unslash( $_GET ) as $k => $v ) {
			if ( is_array( $v ) || 0 === strpos( (string) $k, $id . '-' ) ) {
				continue; // THIS filter's parameters are the ones we rewrite
			}
			$keep[ sanitize_key( $k ) ] = sanitize_text_field( (string) $v );
		}

		return add_query_arg( array_merge( $keep, self::args( $id, $state ) ), self::base_url() );
	}

	/** URL with a term switched on or off: this is what ends up in a chip's href. */
	public static function toggle_url( $filter_id, $state, $taxonomy, $slug ) {
		$tax  = sanitize_key( $taxonomy );
		$slug = sanitize_title( $slug );
		$next = $state;

		$current = isset( $next['tax'][ $tax ] ) ? (array) $next['tax'][ $tax ] : array();
		if ( in_array( $slug, $current, true ) ) {
			$current = array_values( array_diff( $current, array( $slug ) ) );
		} else {
			$current[] = $slug;
		}

		if ( $current ) {
			$next['tax'][ $tax ] = $current;
		} else {
			unset( $next['tax'][ $tax ] );
		}
		$next['page'] = 1; // changing a filter always goes back to the first page

		return self::url( $filter_id, $next );
	}

	/** URL that clears the filter ("All"). */
	public static function reset_url( $filter_id ) {
		return self::url( $filter_id, array( 'tax' => array(), 's' => '', 'sort' => '', 'page' => 1 ) );
	}

	/** URL of another page of results. */
	public static function page_url( $filter_id, $state, $page ) {
		$next         = $state;
		$next['page'] = max( 1, (int) $page );
		return self::url( $filter_id, $next );
	}

	/* -------------------------------- Markers -------------------------------- */

	/**
	 * Bounds of the results, as HTML COMMENTS.
	 *
	 * Not a <div>: results almost always sit inside a grid, and slipping a container between the
	 * grid and the cards would break the layout the AI designed. Two comments have no visual
	 * rendering and still delimit a range the runtime knows how to empty and refill.
	 */
	public static function marker( $filter_id, $which ) {
		$id = sanitize_key( $filter_id );
		return ( 'start' === $which ) ? '<!--aipb:results:' . $id . '-->' : '<!--/aipb:results:' . $id . '-->';
	}

	/** The filter's container: it carries everything the runtime needs. */
	public static function wrap( $filter_id, $html, $ctx ) {
		self::mark_used();

		$id   = sanitize_key( $filter_id );
		$post = self::source();

		// THE LANGUAGE TRAVELS WITH THE ENDPOINT. The next page of results is fetched by the
		// browser, from a URL that is no longer the page's: without saying which language it is
		// for, a multilingual site answers page 2 of the Italian catalogue underneath an English
		// page 1. Empty (and therefore absent) wherever there is only one language.
		$endpoint = rest_url( AIPB_REST::NS . '/filter' );
		$language = AIPB_WPML::language();
		if ( '' !== $language ) {
			$endpoint = add_query_arg( 'lang', $language, $endpoint );
		}

		$attrs = ' data-aipb-filter-root="' . esc_attr( $id ) . '"'
			. ' data-aipb-post="' . (int) $post . '"'
			. ' data-aipb-endpoint="' . esc_url( $endpoint ) . '"'
			. ' data-aipb-total="' . (int) $ctx['total'] . '"'
			// STATE, not a command: it must have a DIFFERENT name from the pager buttons'
			// data-aipb-page. With the same name, click delegation walked up to this container,
			// mistook it for a "go to page 1" command and cancelled (preventDefault) every click
			// made inside the filter — including the result cards, which stopped being clickable
			// even though their href was right.
			. ' data-aipb-cur-page="' . (int) $ctx['page'] . '"'
			. ' data-aipb-pages="' . (int) $ctx['pages'] . '"';

		// The archive's term, when the filter inherits it. It has to travel here because the next
		// request starts from the browser, where the archive query no longer exists: without it, the
		// second page of «Desserts» would show every product on the site.
		if ( ! empty( $ctx['term'] ) ) {
			$attrs .= ' data-aipb-term="' . esc_attr( $ctx['term'] ) . '"'
				. ' data-aipb-tax="' . esc_attr( $ctx['taxonomy'] ) . '"';
		}

		return '<div class="aipb-filter"' . $attrs . '>' . $html . '</div>';
	}

	/* --------------------------------- REST ---------------------------------- */

	public static function routes() {
		register_rest_route( AIPB_REST::NS, '/filter', array(
			'methods'  => 'GET',
			'callback' => array( __CLASS__, 'handle' ),
			// Public: filtering a list is exactly what a visitor does. It only reads already
			// published content, and the query is composed by the server from the saved template.
			'permission_callback' => '__return_true',
		) );
	}

	public static function handle( $request ) {
		$post_id   = (int) $request->get_param( 'post' );
		$filter_id = sanitize_key( (string) $request->get_param( 'filter' ) );

		if ( ! $post_id || '' === $filter_id ) {
			return new WP_Error( 'aipb_filter_args', __( 'Missing parameters.', 'aipb' ), array( 'status' => 400 ) );
		}
		// Public content only: the endpoint must not become a window onto drafts and private posts.
		if ( 'publish' !== get_post_status( $post_id ) ) {
			return new WP_Error( 'aipb_filter_post', __( 'Content not available.', 'aipb' ), array( 'status' => 404 ) );
		}

		$state = self::state( $filter_id, $request->get_params() );

		// The archive term the request comes from. It arrives from the browser, so it is worth
		// nothing until re-checked: it must be a term that exists, in a public taxonomy. The rest —
		// whether the filter really inherits it — is decided by the template, the only thing that
		// commands the query (see principle 1 at the top of the file).
		$state['current'] = self::current_from_request( $request );

		// The address chips and arrows must point at in the response: the archive the request came
		// from, not the template that draws it.
		if ( ! empty( $state['current']['slug'] ) ) {
			$term = get_term_by( 'slug', $state['current']['slug'], $state['current']['taxonomy'] );
			$link = ( $term instanceof WP_Term ) ? get_term_link( $term ) : '';
			self::$archive_base = ( $link && ! is_wp_error( $link ) ) ? $link : '';
		}

		// A search is the only request that cannot be cached — the key space is infinite, just vary
		// the string — and it is also the most expensive. So it is the only one that pays a toll: 40
		// a minute per IP is invisible to someone typing, and takes away a script's ability to order
		// thousands of scans a minute.
		if ( '' !== $state['s'] && ! self::check_rate_limit() ) {
			return new WP_Error(
				'aipb_filter_rate',
				__( 'Too many searches in a short time. Try again in a moment.', 'aipb' ),
				array( 'status' => 429 )
			);
		}

		$cache_key = ( '' === $state['s'] ) ? self::cache_key( $post_id, $filter_id, $state ) : '';
		if ( '' !== $cache_key ) {
			$hit = get_transient( $cache_key );
			if ( is_array( $hit ) ) {
				return rest_ensure_response( $hit );
			}
		}

		$found = self::find_filter( $post_id, $filter_id );
		if ( ! $found ) {
			return new WP_Error( 'aipb_filter_missing', __( 'Filter not found on this page.', 'aipb' ), array( 'status' => 404 ) );
		}

		self::set_source( $post_id );

		$out = AIPB_Template_Engine::render_filter_results(
			$found['template'],
			$found['values'],
			$filter_id,
			$state
		);
		if ( null === $out ) {
			return new WP_Error( 'aipb_filter_missing', __( 'Filter not found.', 'aipb' ), array( 'status' => 404 ) );
		}

		$payload = array(
			'ok'       => true,
			'html'     => $out['html'],
			'total'    => (int) $out['total'],
			'count'    => (int) $out['count'],
			'page'     => (int) $out['page'],
			'pages'    => (int) $out['pages'],
			'has_more' => (bool) $out['has_more'],
			'url'      => self::url( $filter_id, $state ),
		);

		if ( '' !== $cache_key ) {
			$ttl = (int) apply_filters( 'aipb_filter_cache_ttl', self::CACHE_TTL, $post_id, $filter_id );
			if ( $ttl > 0 ) {
				set_transient( $cache_key, $payload, $ttl );
			}
		}

		return rest_ensure_response( $payload );
	}

	/* ---------------------- Endpoint brake and cache ------------------------- */

	/**
	 * The archive term carried by the request, once re-validated.
	 *
	 * It has to exist and belong to a PUBLIC taxonomy: anything else is dropped silently, so a
	 * hand-written `?tax=…&term=…` cannot turn the endpoint into a way of probing private
	 * taxonomies.
	 *
	 * @return array{taxonomy?:string,slug?:string} Empty when nothing usable came in.
	 */
	protected static function current_from_request( $request ) {
		$slug = sanitize_title( (string) $request->get_param( 'term' ) );
		$tax  = sanitize_key( (string) $request->get_param( 'tax' ) );

		if ( '' === $slug || '' === $tax || ! taxonomy_exists( $tax ) ) {
			return array();
		}

		$obj = get_taxonomy( $tax );
		if ( ! $obj || empty( $obj->public ) ) {
			return array();
		}

		$term = get_term_by( 'slug', $slug, $tax );
		if ( ! $term instanceof WP_Term ) {
			return array();
		}

		return array( 'taxonomy' => $tax, 'slug' => $term->slug );
	}

	/**
	 * Key of the cached response — built to expire on its own.
	 *
	 * Besides what was asked for, it also contains:
	 *  - `get_lastpostmodified()`, the moment any content was last changed: publishing or fixing a
	 *    product changes the key and the old responses become unreachable, without having to delete
	 *    them one by one. It is deliberately COARSE — it also invalidates filters that have nothing
	 *    to do with that content — because erring on the high side here costs one query, while
	 *    erring on the low side means showing a stale catalogue;
	 *  - the page's modification date, because the filter's template lives inside it: changing the
	 *    grid in the Studio must invalidate the responses even if no product has moved;
	 *  - the locale, which is not needed today and will be tomorrow (see the note on multilingual
	 *    plugins).
	 *
	 * Searches do not come through here: their key would be chosen by the caller, and filling the
	 * options table with transients does as much damage as scanning it.
	 */
	protected static function cache_key( $post_id, $filter_id, $state ) {
		$parts = array(
			(int) $post_id,
			(string) $filter_id,
			$state['tax'],
			// Without the archive term in the key, the second page of «Desserts» and the one for
			// «Savouries» would be the same cached response: two different archives showing the same
			// products, and no error anywhere.
			isset( $state['current']['slug'] ) ? (string) $state['current']['slug'] : '',
			(string) $state['sort'],
			(int) $state['page'],
			(string) get_post_modified_time( 'U', true, $post_id ),
			(string) get_lastpostmodified( 'server' ),
			(string) determine_locale(),
		);
		return 'aipb_flt_' . md5( wp_json_encode( $parts ) );
	}

	/** Threshold and window of the brake, filterable like the ones on forms. */
	protected static function rate_limit() {
		$conf = apply_filters( 'aipb_filter_rate_limit', array(
			'max'    => self::RATE_MAX,
			'window' => self::RATE_WINDOW,
		) );
		return array(
			max( 1, (int) $conf['max'] ),
			max( 10, (int) $conf['window'] ),
		);
	}

	/**
	 * The visitor's IP, for the rate limit.
	 *
	 * REMOTE_ADDR and not the X-Forwarded-For headers: anyone can write those, so trusting them by
	 * default would make the limit bypassable with a header.
	 *
	 * BEWARE behind a proxy/CDN (Cloudflare, load balancers, some hosts): there REMOTE_ADDR is the
	 * PROXY's IP, the same for every visitor, so the limit becomes global and legitimate users lock
	 * each other out. In that case hook the filter with the header YOU TRUST:
	 *   add_filter( 'aipb_client_ip', fn() => $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '' );
	 *
	 * It used to live in AIPB_Forms, which is gone; `aipb_form_client_ip` is still applied because
	 * that is the name whoever sits behind Cloudflare may already have hooked, and having it quietly
	 * stop working after an update would be the worst possible way to find out.
	 */
	public static function client_ip() {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : '';
		$ip = apply_filters( 'aipb_client_ip', $ip );
		$ip = apply_filters_deprecated( 'aipb_form_client_ip', array( $ip ), '0.38.0', 'aipb_client_ip' );
		return filter_var( $ip, FILTER_VALIDATE_IP ) ? $ip : '';
	}

	/** Sliding window per IP. */
	protected static function check_rate_limit() {
		$ip = self::client_ip();
		if ( '' === $ip ) {
			return true; // without a trustworthy IP we do not block a legitimate visitor
		}
		list( $max, $window ) = self::rate_limit();

		$key   = 'aipb_flt_rl_' . md5( $ip );
		$count = (int) get_transient( $key );
		if ( $count >= $max ) {
			return false;
		}
		set_transient( $key, $count + 1, $window );
		return true;
	}

	/**
	 * Looks for the template containing a given filter, descending into the page's blocks and into
	 * the referenced reusable sections.
	 *
	 * @return array|null { template, values }
	 */
	public static function find_filter( $post_id, $filter_id, $depth = 0 ) {
		if ( $depth > 5 ) {
			return null; // circular references: better to give up than to spin
		}

		// THE SAME CONTENT THE PAGE WAS DRAWN FROM. What arrives from the browser is the ID of the
		// post being looked at, which on a multilingual site can be a translation holding none of
		// our meta: the page renders from the original (AIPB_WPML::content_id) and this has to look
		// in the same place, or the filter is simply not found and the list stops responding.
		$post_id = AIPB_WPML::content_id( (int) $post_id );

		$found = self::find_in_blocks( $post_id, $filter_id, $depth );
		if ( $found ) {
			return $found;
		}

		// Single section (component/pattern): one template in the meta.
		$tpl = (string) get_post_meta( (int) $post_id, AIPB_META_TEMPLATE, true );
		if ( self::template_has_filter( $tpl, $filter_id ) ) {
			$values = json_decode( (string) get_post_meta( (int) $post_id, AIPB_META_VALUES, true ), true );
			$schema = json_decode( (string) get_post_meta( (int) $post_id, AIPB_META_SCHEMA, true ), true );

			return array(
				'template' => $tpl,
				'values'   => AIPB_WPML::translate_values( array(
					'id'     => '',
					'schema' => is_array( $schema ) ? $schema : array( 'fields' => array() ),
					'values' => is_array( $values ) ? $values : array(),
				), $post_id ),
			);
		}

		return null;
	}

	/** The filter inside a page's block list, following `ref` blocks into their sections. */
	protected static function find_in_blocks( $post_id, $filter_id, $depth ) {
		$raw    = get_post_meta( (int) $post_id, AIPB_META_BLOCKS, true );
		$blocks = $raw ? json_decode( (string) $raw, true ) : array();

		if ( ! is_array( $blocks ) ) {
			return null;
		}

		foreach ( $blocks as $b ) {
			if ( ! is_array( $b ) ) {
				continue;
			}

			if ( isset( $b['type'] ) && 'ref' === $b['type'] ) {
				// Resolved exactly as the renderer resolves it (AIPB_Render::render_blocks), or the
				// endpoint would go looking in a different section from the one on the page.
				$ref = AIPB_WPML::object_id( isset( $b['ref'] ) ? (int) $b['ref'] : 0 );
				if ( $ref && 'publish' === get_post_status( $ref ) ) {
					$deep = self::find_filter( $ref, $filter_id, $depth + 1 );
					if ( $deep ) {
						return $deep;
					}
				}
				continue;
			}

			$tpl = isset( $b['template'] ) ? (string) $b['template'] : '';
			if ( self::template_has_filter( $tpl, $filter_id ) ) {
				return array(
					'template' => $tpl,
					'values'   => AIPB_WPML::translate_values( $b, $post_id ),
				);
			}
		}

		return null;
	}

	/** Cheap text check before parsing anything: a filter with this id, in this template. */
	protected static function template_has_filter( $template, $filter_id ) {
		return '' !== $template
			&& false !== strpos( $template, '#filter' )
			&& false !== strpos( $template, 'id="' . $filter_id . '"' );
	}

	/* -------------------------------- Runtime -------------------------------- */

	public static function print_runtime() {
		if ( ! self::$used ) {
			return;
		}
		echo '<script id="aipb-filters">' . self::runtime_js() . "</script>\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
	}

	/**
	 * The filters runtime: it intercepts the controls, asks the server, replaces the results.
	 *
	 * Real progressive enhancement: the chips are <a href> that already work on their own; we only
	 * intercept them here to avoid the reload. If this script never runs, the filter keeps working —
	 * slower, not broken.
	 */
	public static function runtime_js() {
		$js = <<<'JS'
(function(){
	if(window.__aipbFilters){return;} window.__aipbFilters=1;

	var RESERVED={s:1,sort:1,page:1};

	function parse(id){
		var st={tax:{},meta:{},s:'',sort:'',page:1}, q=new URLSearchParams(location.search), p=id+'-';
		q.forEach(function(v,k){
			if(k.indexOf(p)!==0){return;}
			var name=k.slice(p.length);
			if(name==='s'){st.s=v;}
			else if(name==='sort'){st.sort=v;}
			else if(name==='page'){st.page=Math.max(1,parseInt(v,10)||1);}
			/* Fields have a prefix of their own: without it, a field named like a taxonomy
			   would be indistinguishable from it. */
			else if(name.indexOf('m-')===0){ st.meta[name.slice(2)]=Object.assign(st.meta[name.slice(2)]||{},{eq:v.split(',').filter(Boolean)}); }
			else if(name.indexOf('min-')===0){ st.meta[name.slice(4)]=Object.assign(st.meta[name.slice(4)]||{},{min:v}); }
			else if(name.indexOf('max-')===0){ st.meta[name.slice(4)]=Object.assign(st.meta[name.slice(4)]||{},{max:v}); }
			else if(name&&!RESERVED[name]){st.tax[name]=v.split(',').filter(Boolean);}
		});
		return st;
	}

	function params(id,st){
		var q=new URLSearchParams();
		Object.keys(st.tax).forEach(function(t){ if(st.tax[t].length){q.set(id+'-'+t,st.tax[t].join(','));} });
		Object.keys(st.meta||{}).forEach(function(f){
			var c=st.meta[f]||{};
			if(c.eq&&c.eq.length){q.set(id+'-m-'+f,c.eq.join(','));}
			if(c.min){q.set(id+'-min-'+f,c.min);}
			if(c.max){q.set(id+'-max-'+f,c.max);}
		});
		if(st.s){q.set(id+'-s',st.s);}
		if(st.sort){q.set(id+'-sort',st.sort);}
		if(st.page>1){q.set(id+'-page',String(st.page));}
		return q;
	}

	/* The page URL with the state updated: the other parameters stay where they were. */
	function href(id,st){
		var q=new URLSearchParams(location.search), p=id+'-', drop=[];
		q.forEach(function(v,k){ if(k.indexOf(p)===0){drop.push(k);} });
		drop.forEach(function(k){q.delete(k);});
		params(id,st).forEach(function(v,k){q.set(k,v);});
		var s=q.toString();
		return location.pathname+(s?'?'+s:'')+location.hash;
	}

	/* The results live between two comments: no container, no layout to respect. */
	function range(root,id){
		var it=document.createNodeIterator(root,NodeFilter.SHOW_COMMENT),n,a=null,b=null;
		while((n=it.nextNode())){
			var v=(n.nodeValue||'').trim();
			if(v==='aipb:results:'+id){a=n;}
			else if(v==='/aipb:results:'+id){b=n;}
		}
		return (a&&b&&a.parentNode===b.parentNode)?{a:a,b:b}:null;
	}

	function fill(r,html,append){
		var host=r.a.parentNode;
		if(!append){
			var rg=document.createRange(); rg.setStartAfter(r.a); rg.setEndBefore(r.b); rg.deleteContents();
		}
		var ctx=document.createRange(); ctx.selectNodeContents(host);
		host.insertBefore(ctx.createContextualFragment(html),r.b);
	}

	function setText(root,sel,val){
		root.querySelectorAll(sel).forEach(function(el){ el.textContent=String(val); });
	}

	/* Visual state of the controls: a filter that does not look active is a broken filter. */
	function paint(root,id,st){
		root.querySelectorAll('[data-aipb-filter][data-value]').forEach(function(el){
			var tax=el.getAttribute('data-aipb-filter'), val=el.getAttribute('data-value');
			var on=!!(st.tax[tax]&&st.tax[tax].indexOf(val)>=0);
			if(el.tagName==='A'){ el.setAttribute('href',href(id,toggled(st,tax,val))); if(on){el.setAttribute('aria-current','true');}else{el.removeAttribute('aria-current');} }
			else{ el.setAttribute('aria-pressed',on?'true':'false'); }
			el.classList.toggle('is-active',on);
		});
		root.querySelectorAll('select[data-aipb-filter]').forEach(function(el){
			var tax=el.getAttribute('data-aipb-filter');
			el.value=(st.tax[tax]&&st.tax[tax][0])||'';
		});
		root.querySelectorAll('[data-aipb-search]').forEach(function(el){ if(el.value!==st.s){el.value=st.s;} });
		root.querySelectorAll('[data-aipb-sort]').forEach(function(el){ el.value=st.sort; });
		/* Field inputs go back to the state's value: that is what makes the browser's Back
		   button work on ranges too, not only on chips. */
		root.querySelectorAll('[data-aipb-min],[data-aipb-max],[data-aipb-meta]').forEach(function(el){
			var field=el.getAttribute('data-aipb-min')||el.getAttribute('data-aipb-max')||el.getAttribute('data-aipb-meta');
			var c=(st.meta&&st.meta[field])||{};
			var v=el.hasAttribute('data-aipb-min')?(c.min||''):(el.hasAttribute('data-aipb-max')?(c.max||''):((c.eq&&c.eq[0])||''));
			if(el.value!==v){el.value=v;}
		});
		var any=Object.keys(st.tax).length>0||Object.keys(st.meta||{}).length>0||!!st.s||!!st.sort;
		root.querySelectorAll('[data-aipb-reset]').forEach(function(el){ el.hidden=!any; });

		/* "Load more" and the arrows start from the state the server already printed:
		   that way they stay consistent even before the first call. */
		var page=parseInt(root.getAttribute('data-aipb-cur-page')||'1',10);
		var pages=parseInt(root.getAttribute('data-aipb-pages')||'1',10);
		root.querySelectorAll('[data-aipb-more]').forEach(function(el){ el.hidden=(page>=pages); });
		root.querySelectorAll('[data-aipb-page="next"]').forEach(function(el){ el.hidden=(page>=pages); });
		root.querySelectorAll('[data-aipb-page="prev"]').forEach(function(el){ el.hidden=(page<=1); });
	}

	function toggled(st,tax,val){
		var next={tax:{},meta:JSON.parse(JSON.stringify(st.meta||{})),s:st.s,sort:st.sort,page:1};
		Object.keys(st.tax).forEach(function(k){ next.tax[k]=st.tax[k].slice(); });
		var cur=next.tax[tax]||[], i=cur.indexOf(val);
		if(i>=0){cur.splice(i,1);}else{cur.push(val);}
		if(cur.length){next.tax[tax]=cur;}else{delete next.tax[tax];}
		return next;
	}

	document.querySelectorAll('[data-aipb-filter-root]').forEach(function(root){
		var id=root.getAttribute('data-aipb-filter-root');
		var post=parseInt(root.getAttribute('data-aipb-post')||'0',10);
		var endpoint=root.getAttribute('data-aipb-endpoint')||'';
		var st=parse(id), busy=false, timer=null, seq=0;

		/* Preview inside the Studio: there is no published page to ask. The controls stay
		   alive to look at, but they do not navigate to an address that does not exist. */
		var preview=(!post||!endpoint);

		/* push=true adds a history entry, so the Back button undoes the last filter instead of
		   leaving the page. The return from popstate obviously does not push. */
		function apply(append,push){
			paint(root,id,st);
			var u=href(id,st);
			if(false===push){ history.replaceState(null,'',u); }
			else if(u!==location.pathname+location.search+location.hash){ history.pushState(null,'',u); }
			if(preview){return;}

			var mine=++seq;
			busy=true; root.setAttribute('aria-busy','true');
			var q=params(id,st); q.set('post',String(post)); q.set('filter',id);
			/* The archive term: the WordPress query is gone here, so if the filter inherits it
			   it has to be sent back on every request. The server re-checks it anyway: nothing
			   from here is taken on trust. */
			var term=root.getAttribute('data-aipb-term'), tax=root.getAttribute('data-aipb-tax');
			if(term&&tax){ q.set('term',term); q.set('tax',tax); }

			fetch(endpoint+(endpoint.indexOf('?')<0?'?':'&')+q.toString(),{credentials:'same-origin'})
				.then(function(r){ return r.ok?r.json():Promise.reject(r); })
				.then(function(d){
					if(mine!==seq){return;} /* outdated response: discard it */
					var r=range(root,id);
					if(r&&typeof d.html==='string'){ fill(r,d.html,append); }
					setText(root,'[data-aipb-total]',d.total);
					setText(root,'[data-aipb-count]',append?(parseInt(root.getAttribute('data-aipb-shown')||'0',10)+d.count):d.count);
					root.setAttribute('data-aipb-shown',String(append?(parseInt(root.getAttribute('data-aipb-shown')||'0',10)+d.count):d.count));
					root.setAttribute('data-aipb-total',String(d.total));
					root.setAttribute('data-aipb-cur-page',String(d.page));
					root.setAttribute('data-aipb-pages',String(d.pages));
					root.querySelectorAll('[data-aipb-more]').forEach(function(el){ el.hidden=!d.has_more; });
					root.querySelectorAll('[data-aipb-page="next"]').forEach(function(el){ el.hidden=(d.page>=d.pages); });
					root.querySelectorAll('[data-aipb-page="prev"]').forEach(function(el){ el.hidden=(d.page<=1); });
				})
				.catch(function(){ /* network down: the list stays as it was, no hole in the page */ })
				.then(function(){ if(mine===seq){ busy=false; root.removeAttribute('aria-busy'); } });
		}

		root.addEventListener('click',function(e){
			var el=e.target.closest('[data-aipb-filter],[data-aipb-more],[data-aipb-reset],[data-aipb-page]');
			/* el===root: the container carries the filter's STATE, not its commands. Letting it
			   through cancels every click on the results. */
			if(!el||el===root||!root.contains(el)||el.tagName==='SELECT'){return;}
			e.preventDefault();
			if(busy&&el.hasAttribute('data-aipb-more')){return;}

			if(el.hasAttribute('data-aipb-reset')){ st={tax:{},meta:{},s:'',sort:'',page:1}; return apply(false); }
			if(el.hasAttribute('data-aipb-more')){ st.page=st.page+1; return apply(true); }
			if(el.hasAttribute('data-aipb-page')){
				var w=el.getAttribute('data-aipb-page');
				st.page=(w==='next')?st.page+1:(w==='prev'?Math.max(1,st.page-1):Math.max(1,parseInt(w,10)||1));
				return apply(false);
			}
			var tax=el.getAttribute('data-aipb-filter'), val=el.getAttribute('data-value');
			if(tax&&val!==null){ st=toggled(st,tax,val); apply(false); }
		});

		root.addEventListener('change',function(e){
			var el=e.target;
			if(el.matches('select[data-aipb-filter]')){
				var tax=el.getAttribute('data-aipb-filter');
				if(el.value){st.tax[tax]=[el.value];}else{delete st.tax[tax];}
				st.page=1; apply(false);
			}else if(el.matches('[data-aipb-sort]')){
				st.sort=el.value; st.page=1; apply(false);
			}else if(el.matches('[data-aipb-min],[data-aipb-max],select[data-aipb-meta]')){
				setMeta(st,el); st.page=1; apply(false);
			}
		});

		/* One end of a range, or a hand-typed field: read the value and put it in the state
		   under the right field. Empty means "drop the condition". */
		function setMeta(st,el){
			var field=el.getAttribute('data-aipb-min')||el.getAttribute('data-aipb-max')||el.getAttribute('data-aipb-meta');
			if(!field){return;}
			var side=el.hasAttribute('data-aipb-min')?'min':(el.hasAttribute('data-aipb-max')?'max':'eq');
			st.meta[field]=st.meta[field]||{};
			if(side==='eq'){
				if(el.value){st.meta[field].eq=[el.value];}else{delete st.meta[field].eq;}
			}else{
				if(el.value){st.meta[field][side]=el.value;}else{delete st.meta[field][side];}
			}
			if(!Object.keys(st.meta[field]).length){delete st.meta[field];}
		}

		root.addEventListener('input',function(e){
			var el=e.target;
			if(el.matches('[data-aipb-min],[data-aipb-max],input[data-aipb-meta]')){
				clearTimeout(timer);
				/* Same wait as the search: a price is typed one digit at a time, and filtering
				   on every keystroke would mean four queries to write «1500». */
				timer=setTimeout(function(){ setMeta(st,el); st.page=1; apply(false); },400);
				return;
			}
			if(!el.matches('[data-aipb-search]')){return;}
			var v=el.value;
			clearTimeout(timer);
			/* Short wait: filtering happens when typing stops, not on every keystroke. */
			timer=setTimeout(function(){ st.s=v; st.page=1; apply(false); },300);
		});

		/* Enter in the search field: filter right away, without submitting a form. */
		root.addEventListener('keydown',function(e){
			if(e.key==='Enter'&&e.target.matches('[data-aipb-search]')){
				e.preventDefault(); clearTimeout(timer); st.s=e.target.value; st.page=1; apply(false);
			}
		});

		/* Browser back/forward: the state is in the URL, so it can be rebuilt. */
		window.addEventListener('popstate',function(){ st=parse(id); apply(false,false); });

		paint(root,id,st);
		root.setAttribute('data-aipb-shown',String(root.getAttribute('data-aipb-shown')||''));
	});
})();
JS;
		return $js;
	}
}
