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

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

/**
 * Folder CRUD operations built on WordPress taxonomy terms.
 *
 * Each supported post type gets a private hierarchical taxonomy
 * (kookoawpf_folder_{post_type}). Folder metadata (color, order,
 * lock state, etc.) is stored via the term_meta API.
 *
 * @since 1.0.0
 */
class KookoaWPF_Folders {

	/**
	 * Get taxonomy name for a post type.
	 *
	 * @param string $post_type The post type slug.
	 * @return string
	 */
	public static function get_taxonomy( string $post_type ): string {
		return 'kookoawpf_folder_' . sanitize_key( $post_type );
	}

	/**
	 * Create a new folder.
	 *
	 * @param string               $name      Folder display name.
	 * @param string               $post_type The post type slug.
	 * @param int                  $parent_id Parent term ID (0 for root).
	 * @param array<string, mixed> $args      Optional flags. Supported:
	 *                                          - skip_order (bool) when true,
	 *                                            do not compute or set
	 *                                            folder_order. The caller is
	 *                                            responsible for setting it
	 *                                            afterwards. Used by importers
	 *                                            that preserve the source
	 *                                            plugin's own ord.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function create( string $name, string $post_type, int $parent_id = 0, array $args = array() ) {
		$taxonomy = self::get_taxonomy( $post_type );

		$clean_name = sanitize_text_field( $name );

		$result = wp_insert_term(
			$clean_name,
			$taxonomy,
			array(
				'parent' => absint( $parent_id ),
			)
		);

		// If WordPress rejected due to duplicate slug, retry with a unique slug
		// but keep the original display name so users can have folders with the same name.
		if ( is_wp_error( $result ) && $result->get_error_code() === 'term_exists' ) {
			$unique_slug = sanitize_title( $clean_name ) . '-' . substr( md5( uniqid( '', true ) ), 0, 6 );
			$result      = wp_insert_term(
				$clean_name,
				$taxonomy,
				array(
					'parent' => absint( $parent_id ),
					'slug'   => $unique_slug,
				)
			);
		}

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

		$term_id = $result['term_id'];

		// Set default order, unless the caller will set it itself
		// right after (importers preserve the source plugin's `ord`,
		// so paying for get_max_order() here would be wasted work
		// across thousands of folders during a migration).
		if ( empty( $args['skip_order'] ) ) {
			$max_order = self::get_max_order( $taxonomy, $parent_id );
			self::update_meta( $term_id, 'folder_order', $max_order + 1 );
		}

		// Inherit color from parent, or use default.
		$color = get_option( 'kookoawpf_folder_color', '#39FF14' );
		if ( $parent_id > 0 ) {
			$parent_color = self::get_meta( $parent_id, 'folder_color' );
			if ( $parent_color ) {
				$color = $parent_color;
			}
		}
		self::update_meta( $term_id, 'folder_color', $color );

		return array(
			'term_id'  => $term_id,
			'name'     => $name,
			'parent'   => $parent_id,
			'children' => array(),
			'count'    => 0,
		);
	}

	/**
	 * Rename a folder.
	 *
	 * @param int    $folder_id The folder term ID.
	 * @param string $new_name  New display name.
	 * @param string $post_type The post type slug.
	 * @return true|WP_Error
	 */
	public static function rename( int $folder_id, string $new_name, string $post_type ) {
		$taxonomy   = self::get_taxonomy( $post_type );
		$clean_name = sanitize_text_field( $new_name );

		$result = wp_update_term(
			absint( $folder_id ),
			$taxonomy,
			array(
				'name' => $clean_name,
			)
		);

		// If WordPress rejected due to duplicate slug, retry with a unique slug.
		if ( is_wp_error( $result ) && $result->get_error_code() === 'term_exists' ) {
			$unique_slug = sanitize_title( $clean_name ) . '-' . substr( md5( uniqid( '', true ) ), 0, 6 );
			$result      = wp_update_term(
				absint( $folder_id ),
				$taxonomy,
				array(
					'name' => $clean_name,
					'slug' => $unique_slug,
				)
			);
		}

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

		return true;
	}

	/**
	 * Delete a folder and all its subfolders recursively.
	 * Items (posts/media) inside are NOT deleted, only unlinked from the folders.
	 *
	 * @param int    $folder_id The folder term ID.
	 * @param string $post_type The post type slug.
	 * @param int    $depth     Current recursion depth (internal).
	 * @return true|WP_Error
	 */
	public static function delete( int $folder_id, string $post_type, int $depth = 0 ) {
		// Same cap used by duplicate_recursive — prevents stack overflow on
		// pathologically deep trees (bulk cut/paste abuse or corrupted data).
		if ( $depth >= 50 ) {
			return new WP_Error( 'max_depth', __( 'Folder tree too deep.', 'kookoa-folders' ) );
		}

		$taxonomy = self::get_taxonomy( $post_type );
		$term     = get_term( absint( $folder_id ), $taxonomy );

		if ( ! $term || is_wp_error( $term ) ) {
			return new WP_Error( 'invalid_folder', __( 'Folder not found.', 'kookoa-folders' ) );
		}

		// Recursively delete all child folders first (depth-first).
		$children = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'parent'     => $folder_id,
				'hide_empty' => false,
			)
		);

		if ( ! is_wp_error( $children ) ) {
			foreach ( $children as $child ) {
				self::delete( $child->term_id, $post_type, $depth + 1 );
			}
		}

		// Delete this folder's meta.
		self::delete_all_meta( $folder_id );

		$result = wp_delete_term( absint( $folder_id ), $taxonomy );

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

		return true;
	}

	/**
	 * Move a folder (change parent).
	 *
	 * @param int    $folder_id     The folder term ID.
	 * @param int    $new_parent_id New parent term ID (0 for root).
	 * @param string $post_type     The post type slug.
	 * @return true|WP_Error
	 */
	public static function move( int $folder_id, int $new_parent_id, string $post_type ) {
		$taxonomy = self::get_taxonomy( $post_type );

		// Prevent moving a folder into its own descendant.
		if ( self::is_descendant( $new_parent_id, $folder_id, $taxonomy ) ) {
			return new WP_Error( 'invalid_move', __( 'Cannot move a folder into its own subfolder.', 'kookoa-folders' ) );
		}

		$result = wp_update_term(
			absint( $folder_id ),
			$taxonomy,
			array(
				'parent' => absint( $new_parent_id ),
			)
		);

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

		return true;
	}

	/**
	 * Assign posts/items to a folder.
	 *
	 * @param int|int[] $post_ids  Post IDs to assign.
	 * @param int       $folder_id The folder term ID.
	 * @param string    $post_type The post type slug.
	 * @return bool True on success, false if the folder doesn't belong to the
	 *              given taxonomy (defensive guard against orphan term IDs).
	 */
	public static function assign_items( $post_ids, int $folder_id, string $post_type ): bool {
		$taxonomy = self::get_taxonomy( $post_type );

		// Reject invalid term/taxonomy pairs so a buggy caller can't create
		// term_relationships to a folder that doesn't exist in this taxonomy.
		if ( ! term_exists( absint( $folder_id ), $taxonomy ) ) {
			return false;
		}

		if ( ! is_array( $post_ids ) ) {
			$post_ids = array( $post_ids );
		}

		$post_ids = array_map( 'absint', $post_ids );

		wp_defer_term_counting( true );

		foreach ( $post_ids as $post_id ) {
			wp_set_object_terms( $post_id, absint( $folder_id ), $taxonomy );
		}

		wp_defer_term_counting( false );

		// Clear term cache so next get_terms() returns fresh counts.
		clean_term_cache( absint( $folder_id ), $taxonomy );

		return true;
	}

	/**
	 * Remove items from a folder (move to uncategorized).
	 *
	 * @param int|int[] $post_ids  Post IDs to unassign.
	 * @param int       $folder_id The folder term ID. Pass 0 to unassign from ALL folders.
	 * @param string    $post_type The post type slug.
	 * @return true
	 */
	public static function unassign_items( $post_ids, int $folder_id, string $post_type ): bool {
		$taxonomy = self::get_taxonomy( $post_type );

		if ( ! is_array( $post_ids ) ) {
			$post_ids = array( $post_ids );
		}

		wp_defer_term_counting( true );

		foreach ( $post_ids as $post_id ) {
			$pid = absint( $post_id );
			if ( $folder_id ) {
				wp_remove_object_terms( $pid, absint( $folder_id ), $taxonomy );
			} else {
				// folder_id=0 → drop from "All items" onto Uncategorized;
				// strip every term in this post type's folder taxonomy.
				$current = wp_get_object_terms( $pid, $taxonomy, array( 'fields' => 'ids' ) );
				if ( ! is_wp_error( $current ) && ! empty( $current ) ) {
					wp_remove_object_terms( $pid, $current, $taxonomy );
				}
			}
		}

		wp_defer_term_counting( false );

		// Clear term cache so next get_terms() returns fresh counts.
		clean_term_cache( absint( $folder_id ), $taxonomy );

		return true;
	}

	/**
	 * Move items between folders.
	 *
	 * @param int|int[] $post_ids       Post IDs to move.
	 * @param int       $from_folder_id Source folder term ID (0 for uncategorized).
	 * @param int       $to_folder_id   Destination folder term ID (0 for uncategorized).
	 * @param string    $post_type      The post type slug.
	 * @return true
	 */
	public static function move_items( $post_ids, int $from_folder_id, int $to_folder_id, string $post_type ): bool {
		$taxonomy = self::get_taxonomy( $post_type );

		if ( ! is_array( $post_ids ) ) {
			$post_ids = array( $post_ids );
		}

		// No-op: dropping on the same folder (including All→All, or repeated
		// drops under optimistic UI) would otherwise trigger a duplicate-key
		// INSERT in wp_term_relationships.
		if ( absint( $from_folder_id ) === absint( $to_folder_id ) ) {
			return true;
		}

		// Defer term count updates until all items are moved.
		// Without this, WP recalculates counts after each wp_set_object_terms /
		// wp_remove_object_terms call. When moving 3+ items, intermediate counts
		// can be cached and served stale to the next AJAX request.
		wp_defer_term_counting( true );

		foreach ( $post_ids as $post_id ) {
			$post_id = absint( $post_id );
			if ( $from_folder_id > 0 ) {
				wp_remove_object_terms( $post_id, absint( $from_folder_id ), $taxonomy );
			}
			if ( $to_folder_id > 0 ) {
				wp_set_object_terms( $post_id, absint( $to_folder_id ), $taxonomy );
			}
		}

		// Flush: recalculate counts once for all affected terms.
		wp_defer_term_counting( false );

		// Clear term cache so next get_terms() returns fresh counts.
		$term_ids = array();
		if ( $from_folder_id > 0 ) {
			$term_ids[] = absint( $from_folder_id ); }
		if ( $to_folder_id > 0 ) {
			$term_ids[] = absint( $to_folder_id ); }
		if ( $term_ids ) {
			clean_term_cache( $term_ids, $taxonomy );
		}

		return true;
	}

	/**
	 * Sort an array of WP_Term objects by the folder_order term meta ASC,
	 * falling back to name when two terms share the same order value.
	 *
	 * The sort is done in PHP after priming the term-meta cache instead of
	 * passing meta_key/orderby=meta_value_num to get_terms(), which would
	 * force WordPress to JOIN wp_termmeta into the term query. For the
	 * folder counts this plugin handles (dozens, not thousands), reading
	 * the cached meta and sorting in PHP is faster and avoids the extra
	 * JOIN entirely.
	 *
	 * @param WP_Term[] $terms Terms to sort in place.
	 * @return WP_Term[]
	 */
	private static function sort_by_folder_order( $terms ) {
		if ( empty( $terms ) || ! is_array( $terms ) ) {
			return $terms;
		}

		update_termmeta_cache( wp_list_pluck( $terms, 'term_id' ) );

		usort(
			$terms,
			static function ( $a, $b ) {
				$oa = (int) get_term_meta( $a->term_id, 'folder_order', true );
				$ob = (int) get_term_meta( $b->term_id, 'folder_order', true );
				if ( $oa === $ob ) {
					return strcmp( $a->name, $b->name );
				}
				return $oa - $ob;
			}
		);

		return $terms;
	}

	/**
	 * Get folder tree for a post type.
	 *
	 * @param string $post_type     The post type slug.
	 * @param string $force_sort_by Optional. Override the saved sort preference
	 *                              for this call only. Pass 'custom' from the
	 *                              media modal to get manual ordering even if
	 *                              the user has an auto sort active in library.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_tree( string $post_type, string $force_sort_by = '' ): array {
		$taxonomy = self::get_taxonomy( $post_type );

		$terms = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
				'orderby'    => 'name',
				'order'      => 'ASC',
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return array();
		}

		$terms = self::sort_by_folder_order( $terms );

		// Get sort preference for display ordering.
		$sort_pref = get_option(
			'kookoawpf_sort_' . $post_type,
			array(
				'by'  => 'custom',
				'dir' => 'ASC',
			)
		);

		// Force override (e.g. media modal asks for manual order regardless
		// of the user's library sort preference).
		if ( ! empty( $force_sort_by ) ) {
			$sort_pref = array(
				'by'  => $force_sort_by,
				'dir' => isset( $sort_pref['dir'] ) ? $sort_pref['dir'] : 'ASC',
			);
		}

		return self::build_tree( $terms, 0, $sort_pref );
	}

	/**
	 * Build hierarchical tree from flat terms.
	 *
	 * Uses an indexed lookup table so that each term is visited exactly once,
	 * giving O(n) performance instead of O(n²) with the recursive filter
	 * approach that re-scans the full array per level.
	 *
	 * @param array<int, WP_Term>        $terms     Array of WP_Term objects.
	 * @param int                        $parent_id Root parent ID to start from.
	 * @param array<string, string>|null $sort_pref Sort preference array with 'by' and 'dir' keys.
	 * @return array<int, array<string, mixed>>
	 */
	private static function build_tree( $terms, $parent_id = 0, $sort_pref = null ) {
		// 1) Build flat node array and group by parent.
		$nodes_by_id = array();
		$children_of = array(); // parent_id => array of term_ids.

		// Determine post type from taxonomy to decide if we need trash-aware counts.
		$post_type = '';
		if ( ! empty( $terms ) ) {
			$post_type = self::get_post_type_from_taxonomy( $terms[0]->taxonomy );
		}
		$exclude_trash = ! empty( $post_type );

		// Prime termmeta cache in a single query to avoid N+1.
		$all_term_ids = wp_list_pluck( $terms, 'term_id' );
		if ( ! empty( $all_term_ids ) ) {
			update_termmeta_cache( $all_term_ids );
		}

		// Batch-count all non-dynamic folders in a SINGLE query (instead of 2 per folder).
		$batch_counts = array();
		if ( $exclude_trash && ! empty( $terms ) ) {
			// Collect term IDs of non-dynamic folders.
			$count_term_ids = array();
			foreach ( $terms as $term ) {
				$is_dyn = self::get_meta( $term->term_id, 'is_dynamic' );
				if ( ! $is_dyn ) {
					$count_term_ids[] = $term->term_id;
				}
			}
			if ( ! empty( $count_term_ids ) ) {
				$batch_counts = self::batch_count_excluding_trash( $count_term_ids, $terms[0]->taxonomy );
			}
		}

		foreach ( $terms as $term ) {
			$color  = self::get_meta( $term->term_id, 'folder_color' );
			$order  = self::get_meta( $term->term_id, 'folder_order' );
			$locked = self::get_meta( $term->term_id, 'folder_locked' );
			$pinned = self::get_meta( $term->term_id, 'folder_pinned' );
			$icon   = self::get_meta( $term->term_id, 'folder_icon' );
			$is_dyn = self::get_meta( $term->term_id, 'is_dynamic' );

			// Use batch count (single query for all folders) instead of per-folder query.
			$count = $term->count;
			if ( $exclude_trash && ! $is_dyn && isset( $batch_counts[ $term->term_id ] ) ) {
				$count = $batch_counts[ $term->term_id ];
			}

			$node = array(
				'id'         => $term->term_id,
				'text'       => $term->name,
				'parent'     => $term->parent,
				'count'      => $count,
				'color'      => $color ? $color : '#39FF14',
				'order'      => (int) $order,
				'locked'     => (bool) $locked,
				'pinned'     => (bool) $pinned,
				'icon'       => $icon ? $icon : '',
				'is_dynamic' => (bool) $is_dyn,
				'children'   => array(),
				'slug'       => $term->slug,
				'_taxonomy'  => $term->taxonomy, // Temp, stripped later.
			);

			// Dynamic folder count is delegated to KookoaWPF_Dynamic when the
			// class is loaded; otherwise short-circuit to 0 for any orphan
			// is_dynamic termmeta.
			if ( $is_dyn ) {
				$dyn_pt        = self::get_post_type_from_taxonomy( $term->taxonomy );
				$node['count'] = ( $dyn_pt && class_exists( 'KookoaWPF_Dynamic' ) )
					? KookoaWPF_Dynamic::count_items( $term->term_id, $dyn_pt )
					: 0;
			}

			$nodes_by_id[ $term->term_id ] = $node;

			$pid = (int) $term->parent;
			if ( ! isset( $children_of[ $pid ] ) ) {
				$children_of[ $pid ] = array();
			}
			$children_of[ $pid ][] = $term->term_id;
		}

		// 2) Assemble tree bottom-up: attach children, then sort each group.
		// Process deepest nodes first so their children are ready when the
		// parent is assembled.  We achieve this by iterating all parents in
		// reverse-depth order, but a simpler approach that works identically
		// is two passes: first attach, then return root.
		// Determine sort field and direction from preference
		$sort_by  = ( $sort_pref && isset( $sort_pref['by'] ) ) ? $sort_pref['by'] : 'custom';
		$sort_dir = ( $sort_pref && isset( $sort_pref['dir'] ) ) ? $sort_pref['dir'] : 'ASC';
		$dir_mult = ( 'DESC' === $sort_dir ) ? -1 : 1;

		foreach ( $children_of as $pid => $child_ids ) {
			/*
			 * Sort sibling group:
			 * 1. Pinned folders always first (regardless of sort mode).
			 * 2. In auto sort modes (name/date/count) dynamic folders are
			 *    pushed below regular ones — they are filters, not real
			 *    containers, and would otherwise pollute the user's
			 *    alphabetical/numeric reading of the list.
			 * 3. Custom sort: dynamic folders stay wherever the user
			 *    manually placed them, so this rule does NOT apply.
			 * 4. Within each resulting group, sort by the chosen field.
			 */
			usort(
				$child_ids,
				function ( $a, $b ) use ( $nodes_by_id, $sort_by, $dir_mult ) {
					$na = $nodes_by_id[ $a ];
					$nb = $nodes_by_id[ $b ];

					// 1. Pinned folders always come first regardless of sort.
					$pa = (int) $na['pinned'];
					$pb = (int) $nb['pinned'];
					if ( $pa !== $pb ) {
						return $pb - $pa; // Pinned first.
					}

					/*
					 * 2. In auto sort modes, push dynamic folders to the
					 * bottom of their (pinned/non-pinned) tier.
					 */
					if ( 'custom' !== $sort_by ) {
						$da = (int) $na['is_dynamic'];
						$db = (int) $nb['is_dynamic'];
						if ( $da !== $db ) {
							return $da - $db; // Non-dynamic first.
						}
					}

					// 4. Sort by chosen field.
					switch ( $sort_by ) {
						case 'name':
							return $dir_mult * strnatcasecmp( $na['text'], $nb['text'] );
						case 'date':
							return $dir_mult * ( $na['id'] - $nb['id'] ); // Term_id as proxy for creation date.
						case 'count':
							return $dir_mult * ( $na['count'] - $nb['count'] );
						default: // 'custom' — manual order via folder_order meta
							return $na['order'] - $nb['order'];
					}
				}
			);
			$children_of[ $pid ] = $child_ids;
		}

		// 3) Recursive assembly from requested parent, now O(n) total.
		return self::assemble_tree( $parent_id, $nodes_by_id, $children_of );
	}

	/**
	 * Recursively assemble tree from the lookup tables built by build_tree().
	 *
	 * @param int                              $parent_id   The parent ID to assemble from.
	 * @param array<int, array<string, mixed>> $nodes_by_id Lookup table of node arrays keyed by term ID.
	 * @param array<int, array<int, int>>      $children_of Lookup table of child IDs keyed by parent ID.
	 * @return array<int, array<string, mixed>>
	 */
	private static function assemble_tree( $parent_id, &$nodes_by_id, &$children_of ) {
		if ( ! isset( $children_of[ $parent_id ] ) ) {
			return array();
		}

		$tree = array();
		foreach ( $children_of[ $parent_id ] as $tid ) {
			$node             = $nodes_by_id[ $tid ];
			$node['children'] = self::assemble_tree( $tid, $nodes_by_id, $children_of );
			unset( $node['_taxonomy'] ); // Strip temp field.
			$tree[] = $node;
		}

		return $tree;
	}

	/**
	 * Get jstree-compatible data.
	 *
	 * @param string $post_type     The post type slug.
	 * @param string $force_sort_by Optional. Override the saved sort preference
	 *                              for this call only. See get_tree() for use.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_jstree_data( string $post_type, string $force_sort_by = '' ): array {
		$tree = self::get_tree( $post_type, $force_sort_by );
		return self::format_for_jstree( $tree );
	}

	/**
	 * Format tree for jstree library.
	 *
	 * @param array<int, array<string, mixed>> $tree Hierarchical folder tree array.
	 * @return array<int, array<string, mixed>>
	 */
	private static function format_for_jstree( $tree ) {
		$result = array();

		foreach ( $tree as $node ) {
			// Build status icons.
			$badges = '';
			if ( ! empty( $node['pinned'] ) ) {
				$badges .= ' <span class="kookoawpf-badge-icon kookoawpf-badge-pin" title="' . esc_attr__( 'Pinned', 'kookoa-folders' ) . '"><span class="dashicons dashicons-star-filled"></span></span>';
			}
			if ( ! empty( $node['locked'] ) ) {
				$badges .= ' <span class="kookoawpf-badge-icon kookoawpf-badge-lock" title="' . esc_attr__( 'Locked', 'kookoa-folders' ) . '"><span class="dashicons dashicons-lock"></span></span>';
			}

			$item = array(
				'id'    => 'folder_' . $node['id'],
				'text'  => '<span class="kookoawpf-folder-text">' . esc_html( $node['text'] ) . '</span>' . $badges,
				'data'  => array(
					'folder_id'     => $node['id'],
					'color'         => $node['color'],
					'locked'        => $node['locked'],
					'pinned'        => $node['pinned'],
					'count'         => $node['count'],
					'icon'          => $node['icon'],
					'is_dynamic'    => ! empty( $node['is_dynamic'] ),
					'original_text' => $node['text'],
				),
				'icon'  => 'kookoawpf-folder-icon',
				'state' => array(
					'opened' => false,
				),
			);

			if ( ! empty( $node['children'] ) ) {
				$item['children'] = self::format_for_jstree( $node['children'] );
			}

			$result[] = $item;
		}

		return $result;
	}

	/**
	 * Check if a term is a descendant of another.
	 *
	 * Uses get_ancestors() which fetches the full ancestor chain in a
	 * single query, avoiding recursive DB calls for deeply nested trees.
	 *
	 * @param int    $term_id     The term ID to check.
	 * @param int    $ancestor_id The potential ancestor term ID.
	 * @param string $taxonomy    The taxonomy name.
	 * @return bool
	 */
	private static function is_descendant( $term_id, $ancestor_id, $taxonomy ) {
		if ( (int) $term_id === (int) $ancestor_id ) {
			return true;
		}

		if ( 0 === (int) $term_id ) {
			return false;
		}

		$ancestors = get_ancestors( $term_id, $taxonomy, 'taxonomy' );

		return in_array( (int) $ancestor_id, array_map( 'intval', $ancestors ), true );
	}

	/**
	 * Check whether any descendant of a folder is locked.
	 *
	 * Uses a single query to fetch all descendant term IDs, then checks
	 * each for the folder_locked meta. Stops at the first locked hit.
	 *
	 * @param int    $folder_id Folder term ID.
	 * @param string $taxonomy  Taxonomy name.
	 * @return bool
	 */
	public static function has_locked_descendant( int $folder_id, string $taxonomy ): bool {
		$descendants = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'child_of'   => $folder_id,
				'hide_empty' => false,
				'fields'     => 'ids',
			)
		);

		if ( is_wp_error( $descendants ) || empty( $descendants ) ) {
			return false;
		}

		update_termmeta_cache( $descendants );

		foreach ( $descendants as $desc_id ) {
			if ( self::get_meta( $desc_id, 'folder_locked' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get max order value for siblings.
	 *
	 * @param string $taxonomy  The taxonomy name.
	 * @param int    $parent_id Parent term ID.
	 * @return int
	 */
	private static function get_max_order( $taxonomy, $parent_id ) {
		$terms = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'parent'     => absint( $parent_id ),
				'hide_empty' => false,
				'fields'     => 'ids',
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return 0;
		}

		update_termmeta_cache( $terms );

		$max = 0;
		foreach ( $terms as $tid ) {
			$order = (int) self::get_meta( $tid, 'folder_order' );
			if ( $order > $max ) {
				$max = $order;
			}
		}

		return $max;
	}

	/**
	 * Fetch direct children of a parent term, ordered by folder_order.
	 *
	 * Centralises the get_terms() call shared by move_folder and toggle_pin
	 * (both need siblings in the user-defined manual order before reshuffling
	 * pinned vs regular). Returns an empty array on WP_Error so callers can
	 * use `empty()` without an additional is_wp_error() guard.
	 *
	 * @param int    $parent_id Parent term ID (0 for root).
	 * @param string $taxonomy  Taxonomy slug.
	 * @return array<string, mixed> Array of WP_Term objects, or empty array.
	 */
	public static function get_children_ordered( int $parent_id, string $taxonomy ): array {
		$terms = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'parent'     => absint( $parent_id ),
				'hide_empty' => false,
				'orderby'    => 'name',
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return array();
		}

		return self::sort_by_folder_order( $terms );
	}

	/**
	 * Duplicate a folder (structure only — colors, icons, and the
	 * full subtree of folders/subfolders, but NOT item assignments).
	 *
	 * Item-copy was intentionally dropped: every UI caller hardcoded
	 * "no items", PRO doesn't expose a toggle, and copying assignments
	 * across a deep subtree introduces batch / permission concerns
	 * that nothing in the codebase currently needs. Re-add explicitly
	 * if a future feature needs it.
	 *
	 * @param int      $folder_id     The folder term ID to duplicate.
	 * @param string   $post_type     The post type slug.
	 * @param int|null $target_parent Target parent ID, or null for same parent.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function duplicate( int $folder_id, string $post_type, $target_parent = null ) {
		$taxonomy = self::get_taxonomy( $post_type );
		$term     = get_term( absint( $folder_id ), $taxonomy );

		if ( ! $term || is_wp_error( $term ) ) {
			return new WP_Error( 'invalid_folder', __( 'Folder not found.', 'kookoa-folders' ) );
		}

		// Smart folders cannot be duplicated — duplicating a saved query is
		// semantically useless (would create two folders showing the exact
		// same contents). To create a variant, edit the rules or create a
		// new smart folder from scratch.
		if ( '1' === self::get_meta( absint( $folder_id ), 'is_dynamic' ) ) {
			return new WP_Error( 'cannot_duplicate_smart', __( 'Smart folders cannot be duplicated. Edit its rules or create a new smart folder.', 'kookoa-folders' ) );
		}

		$new_name   = $term->name . ' ' . __( '(Copy)', 'kookoa-folders' );
		$parent_id  = null !== $target_parent ? absint( $target_parent ) : $term->parent;
		$new_folder = self::create( $new_name, $post_type, $parent_id );

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

		$new_id = $new_folder['term_id'];

		// Copy color.
		$color = self::get_meta( $folder_id, 'folder_color' );
		self::update_meta( $new_id, 'folder_color', $color );

		// Copy icon.
		$icon = self::get_meta( $folder_id, 'folder_icon' );
		if ( $icon ) {
			self::update_meta( $new_id, 'folder_icon', $icon );
		}

		// Note: smart (dynamic) folders are rejected at the top of this method,
		// so we never need to copy is_dynamic / dynamic_rules here.

		// Recursively duplicate children (no items, no lock/pin).
		$children = self::get_children_ordered( $folder_id, $taxonomy );
		foreach ( $children as $child ) {
			// Skip smart (dynamic) children — duplicating a saved query is
			// semantically useless.
			if ( '1' === self::get_meta( $child->term_id, 'is_dynamic' ) ) {
				continue;
			}
			self::duplicate_recursive( $child->term_id, $new_id, $post_type, $taxonomy );
		}

		return $new_folder;
	}

	/**
	 * Recursively duplicate a folder subtree.
	 *
	 * @param int    $source_id     Source folder term ID.
	 * @param int    $new_parent_id New parent term ID.
	 * @param string $post_type     The post type slug.
	 * @param string $taxonomy      The taxonomy name.
	 * @param int    $depth         Current recursion depth.
	 */
	private static function duplicate_recursive( $source_id, $new_parent_id, $post_type, $taxonomy, $depth = 0 ): void {
		if ( $depth >= 50 ) {
			return;
		}

		$term = get_term( $source_id, $taxonomy );
		if ( ! $term || is_wp_error( $term ) ) {
			return;
		}

		$new_folder = self::create( $term->name, $post_type, $new_parent_id );
		if ( is_wp_error( $new_folder ) ) {
			return;
		}

		$new_id = $new_folder['term_id'];

		// Copy color.
		$color = self::get_meta( $source_id, 'folder_color' );
		self::update_meta( $new_id, 'folder_color', $color );

		// Copy icon.
		$icon = self::get_meta( $source_id, 'folder_icon' );
		if ( $icon ) {
			self::update_meta( $new_id, 'folder_icon', $icon );
		}

		// Copy folder_order.
		$order = self::get_meta( $source_id, 'folder_order' );
		if ( '' !== $order ) {
			self::update_meta( $new_id, 'folder_order', $order );
		}

		// Note: smart (dynamic) children are skipped below before they ever
		// reach this function, so no is_dynamic / dynamic_rules copy needed.

		// Recurse into children.
		$children = self::get_children_ordered( $source_id, $taxonomy );
		if ( ! empty( $children ) ) {
			foreach ( $children as $child ) {
				// Skip smart (dynamic) children — duplicating a saved query is
				// semantically useless. Matches the behavior of the top-level
				// duplicate() which rejects smart sources.
				if ( '1' === self::get_meta( $child->term_id, 'is_dynamic' ) ) {
					continue;
				}
				self::duplicate_recursive( $child->term_id, $new_id, $post_type, $taxonomy, $depth + 1 );
			}
		}
	}

	/**
	 * Flatten hierarchical tree for dropdown usage.
	 *
	 * @param array<int, array<string, mixed>> $tree            Hierarchical folder tree array.
	 * @param array<int, array<string, mixed>> $flat            Reference to output flat array.
	 * @param int                              $depth           Current nesting depth.
	 * @param bool                             $exclude_dynamic Whether to skip dynamic folders.
	 */
	public static function flatten_tree( array $tree, array &$flat, int $depth = 0, bool $exclude_dynamic = false ): void {
		foreach ( $tree as $node ) {
			if ( $exclude_dynamic && ! empty( $node['is_dynamic'] ) ) {
				continue;
			}
			$flat[] = array(
				'id'     => $node['id'],
				'text'   => str_repeat( '— ', $depth ) . $node['text'],
				'color'  => $node['color'],
				'depth'  => $depth,
				'count'  => isset( $node['count'] ) ? (int) $node['count'] : 0,
				'locked' => ! empty( $node['locked'] ),
				'pinned' => ! empty( $node['pinned'] ),
			);
			if ( ! empty( $node['children'] ) ) {
				self::flatten_tree( $node['children'], $flat, $depth + 1, $exclude_dynamic );
			}
		}
	}

	// =====================
	// Folder meta helpers using term_meta
	// =====================

	/**
	 * Get a single folder meta value.
	 *
	 * @param int    $folder_id The folder term ID.
	 * @param string $key       Meta key.
	 * @return mixed
	 */
	public static function get_meta( int $folder_id, string $key ) {
		return get_term_meta( absint( $folder_id ), $key, true );
	}

	/**
	 * Update a folder meta value.
	 *
	 * @param int    $folder_id The folder term ID.
	 * @param string $key       Meta key.
	 * @param mixed  $value     Meta value.
	 * @return int|bool|WP_Error
	 */
	public static function update_meta( int $folder_id, string $key, $value ) {
		return update_term_meta( absint( $folder_id ), $key, $value );
	}

	/**
	 * Delete a folder meta value.
	 *
	 * @param int    $folder_id The folder term ID.
	 * @param string $key       Meta key.
	 * @return bool
	 */
	public static function delete_meta( int $folder_id, string $key ): bool {
		return delete_term_meta( absint( $folder_id ), $key );
	}

	/**
	 * Delete all meta for a folder.
	 *
	 * Called on folder deletion (rare), so the extra per-key query overhead
	 * from delete_term_meta() vs. a single $wpdb->delete is acceptable; in
	 * exchange we stay on the core API and keep the cache invalidation WP
	 * does for us.
	 *
	 * @param int $folder_id The folder term ID.
	 */
	public static function delete_all_meta( int $folder_id ): void {
		$folder_id = absint( $folder_id );
		$all_meta  = get_term_meta( $folder_id );
		if ( ! is_array( $all_meta ) ) {
			return;
		}
		foreach ( array_keys( $all_meta ) as $key ) {
			delete_term_meta( $folder_id, $key );
		}
	}

	/**
	 * Get total folder count for a post type.
	 *
	 * @param string $post_type The post type slug.
	 * @return int
	 */
	public static function get_count( string $post_type ): int {
		$taxonomy = self::get_taxonomy( $post_type );
		$count    = wp_count_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
			)
		);
		return is_wp_error( $count ) ? 0 : (int) $count;
	}

	/**
	 * Get subfolder count (terms with parent > 0).
	 *
	 * @param string $post_type The post type slug.
	 * @return int
	 */
	public static function get_subfolder_count( string $post_type ): int {
		$taxonomy = self::get_taxonomy( $post_type );
		$terms    = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
			)
		);

		if ( is_wp_error( $terms ) ) {
			return 0;
		}

		$count = 0;
		foreach ( $terms as $term ) {
			if ( $term->parent > 0 ) {
				++$count;
			}
		}

		return $count;
	}

	/**
	 * Get items not in any folder.
	 *
	 * Runs a single WP_Query and applies a "NOT EXISTS" tax_query via
	 * WP_Query::set on the live object after construction. WordPress's
	 * standard NOT EXISTS taxonomy semantics are preserved (the exclusion
	 * is resolved in the SQL JOIN, not by precomputing an ID list in PHP).
	 *
	 * For non-attachment post types the count includes all visible statuses
	 * (publish, draft, pending, private, future) so it aligns with the
	 * dynamic-folder count_items() logic.
	 *
	 * @param string $post_type The post type slug.
	 * @return int
	 */
	public static function get_uncategorized_count( string $post_type ): int {
		$taxonomy = self::get_taxonomy( $post_type );
		$statuses = 'attachment' === $post_type
			? array( 'inherit' )
			: array( 'publish', 'draft', 'pending', 'private', 'future' );

		// Fast path: when PRO Cleanup is not installed no attachment can carry
		// the _kookoawpf_cleanup_trash flag, so found_posts from a NOT EXISTS
		// taxonomy query is already exact. This avoids loading every
		// uncategorized ID into PHP + priming its postmeta cache on every
		// sidebar tree load — a real cost on libraries with tens of thousands
		// of attachments. The PRO branch below keeps the per-item subtraction.
		if ( ! function_exists( 'kookoawpf_pro_cleanup_trashed_count' ) ) {
			$count_query = new WP_Query();
			$count_query->set( 'post_type', $post_type );
			$count_query->set( 'post_status', $statuses );
			$count_query->set( 'posts_per_page', 1 );
			$count_query->set( 'fields', 'ids' );
			$count_query->set( 'no_found_rows', false );
			$count_query->set( 'update_post_meta_cache', false );
			$count_query->set( 'update_post_term_cache', false );
			$count_query->set(
				'tax_query',
				array(
					array(
						'taxonomy' => $taxonomy,
						'operator' => 'NOT EXISTS',
					),
				)
			);
			$count_query->get_posts();
			return (int) $count_query->found_posts;
		}

		$query_args = array(
			'post_type'              => $post_type,
			'post_status'            => $statuses,
			'posts_per_page'         => -1,
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
		);

		// Build the query without any tax/exclusion clause in the args array,
		// then apply the "NOT EXISTS" tax query via WP_Query::set on the live
		// object. WordPress still issues a real NOT EXISTS taxonomy join at
		// SQL level — no exclusion list is precomputed or held in PHP memory.
		$query = new WP_Query();
		foreach ( $query_args as $key => $value ) {
			$query->set( $key, $value );
		}
		$query->set(
			'tax_query',
			array(
				array(
					'taxonomy' => $taxonomy,
					'operator' => 'NOT EXISTS',
				),
			)
		);
		$query->get_posts();

		/**
		 * Narrow WP_Query::$posts.
		 *
		 * `fields => ids` above guarantees an int[] payload.
		 *
		 * @var int[] $ids
		 */
		$ids = $query->posts;
		if ( empty( $ids ) ) {
			return 0;
		}

		// Prime postmeta cache once so the cleanup-trash filter below is
		// served from cache, then subtract the soft-trashed items.
		update_meta_cache( 'post', $ids );

		$count = 0;
		foreach ( $ids as $pid ) {
			if ( '' === (string) get_post_meta( $pid, '_kookoawpf_cleanup_trash', true ) ) {
				++$count;
			}
		}
		return $count;
	}

	/**
	 * Count attachments soft-trashed by PRO Cleanup.
	 *
	 * These keep post_status='inherit' (the trash is a postmeta flag on
	 * '_kookoawpf_cleanup_trash'), so wp_count_posts() counts them as live
	 * attachments. We subtract them from the "All Items" total so the
	 * sidebar number matches what the Media Library actually shows
	 * (the cleanup-trash filter in PRO hides them from the grid).
	 *
	 * Delegated to the PRO trampoline kookoawpf_pro_cleanup_trashed_count()
	 * (same pattern as kookoawpf_pro_bootstrap()) so the direct COUNT() query
	 * lives in PRO under its file-level phpcs:disable, while FREE static
	 * analysis stays clean — no class_exists/method_exists on PRO symbols
	 * PHPStan can't see. Without PRO installed the trampoline doesn't exist
	 * and we return 0.
	 *
	 * @return int
	 */
	public static function get_cleanup_trashed_count(): int {
		if ( ! function_exists( 'kookoawpf_pro_cleanup_trashed_count' ) ) {
			return 0;
		}
		return (int) kookoawpf_pro_cleanup_trashed_count();
	}

	/**
	 * Filter a list of post IDs down to those NOT soft-trashed by PRO Cleanup.
	 *
	 * Mirrors the per-item exclusion get_uncategorized_count() applies to the
	 * "All Items" total, so every surface that materialises a concrete set of
	 * IDs — smart-folder matching (KookoaWPF_Dynamic), the folder ZIP download
	 * — hides cleanup-trashed attachments the same way the counters already do
	 * (see [[feedback_cleanup_trashed_invisible_everywhere]]).
	 *
	 * Fast path: when PRO Cleanup is not installed the trampoline function
	 * doesn't exist, no attachment can carry the _kookoawpf_cleanup_trash flag,
	 * and the input is returned untouched (zero extra queries). When PRO is
	 * present the postmeta cache is primed once and each ID tested in memory —
	 * no N+1.
	 *
	 * @param int[] $ids Candidate post IDs.
	 * @return int[] The subset that is not cleanup-trashed (order preserved).
	 */
	public static function exclude_cleanup_trashed_ids( array $ids ): array {
		if ( empty( $ids ) || ! function_exists( 'kookoawpf_pro_cleanup_trashed_count' ) ) {
			return $ids;
		}

		// Prime postmeta cache once so the per-ID check below is served from
		// cache, then drop the soft-trashed entries.
		update_meta_cache( 'post', $ids );

		$out = array();
		foreach ( $ids as $pid ) {
			if ( '' === (string) get_post_meta( $pid, '_kookoawpf_cleanup_trash', true ) ) {
				$out[] = (int) $pid;
			}
		}
		return $out;
	}

	/**
	 * Collect every post ID that sits in at least one folder of a taxonomy.
	 *
	 * Iterates the taxonomy with get_terms() + get_objects_in_term() so the
	 * lookup stays on the public API (no tax_query, no raw SQL). The result
	 * is intentionally unsorted and de-duplicated as integers.
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @return int[] Post IDs assigned to any folder in the taxonomy.
	 */
	public static function get_assigned_post_ids( string $taxonomy ): array {
		$term_ids = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => false,
				'fields'     => 'ids',
			)
		);
		if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) {
			return array();
		}
		$assigned = array();
		foreach ( $term_ids as $tid ) {
			$objects = get_objects_in_term( (int) $tid, $taxonomy );
			if ( is_wp_error( $objects ) || empty( $objects ) ) {
				continue;
			}
			foreach ( $objects as $oid ) {
				$assigned[ (int) $oid ] = true;
			}
		}
		return array_keys( $assigned );
	}

	/**
	 * Batch-count items per folder excluding trashed and cleanup-trashed posts.
	 * Returns array keyed by term_id => count.
	 * Uses a SINGLE query for ALL folders instead of 2 queries per folder.
	 *
	 * @param array<int, int> $term_ids Array of term IDs to count.
	 * @param string          $taxonomy The taxonomy name.
	 * @return array<int, int> term_id => count
	 */
	private static function batch_count_excluding_trash( $term_ids, $taxonomy ) {
		if ( empty( $term_ids ) ) {
			return array();
		}

		$term_ids  = array_map( 'absint', $term_ids );
		$counts    = array_fill_keys( $term_ids, 0 );
		$post_type = self::get_post_type_from_taxonomy( $taxonomy );
		if ( '' === $post_type ) {
			return $counts;
		}

		// Visible statuses for each post type (excludes 'trash' by omission).
		$statuses = 'attachment' === $post_type
			? array( 'inherit' )
			: array( 'publish', 'draft', 'pending', 'private', 'future' );

		// Build the candidate set via get_objects_in_term() per folder, then
		// fetch the corresponding posts with post__in. Same end result as a
		// single tax_query but resolved upfront, so the WP_Query stays a
		// straight SELECT against wp_posts.
		// Single query for the union of objects across ALL folders, instead of
		// one get_objects_in_term() call per folder (N+1 on large trees).
		// get_objects_in_term() accepts an array of term IDs and returns the
		// combined object set in one SELECT; per-term bucketing is recovered
		// below from the primed object-term cache, so the per-term breakdown
		// from this call is not needed here.
		$objects = get_objects_in_term( $term_ids, $taxonomy );
		if ( is_wp_error( $objects ) || empty( $objects ) ) {
			return $counts;
		}
		$candidates = array();
		foreach ( $objects as $oid ) {
			$candidates[ (int) $oid ] = true;
		}
		$query = new WP_Query(
			array(
				'post_type'              => $post_type,
				'post_status'            => $statuses,
				'posts_per_page'         => -1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'post__in'               => array_keys( $candidates ),
				'orderby'                => 'post__in',
			)
		);

		/**
		 * Narrow WP_Query::$posts.
		 *
		 * `fields => ids` above guarantees an int[] payload.
		 *
		 * @var int[] $ids
		 */
		$ids = $query->posts;
		if ( empty( $ids ) ) {
			return $counts;
		}

		// Prime caches: meta for the cleanup-trash filter, terms for bucketing.
		update_meta_cache( 'post', $ids );
		update_object_term_cache( $ids, $post_type );

		foreach ( $ids as $pid ) {
			if ( '' !== (string) get_post_meta( $pid, '_kookoawpf_cleanup_trash', true ) ) {
				continue;
			}
			$post_term_ids = wp_get_object_terms( $pid, $taxonomy, array( 'fields' => 'ids' ) );
			if ( is_wp_error( $post_term_ids ) ) {
				continue;
			}
			foreach ( $post_term_ids as $tid ) {
				$tid = (int) $tid;
				if ( isset( $counts[ $tid ] ) ) {
					++$counts[ $tid ];
				}
			}
		}

		return $counts;
	}

	/**
	 * Reverse-lookup: taxonomy name to post type.
	 *
	 * @param string $taxonomy Taxonomy name (e.g. 'kookoawpf_folder_attachment').
	 * @return string Post type, or empty string on mismatch.
	 */
	private static function get_post_type_from_taxonomy( $taxonomy ) {
		// kookoawpf_folder_attachment → attachment, kookoawpf_folder_post → post, etc.
		$prefix = 'kookoawpf_folder_';
		if ( strpos( $taxonomy, $prefix ) === 0 ) {
			return substr( $taxonomy, strlen( $prefix ) );
		}
		return '';
	}
}
