<?php
/**
 * KooKoa Folders
 *
 * @package KookoaWPF
 * @license GPL-2.0-or-later
 */

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

/**
 * Dynamic (Smart) Folders — virtual folders that auto-populate based on rules.
 *
 * Rules are stored as term_meta `dynamic_rules` (serialised array) on terms
 * that have meta `is_dynamic` = 1. Rules combine with AND logic, capped at 6.
 *
 * The query layer is split intentionally for predictable performance:
 * cheap rules (mime, status, author, date, search) ride a vanilla
 * WP_Query, while meta-based rules (file size) are filtered in PHP after
 * the candidate set is fetched, so a single fast SELECT covers the hot path.
 *
 * @since 1.0.0
 */
class KookoaWPF_Dynamic {

	/**
	 * Cap on `_wp_attached_file_size` meta computations per request — keeps a
	 * first-time render of a size-based smart folder from stalling on disk
	 * I/O when the library has tens of thousands of un-indexed attachments.
	 * Items past the cap return size 0 for this request and are picked up on
	 * the next render.
	 */
	const SIZE_COMPUTE_CAP = 500;

	/**
	 * Singleton instance.
	 *
	 * @var self|null
	 */
	private static $instance = null;

	/**
	 * Running count of file-size meta computations performed in this request.
	 *
	 * @var int
	 */
	private static $size_compute_count = 0;

	/**
	 * Return the singleton instance, registering hooks on first call.
	 *
	 * @return self
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Wire count-cache invalidation onto attachment/post mutations + drop the
	 * cached `kookoawpf_dynamic_term_ids` registry whenever a folder term is
	 * deleted so the lookup table stays in sync.
	 */
	private function __construct() {
		foreach ( array( 'add_attachment', 'delete_attachment', 'trashed_post', 'untrashed_post', 'deleted_post', 'save_post' ) as $hook ) {
			add_action( $hook, array( $this, 'invalidate_dynamic_counts' ) );
		}
		add_action( 'delete_term', array( $this, 'on_term_deleted' ), 10, 3 );
	}

	/**
	 * Flush the cached dynamic-term-ids registry when one of our folder terms
	 * is deleted (the next read in invalidate_dynamic_counts rebuilds it).
	 *
	 * @param int    $term_id  Term ID (unused but required by the action).
	 * @param int    $tt_id    Term taxonomy ID (unused).
	 * @param string $taxonomy Taxonomy slug.
	 */
	public function on_term_deleted( $term_id, $tt_id, $taxonomy ): void {
		unset( $term_id, $tt_id );
		if ( 0 === strpos( (string) $taxonomy, 'kookoawpf_folder_' ) ) {
			delete_option( 'kookoawpf_dynamic_term_ids' );
		}
	}

	// ─── Rule definitions (used by JS rule builder) ───

	/**
	 * Return the available rule fields for a given post type.
	 *
	 * @param string $post_type The post type slug.
	 * @return array<string, mixed>
	 */
	public static function get_rule_fields( string $post_type ): array {
		$common = array(
			'author'     => array(
				'label' => __( 'Author', 'kookoa-folders' ),
				'type'  => 'select',
			),
			'date_range' => array(
				'label' => __( 'Date Range', 'kookoa-folders' ),
				'type'  => 'select',
			),
		);

		if ( 'attachment' === $post_type ) {
			return array_merge(
				array(
					'file_type' => array(
						'label' => __( 'File Type', 'kookoa-folders' ),
						'type'  => 'select',
					),
					'file_ext'  => array(
						'label' => __( 'Extension', 'kookoa-folders' ),
						'type'  => 'text',
					),
					'size_min'  => array(
						'label' => __( 'Min Size (KB)', 'kookoa-folders' ),
						'type'  => 'number',
					),
					'size_max'  => array(
						'label' => __( 'Max Size (KB)', 'kookoa-folders' ),
						'type'  => 'number',
					),
				),
				$common,
				array(
					// Attachments: searches Title + Caption (post_excerpt) + Description (post_content) via WP_Query 's'.
					'search'     => array(
						'label' => __( 'Text contains (Title+Caption+Desc.)', 'kookoa-folders' ),
						'type'  => 'text',
					),
					'search_alt' => array(
						'label' => __( 'Alt Text contains', 'kookoa-folders' ),
						'type'  => 'text',
					),
				)
			);
		}

		// Posts / Pages / CPT.
		$fields = array(
			'post_status' => array(
				'label' => __( 'Status', 'kookoa-folders' ),
				'type'  => 'select',
			),
		);
		$fields = array_merge( $fields, $common );

		if ( 'post' === $post_type ) {
			$fields['category'] = array(
				'label' => __( 'Category', 'kookoa-folders' ),
				'type'  => 'select',
			);
			$fields['tag']      = array(
				'label' => __( 'Tag', 'kookoa-folders' ),
				'type'  => 'select',
			);
		}

		// WooCommerce-only rule: stock status reads `_stock_status` postmeta
		// that WC writes for every product. Surfaced only on the `product`
		// CPT so non-WC sites or other CPTs don't see an option that would
		// silently match nothing.
		if ( 'product' === $post_type ) {
			$fields['stock_status'] = array(
				'label' => __( 'Stock status', 'kookoa-folders' ),
				'type'  => 'select',
			);
			// WooCommerce products carry their own `product_cat` / `product_tag`
			// taxonomies (NOT the core `category` / `post_tag`). Surface them as
			// separate rule fields so a product smart folder can target the WC
			// taxonomies the same way a post smart folder targets category/tag.
			// Guard on taxonomy_exists() so a non-WC CPT that happens to be named
			// `product` doesn't get rules that would silently match nothing.
			if ( taxonomy_exists( 'product_cat' ) ) {
				$fields['product_cat'] = array(
					'label' => __( 'Category', 'kookoa-folders' ),
					'type'  => 'select',
				);
			}
			if ( taxonomy_exists( 'product_tag' ) ) {
				$fields['product_tag'] = array(
					'label' => __( 'Tag', 'kookoa-folders' ),
					'type'  => 'select',
				);
			}
		}

		// Posts/Pages/CPT: title-only search (kept distinct from attachment 'search'
		// which also covers excerpt+content — those fields mean different things on
		// posts and would confuse the rule label).
		$fields['title_search'] = array(
			'label' => __( 'Title contains', 'kookoa-folders' ),
			'type'  => 'text',
		);

		return $fields;
	}

	/**
	 * Return dropdown options for select-type rule fields.
	 *
	 * Keys are stringly-typed (numeric IDs cast to string), but PHP folds
	 * numeric strings back to int when used as array keys — hence the
	 * `int|string` key annotation.
	 *
	 * @param string $field The rule field key.
	 * @return array<int|string, string>
	 */
	public static function get_field_options( string $field ): array {
		switch ( $field ) {
			case 'file_type':
				return array(
					'image'       => __( 'Images', 'kookoa-folders' ),
					'video'       => __( 'Videos', 'kookoa-folders' ),
					'audio'       => __( 'Audio', 'kookoa-folders' ),
					'document'    => __( 'Documents (PDF, DOC…)', 'kookoa-folders' ),
					'spreadsheet' => __( 'Spreadsheets', 'kookoa-folders' ),
					'archive'     => __( 'Archives (ZIP, RAR…)', 'kookoa-folders' ),
				);

			case 'date_range':
				return array(
					'last_7'   => __( 'Last 7 days', 'kookoa-folders' ),
					'last_30'  => __( 'Last 30 days', 'kookoa-folders' ),
					'last_90'  => __( 'Last 90 days', 'kookoa-folders' ),
					'last_365' => __( 'Last year', 'kookoa-folders' ),
					'custom'   => __( 'Custom range…', 'kookoa-folders' ),
				);

			case 'post_status':
				return array(
					'publish' => __( 'Published', 'kookoa-folders' ),
					'draft'   => __( 'Draft', 'kookoa-folders' ),
					'pending' => __( 'Pending Review', 'kookoa-folders' ),
					'private' => __( 'Private', 'kookoa-folders' ),
					'future'  => __( 'Scheduled', 'kookoa-folders' ),
					'trash'   => __( 'Trashed', 'kookoa-folders' ),
				);

			case 'stock_status':
				// Values mirror WooCommerce's `_stock_status` postmeta — keep
				// them byte-exact so the meta_query in build_query_payload()
				// matches without normalisation.
				return array(
					'instock'     => __( 'In stock', 'kookoa-folders' ),
					'outofstock'  => __( 'Out of stock', 'kookoa-folders' ),
					'onbackorder' => __( 'On backorder', 'kookoa-folders' ),
				);

			case 'author':
				$users   = get_users(
					array(
						'fields'     => array( 'ID', 'display_name' ),
						'number'     => 200,
						'capability' => array( 'upload_files' ),
					)
				);
				$options = array( 'me' => __( 'Current User', 'kookoa-folders' ) );
				foreach ( $users as $u ) {
					$options[ (string) $u->ID ] = $u->display_name;
				}
				return $options;

			case 'category':
				$cats    = get_terms(
					array(
						'taxonomy'   => 'category',
						'hide_empty' => false,
					)
				);
				$options = array();
				if ( ! is_wp_error( $cats ) ) {
					foreach ( $cats as $cat ) {
						$options[ (string) $cat->term_id ] = $cat->name;
					}
				}
				return $options;

			case 'tag':
				$tags    = get_terms(
					array(
						'taxonomy'   => 'post_tag',
						'hide_empty' => false,
					)
				);
				$options = array();
				if ( ! is_wp_error( $tags ) ) {
					foreach ( $tags as $tag ) {
						$options[ (string) $tag->term_id ] = $tag->name;
					}
				}
				return $options;

			case 'product_cat':
			case 'product_tag':
				// WooCommerce product taxonomies: the rule-field key IS the
				// taxonomy name (`product_cat` / `product_tag`), so we resolve
				// terms directly without a lookup map.
				$terms   = get_terms(
					array(
						'taxonomy'   => $field,
						'hide_empty' => false,
					)
				);
				$options = array();
				if ( ! is_wp_error( $terms ) ) {
					foreach ( $terms as $term ) {
						$options[ (string) $term->term_id ] = $term->name;
					}
				}
				return $options;
		}

		return array();
	}

	// ─── CRUD helpers ───

	/**
	 * Create a smart folder (taxonomy term with is_dynamic + dynamic_rules meta).
	 *
	 * @param string                            $name      Folder display name.
	 * @param string                            $post_type The post type slug.
	 * @param array<int, array<string, string>> $rules     Array of {field, value} arrays.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function create( string $name, string $post_type, array $rules = array() ) {
		$result = KookoaWPF_Folders::create( $name, $post_type, 0 );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		$term_id = $result['term_id'];

		KookoaWPF_Folders::update_meta( $term_id, 'is_dynamic', 1 );
		KookoaWPF_Folders::update_meta( $term_id, 'dynamic_rules', $rules );

		// Smart folders use ⚡ icon by default.
		KookoaWPF_Folders::update_meta( $term_id, 'folder_icon', '⚡' );

		// Drop the cached dynamic-term-ids registry — next invalidate_dynamic_counts
		// will rebuild and pick up the new term.
		delete_option( 'kookoawpf_dynamic_term_ids' );

		return $result;
	}

	/**
	 * Replace the rules on an existing smart folder.
	 *
	 * @param int                               $folder_id The folder term ID.
	 * @param array<int, array<string, string>> $rules     Array of {field, value} arrays.
	 */
	public static function update_rules( int $folder_id, array $rules ): void {
		$folder_id = absint( $folder_id );
		KookoaWPF_Folders::update_meta( $folder_id, 'dynamic_rules', $rules );

		// Invalidate both count and matching-IDs caches via API so persistent
		// object caches (Redis/Memcached) drop too.
		foreach ( kookoawpf()->get_supported_post_types() as $pt ) {
			delete_transient( 'kookoawpf_dyn_count_' . $folder_id . '_' . $pt );
			delete_transient( 'kookoawpf_dyn_ids_' . $folder_id . '_' . $pt );
		}
	}

	/**
	 * Read the rules attached to a smart folder.
	 *
	 * @param int $folder_id The folder term ID.
	 * @return array<int, array<string, string>>
	 */
	public static function get_rules( int $folder_id ): array {
		$rules = KookoaWPF_Folders::get_meta( absint( $folder_id ), 'dynamic_rules' );
		return is_array( $rules ) ? $rules : array();
	}

	/**
	 * Check whether a folder term is a smart folder.
	 *
	 * @param int $folder_id The folder term ID.
	 * @return bool
	 */
	public static function is_dynamic( int $folder_id ): bool {
		return (bool) KookoaWPF_Folders::get_meta( absint( $folder_id ), 'is_dynamic' );
	}

	// ─── Query layer ───

	/**
	 * Resolve a smart folder's rules to a list of matching post IDs.
	 *
	 * Cheap rules (mime, status, author, date, search, cat/tag) ride a single
	 * WP_Query; meta-based rules (file size) are filtered in PHP afterwards
	 * using `get_post_meta`, so the WP_Query stays a single fast SELECT
	 * instead of joining the postmeta table for every candidate.
	 *
	 * Result is cached in a 30-second transient so repeated render passes
	 * (sidebar tree → media grid filter → folder column) don't replay the
	 * full query for the same smart folder.
	 *
	 * @param int    $folder_id The smart folder term ID.
	 * @param string $post_type The post type context.
	 * @return int[] Matching post IDs.
	 */
	public static function get_matching_ids( int $folder_id, string $post_type ): array {
		$cache_key = 'kookoawpf_dyn_ids_' . $folder_id . '_' . $post_type;
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return array_map( 'intval', $cached );
		}

		$rules = self::get_rules( $folder_id );
		if ( empty( $rules ) ) {
			set_transient( $cache_key, array(), 30 );
			return array();
		}

		$payload = self::build_query_payload( $rules, $post_type );

		/**
		 * Narrow get_posts() return.
		 *
		 * `fields => ids` in build_query_payload() guarantees an int[] payload.
		 *
		 * @var int[] $candidates
		 */
		$candidates = get_posts( $payload['args'] );

		// Hide PRO Cleanup soft-trashed attachments from smart-folder results,
		// consistent with the "All Items" counters and the Media Library grid.
		// Inert in pure FREE (no _kookoawpf_cleanup_trash flag can exist).
		$candidates = KookoaWPF_Folders::exclude_cleanup_trashed_ids( $candidates );

		if ( $payload['cat_tag_empty'] || empty( $candidates ) ) {
			set_transient( $cache_key, array(), 30 );
			return array();
		}

		// PHP-side filter: file size meta. Computed on-the-fly when missing,
		// up to SIZE_COMPUTE_CAP per request to avoid first-render stalls.
		//
		// Performance: a single `update_meta_cache()` warms the postmeta
		// cache for ALL candidates with one batched query, and any newly
		// computed sizes go into ONE multi-row `KookoaWPF_Postmeta_Bulk`
		// INSERT instead of N separate `update_post_meta()` calls. Without
		// this, a smart folder with a size rule on a cache-cold attachment
		// set was paying 2 queries per candidate — see tools/perf/bottlenecks.md.
		if ( $payload['size_min'] > 0 || $payload['size_max'] > 0 ) {
			update_meta_cache( 'post', $candidates );

			$matching  = array();
			$bulk_rows = array();
			foreach ( $candidates as $pid ) {
				$existing = get_post_meta( $pid, '_wp_attached_file_size', true );
				if ( '' !== $existing ) {
					$size = (int) $existing;
				} elseif ( self::$size_compute_count >= self::SIZE_COMPUTE_CAP ) {
					$size = 0;
				} else {
					++self::$size_compute_count;
					$file        = get_attached_file( $pid );
					$size        = ( $file && file_exists( $file ) ) ? (int) filesize( $file ) : 0;
					$bulk_rows[] = array(
						'post_id' => $pid,
						'key'     => '_wp_attached_file_size',
						'value'   => $size,
					);
				}
				if ( $payload['size_min'] > 0 && $size < $payload['size_min'] ) {
					continue;
				}
				if ( $payload['size_max'] > 0 && $size > $payload['size_max'] ) {
					continue;
				}
				$matching[] = $pid;
			}

			if ( ! empty( $bulk_rows ) && class_exists( 'KookoaWPF_Postmeta_Bulk' ) ) {
				KookoaWPF_Postmeta_Bulk::insert( $bulk_rows );
			}

			set_transient( $cache_key, $matching, 30 );
			return $matching;
		}

		// PHP-side filter: WooCommerce `_stock_status` postmeta. Warm the
		// postmeta cache for all candidates with one batch query, then test
		// each in memory — avoids meta_query in the main WP_Query.
		if ( '' !== $payload['stock_status'] ) {
			update_meta_cache( 'post', $candidates );
			$expected_stock = $payload['stock_status'];
			$matching       = array();
			foreach ( $candidates as $pid ) {
				if ( get_post_meta( $pid, '_stock_status', true ) === $expected_stock ) {
					$matching[] = $pid;
				}
			}
			set_transient( $cache_key, $matching, 30 );
			return $matching;
		}

		set_transient( $cache_key, $candidates, 30 );
		return $candidates;
	}

	/**
	 * Translate a rules array into the pieces needed by both `get_matching_ids`
	 * and the fast-count path: WP_Query args, size thresholds (PHP-filtered),
	 * and a flag signalling that an intersection of category/tag rules is
	 * empty (which means the matching set is already known to be empty).
	 *
	 * @param array<int, array<string, string>> $rules     Sanitized rule list.
	 * @param string                            $post_type Post type context.
	 * @return array{
	 *     args: array<string, mixed>,
	 *     size_min: int,
	 *     size_max: int,
	 *     stock_status: string,
	 *     cat_tag_empty: bool
	 * }
	 */
	private static function build_query_payload( array $rules, string $post_type ): array {
		$args = array(
			'post_type'      => $post_type,
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
		);

		$php_size_min     = 0;
		$php_size_max     = 0;
		$php_stock_status = ''; // WooCommerce: filtered PHP-side after the query, same pattern as size meta.
		$post_in_scope    = null; // Intersection of category/tag object IDs (when present).

		foreach ( $rules as $rule ) {
			$field = isset( $rule['field'] ) ? sanitize_key( $rule['field'] ) : '';
			$value = isset( $rule['value'] ) ? $rule['value'] : '';

			if ( '' === $field || '' === $value ) {
				continue;
			}

			switch ( $field ) {

				case 'file_type':
					$mime = self::file_type_to_mime( sanitize_key( $value ) );
					if ( $mime ) {
						if ( isset( $args['post_mime_type'] ) ) {
							$existing               = (array) $args['post_mime_type'];
							$args['post_mime_type'] = array_merge( $existing, (array) $mime );
						} else {
							$args['post_mime_type'] = $mime;
						}
					}
					break;

				case 'file_ext':
					$exts  = array_map( 'trim', explode( ',', strtolower( sanitize_text_field( $value ) ) ) );
					$mimes = array();
					foreach ( $exts as $ext ) {
						$ext  = ltrim( $ext, '.' );
						$type = wp_check_filetype( 'file.' . $ext );
						if ( $type['type'] ) {
							$mimes[] = $type['type'];
						}
					}
					if ( $mimes ) {
						if ( isset( $args['post_mime_type'] ) ) {
							$args['post_mime_type'] = array_merge( (array) $args['post_mime_type'], $mimes );
						} else {
							$args['post_mime_type'] = $mimes;
						}
					}
					break;

				case 'size_min':
				case 'size_max':
					$bytes = absint( $value ) * 1024;
					if ( $bytes > 0 ) {
						if ( 'size_min' === $field ) {
							$php_size_min = $bytes;
						} else {
							$php_size_max = $bytes;
						}
					}
					break;

				case 'post_status':
					$valid  = array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' );
					$status = sanitize_key( $value );
					if ( in_array( $status, $valid, true ) ) {
						if ( isset( $args['post_status'] ) ) {
							$existing            = (array) $args['post_status'];
							$existing[]          = $status;
							$args['post_status'] = array_unique( $existing );
						} else {
							$args['post_status'] = $status;
						}
					}
					break;

				case 'stock_status':
					// WooCommerce stores stock state as `_stock_status` postmeta on
					// every product. Stash the requested value for a PHP-side filter
					// applied after the main WP_Query — same pattern used by the
					// `size_min` / `size_max` attachment rules. Avoids meta_query
					// (PHPCS slow_db_query warning) and $wpdb direct queries (which
					// would burn a fresh phpcs:ignore line).
					$valid_stock = array( 'instock', 'outofstock', 'onbackorder' );
					$stock       = sanitize_key( $value );
					if ( in_array( $stock, $valid_stock, true ) ) {
						$php_stock_status = $stock;
					}
					break;

				case 'category':
				case 'tag':
				case 'product_cat':
				case 'product_tag':
					// Resolve the rule-field key to its taxonomy. Core post rules
					// use `category` / `post_tag`; the WooCommerce product rules
					// (`product_cat` / `product_tag`) map 1:1 to their own
					// taxonomy names, so the field key doubles as the taxonomy.
					if ( 'category' === $field ) {
						$taxonomy = 'category';
					} elseif ( 'tag' === $field ) {
						$taxonomy = 'post_tag';
					} else {
						$taxonomy = $field;
					}
					$tid = absint( $value );
					if ( $tid ) {
						$objects = get_objects_in_term( $tid, $taxonomy );
						if ( is_wp_error( $objects ) ) {
							$objects = array();
						}
						$objects = array_map( 'intval', (array) $objects );
						if ( null === $post_in_scope ) {
							$post_in_scope = $objects;
						} else {
							$post_in_scope = array_values( array_intersect( $post_in_scope, $objects ) );
						}
					}
					break;

				case 'author':
					$args['author'] = ( 'me' === $value ) ? get_current_user_id() : absint( $value );
					break;

				case 'date_range':
					$value    = sanitize_key( $value );
					$days_map = array(
						'last_7'   => 7,
						'last_30'  => 30,
						'last_90'  => 90,
						'last_365' => 365,
					);
					if ( isset( $days_map[ $value ] ) ) {
						if ( ! isset( $args['date_query'] ) ) {
							$args['date_query'] = array();
						}
						$args['date_query'][] = array(
							'after'     => $days_map[ $value ] . ' days ago',
							'inclusive' => true,
						);
					}
					break;

				case 'date_after':
					if ( ! isset( $args['date_query'] ) ) {
						$args['date_query'] = array();
					}
					$args['date_query'][] = array(
						'after'     => sanitize_text_field( $value ),
						'inclusive' => true,
					);
					break;

				case 'date_before':
					if ( ! isset( $args['date_query'] ) ) {
						$args['date_query'] = array();
					}
					$args['date_query'][] = array(
						'before'    => sanitize_text_field( $value ),
						'inclusive' => true,
					);
					break;

				case 'search':
					$args['s'] = sanitize_text_field( $value );
					break;

				case 'title_search':
					$title_text = sanitize_text_field( $value );
					if ( '' !== $title_text ) {
						global $wpdb;
						$like = '%' . $wpdb->esc_like( $title_text ) . '%';
						// Direct query: WP_Query 's' always searches title+excerpt+content;
						// no public arg restricts the search to post_title alone, so we
						// resolve matching IDs here and intersect with the existing
						// post__in scope (same mechanism used by category/tag rules).
						// Results ride the smart-folder transient (30s/60s) downstream.
						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cached via smart folder transient layer.
						$matching_ids = $wpdb->get_col(
							$wpdb->prepare(
								"SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_title LIKE %s",
								$post_type,
								$like
							)
						);
						$matching_ids = array_map( 'intval', (array) $matching_ids );
						if ( null === $post_in_scope ) {
							$post_in_scope = $matching_ids;
						} else {
							$post_in_scope = array_values( array_intersect( $post_in_scope, $matching_ids ) );
						}
					}
					break;

				case 'search_alt':
					$alt_search = sanitize_text_field( $value );
					if ( '' !== $alt_search ) {
						if ( ! isset( $args['meta_query'] ) ) {
							$args['meta_query'] = array(); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Indexed by meta_key, scoped by post_type=attachment, results cached in transient.
						}
						$args['meta_query'][] = array(
							'key'     => '_wp_attachment_image_alt',
							'value'   => $alt_search,
							'compare' => 'LIKE',
						);
					}
					break;
			}
		}

		// Default post status by type so trashed rows don't leak in by accident.
		if ( ! isset( $args['post_status'] ) ) {
			$args['post_status'] = ( 'attachment' === $post_type )
				? 'inherit'
				: array( 'publish', 'draft', 'pending', 'private', 'future' );
		}

		$cat_tag_empty = false;
		if ( null !== $post_in_scope ) {
			if ( empty( $post_in_scope ) ) {
				$cat_tag_empty = true;
			} else {
				$args['post__in'] = $post_in_scope;
			}
		}

		return array(
			'args'          => $args,
			'size_min'      => $php_size_min,
			'size_max'      => $php_size_max,
			'stock_status'  => $php_stock_status,
			'cat_tag_empty' => $cat_tag_empty,
		);
	}

	/**
	 * Count items matching a smart folder's rules (60s transient cache).
	 *
	 * Fast path: when the rules don't include `size_min`/`size_max`, runs a
	 * single WP_Query with `posts_per_page=1` and reads `found_posts` so the
	 * full ID set is never loaded into PHP. With size rules we fall back to
	 * `count(get_matching_ids())` because the size filter happens in PHP and
	 * needs the candidate set anyway.
	 *
	 * @param int    $folder_id The smart folder term ID.
	 * @param string $post_type The post type context.
	 * @return int
	 */
	public static function count_items( int $folder_id, string $post_type ): int {
		$cache_key = 'kookoawpf_dyn_count_' . $folder_id . '_' . $post_type;
		$cached    = get_transient( $cache_key );
		if ( false !== $cached ) {
			return (int) $cached;
		}

		$rules = self::get_rules( $folder_id );
		if ( empty( $rules ) ) {
			set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
			return 0;
		}

		$payload = self::build_query_payload( $rules, $post_type );

		if ( $payload['cat_tag_empty'] ) {
			set_transient( $cache_key, 0, MINUTE_IN_SECONDS );
			return 0;
		}

		if ( 0 === $payload['size_min'] && 0 === $payload['size_max'] && '' === $payload['stock_status']
			&& ! function_exists( 'kookoawpf_pro_cleanup_trashed_count' ) ) {
			// Fast path — let MySQL do the COUNT. Only taken in pure FREE: with
			// no PRO Cleanup installed no attachment carries the soft-trash flag,
			// so found_posts is already exact.
			$args                   = $payload['args'];
			$args['posts_per_page'] = 1;
			$args['no_found_rows']  = false;
			$query                  = new WP_Query( $args );
			$count                  = (int) $query->found_posts;
		} else {
			// Slow path — size/stock filters run in PHP, OR PRO Cleanup is
			// active and we must exclude soft-trashed items. Routing through
			// get_matching_ids() reuses its cleanup-trash filter so the badge
			// count matches what the grid/ZIP actually show.
			$count = count( self::get_matching_ids( $folder_id, $post_type ) );
		}

		set_transient( $cache_key, $count, MINUTE_IN_SECONDS );
		return $count;
	}

	// ─── File type → MIME helpers ───

	/**
	 * Map a file type label to MIME type(s).
	 *
	 * @param string $type File type key (e.g. 'image', 'document').
	 * @return string|array<int, string>
	 */
	private static function file_type_to_mime( string $type ) {
		$map = array(
			'image'       => 'image',
			'video'       => 'video',
			'audio'       => 'audio',
			'document'    => array(
				'application/pdf',
				'application/msword',
				'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
				'application/vnd.ms-powerpoint',
				'application/vnd.openxmlformats-officedocument.presentationml.presentation',
				'text/plain',
				'text/csv',
				'text/html',
			),
			'spreadsheet' => array(
				'application/vnd.ms-excel',
				'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
				'text/csv',
			),
			'archive'     => array(
				'application/zip',
				'application/x-rar-compressed',
				'application/x-7z-compressed',
				'application/x-tar',
				'application/gzip',
			),
		);

		return isset( $map[ $type ] ) ? $map[ $type ] : '';
	}

	// ─── Cache invalidation ───

	/**
	 * Drop every smart-folder count + matching-IDs transient when an
	 * attachment/post mutates.
	 *
	 * Reads the cached registry of dynamic term IDs (`kookoawpf_dynamic_term_ids`
	 * option) so invalidate stays O(N) on the smart-folder count instead of
	 * O(N) on every folder. The registry is rebuilt on the rare cache miss
	 * (after explicit busts in create / update_rules / delete_term).
	 *
	 * @param int $post_id The post being touched.
	 */
	public function invalidate_dynamic_counts( $post_id ): void {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		$post_type = get_post_type( $post_id );
		$supported = kookoawpf()->get_supported_post_types();
		if ( $post_type && ! in_array( $post_type, (array) $supported, true ) ) {
			return;
		}

		$registry = self::get_dynamic_term_ids();
		foreach ( $registry as $pt => $term_ids ) {
			foreach ( $term_ids as $tid ) {
				delete_transient( 'kookoawpf_dyn_count_' . (int) $tid . '_' . $pt );
				delete_transient( 'kookoawpf_dyn_ids_' . (int) $tid . '_' . $pt );
			}
		}
	}

	/**
	 * Read the registry of smart-folder term IDs grouped by post type. Cached
	 * in the `kookoawpf_dynamic_term_ids` option (autoload off) so the hot
	 * path of `invalidate_dynamic_counts` doesn't re-scan every taxonomy on
	 * every post mutation. Cache miss rebuilds via `get_terms()` with a
	 * single PHP filter for `is_dynamic` — no meta_query.
	 *
	 * @return array<string,int[]> Map of post_type => [term_id, …].
	 */
	private static function get_dynamic_term_ids(): array {
		$cached = get_option( 'kookoawpf_dynamic_term_ids', null );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$out = array();
		foreach ( (array) kookoawpf()->get_supported_post_types() as $pt ) {
			$taxonomy = KookoaWPF_Folders::get_taxonomy( $pt );
			if ( ! $taxonomy ) {
				continue;
			}
			$terms = get_terms(
				array(
					'taxonomy'   => $taxonomy,
					'hide_empty' => false,
					'fields'     => 'ids',
				)
			);
			if ( is_wp_error( $terms ) ) {
				continue;
			}
			$dyn = array();
			foreach ( $terms as $tid ) {
				if ( '1' === KookoaWPF_Folders::get_meta( (int) $tid, 'is_dynamic' ) ) {
					$dyn[] = (int) $tid;
				}
			}
			$out[ $pt ] = $dyn;
		}

		update_option( 'kookoawpf_dynamic_term_ids', $out, false );
		return $out;
	}
}
