<?php
/**
 * Media scanner — walks uploads directory and checks each file
 * against the WP media library, usage in posts, and thumbnail state.
 *
 * @package CoffeeWP_Media_Cleanup
 */

namespace CWPM\Media;

use CWPM\Thumbnail\Sizes;
use CWPM\Compat\WooCommerce;
use CWPM\Compat\Multilingual;
use CWPM\Compat\Acf;

defined( 'ABSPATH' ) || exit;

// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders -- media scanner reads wp_posts/wp_postmeta directly to detect attachment usage; SQL composed with pre-built placeholder clauses.

class Scanner {

	/**
	 * Resolve the list of post types the scanner should inspect.
	 *
	 * Defaults to ['post', 'page']. Extensions may extend this via the
	 * `cwpm_scan_post_types` filter to include additional CPTs.
	 *
	 * @return string[] Sanitised, non-empty post-type slug list.
	 */
	private static function get_scan_post_types() {
		$types = apply_filters( 'cwpm_scan_post_types', array( 'post', 'page' ) );
		$types = is_array( $types ) ? array_values( array_filter( array_map( 'sanitize_key', $types ) ) ) : array();
		if ( empty( $types ) ) {
			$types = array( 'post', 'page' );
		}
		return $types;
	}

	/**
	 * Build an SQL-safe `IN (...)` placeholder list + args for the resolved
	 * scan post types.
	 *
	 * @return array{placeholders:string,args:string[]}
	 */
	private static function get_scan_post_types_clause() {
		$slugs        = self::get_scan_post_types();
		$placeholders = implode( ',', array_fill( 0, count( $slugs ), '%s' ) );
		return array(
			'placeholders' => $placeholders,
			'args'         => $slugs,
		);
	}

	/**
	 * Image extensions we scan for.
	 *
	 * @var string[]
	 */
	private static $image_extensions = array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'ico' );

	/**
	 * Default TTL for cached scan enumerations.
	 */
	const SCAN_CACHE_TTL = 10 * MINUTE_IN_SECONDS;

	/**
	 * Cache buckets used by flush_cache(). Keep in sync with cache_key() callers.
	 *
	 * @var string[]
	 */
	private static $cache_buckets = array( 'init_scan', 'thumbnail_index' );

	/**
	 * Build a transient key for a given cache bucket.
	 *
	 * @param string $bucket Bucket name.
	 * @return string
	 */
	private static function cache_key( $bucket ) {
		return 'cwpm_scan_cache_' . $bucket;
	}

	/**
	 * TTL for cached enumerations. Filterable via 'cwpm_scan_cache_ttl'.
	 *
	 * @return int Seconds.
	 */
	private static function cache_ttl() {
		return (int) apply_filters( 'cwpm_scan_cache_ttl', self::SCAN_CACHE_TTL );
	}

	/**
	 * Drop every cached enumeration result.
	 *
	 * Called automatically on add_attachment / delete_attachment. Safe to call
	 * by hand when uploads change outside WP (SFTP, manual file ops).
	 */
	public static function flush_cache() {
		foreach ( self::$cache_buckets as $bucket ) {
			delete_transient( self::cache_key( $bucket ) );
		}
	}

	/**
	 * Initialise the scan: collect all year/month folders and count original images.
	 *
	 * Returns a manifest the JS side uses to drive batch requests.
	 *
	 * @return array {
	 *     @type array  $folders    [ [ 'year' => '2024', 'month' => '06', 'files' => [ 'image.jpg', ... ] ], ... ]
	 *     @type int    $total      Total number of original images found.
	 *     @type int    $batch_size Current batch size setting.
	 * }
	 */
	public static function init_scan() {
		$cached = get_transient( self::cache_key( 'init_scan' ) );
		if ( false !== $cached && is_array( $cached ) ) {

			$cached['batch_size'] = self::get_batch_size();
			return $cached;
		}

		$upload_dir = wp_upload_dir();
		$base_dir   = $upload_dir['basedir'];
		$folders    = array();
		$total      = 0;

		if ( ! is_dir( $base_dir ) ) {
			return array(
				'folders'    => $folders,
				'total'      => 0,
				'batch_size' => self::get_batch_size(),
			);
		}

		$thumb_index = self::build_thumbnail_index();

		$years = glob( $base_dir . '/[0-9][0-9][0-9][0-9]', GLOB_ONLYDIR );
		if ( ! $years ) {
			$years = array();
		}

		sort( $years );

		foreach ( $years as $year_path ) {
			$year   = basename( $year_path );
			$months = glob( $year_path . '/[0-9][0-9]', GLOB_ONLYDIR );
			if ( ! $months ) {
				continue;
			}

			sort( $months );

			foreach ( $months as $month_path ) {
				$month     = basename( $month_path );
				$all_files = scandir( $month_path );
				$images    = array();

				$candidates = array();

				foreach ( $all_files as $file ) {
					if ( '.' === $file[0] ) {
						continue;
					}

					$ext = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
					if ( ! in_array( $ext, self::$image_extensions, true ) ) {
						continue;
					}

					$rel_path = $year . '/' . $month . '/' . $file;
					if ( isset( $thumb_index[ $rel_path ] ) ) {
						continue;
					}

					$candidates[] = $file;
				}

				$candidate_set = array_flip( $candidates );
				foreach ( $candidates as $file ) {
					$file_ext  = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
					$file_base = pathinfo( $file, PATHINFO_FILENAME );
					if ( preg_match( '/^(.+)-\d+x\d+$/', $file_base, $m ) ) {
						$parent_base = $m[1];

						if (
							isset( $candidate_set[ $parent_base . '.' . $file_ext ] ) ||
							isset( $candidate_set[ $parent_base . '-scaled.' . $file_ext ] )
						) {
							continue;
						}
					}
					$images[] = $file;
				}

				if ( ! empty( $images ) ) {
					sort( $images );
					$folders[] = array(
						'year'  => $year,
						'month' => $month,
						'files' => $images,
					);
					$total    += count( $images );
				}
			}
		}

		$result = array(
			'folders'    => $folders,
			'total'      => $total,
			'batch_size' => self::get_batch_size(),
		);

		set_transient( self::cache_key( 'init_scan' ), $result, self::cache_ttl() );

		return $result;
	}

	/**
	 * Scan a batch of files and return detailed info for each.
	 *
	 * When a timeout guard is provided the method may return fewer results
	 * than files requested.  The caller should check the 'timeout' key in
	 * the response from the AJAX wrapper to detect partial batches.
	 *
	 * @param array                         $files   Array of [ 'year' => '2024', 'month' => '06', 'file' => 'image.jpg' ].
	 * @param \CWPM\Utils\TimeoutGuard|null $guard   Optional timeout guard.
	 * @return array Array of file info arrays.
	 */
	public static function scan_batch( $files, $guard = null ) {
		if ( empty( $files ) ) {
			return array();
		}

		$upload_dir = wp_upload_dir();
		$base_dir   = $upload_dir['basedir'];
		$base_url   = $upload_dir['baseurl'];
		$results    = array();

		$rel_paths = array();
		foreach ( $files as $item ) {
			$rel_paths[] = $item['year'] . '/' . $item['month'] . '/' . $item['file'];
		}
		$attachment_map = self::get_attachments_by_paths( $rel_paths );

		$attach_ids = array_filter( array_values( $attachment_map ) );

		$usage_ids = $attach_ids;
		if ( ! empty( $attach_ids ) && Multilingual::is_active() ) {
			foreach ( $attach_ids as $id ) {
				$usage_ids = array_merge( $usage_ids, Multilingual::get_all_translations( $id ) );
			}
			$usage_ids = array_values( array_unique( $usage_ids ) );
		}

		$usage_map = ! empty( $usage_ids ) ? self::get_usage_map( $usage_ids ) : array();

		$registered_sizes = wp_get_registered_image_subsizes();

		foreach ( $files as $item ) {

			if ( null !== $guard && ! $guard->can_continue() ) {
				break;
			}
			$year     = $item['year'];
			$month    = $item['month'];
			$filename = $item['file'];
			$rel_path = $year . '/' . $month . '/' . $filename;
			$abs_path = $base_dir . '/' . $rel_path;

			$file_size = file_exists( $abs_path ) ? filesize( $abs_path ) : 0;
			$attach_id = isset( $attachment_map[ $rel_path ] ) ? $attachment_map[ $rel_path ] : 0;

			$in_media = $attach_id > 0;
			$used_in  = $in_media && ! empty( $usage_map[ $attach_id ] ) ? $usage_map[ $attach_id ] : array();

			if ( $in_media && empty( $used_in ) && Multilingual::is_active() ) {
				$all_translation_ids = Multilingual::get_all_translations( $attach_id );
				foreach ( $all_translation_ids as $trans_id ) {
					if ( ! empty( $usage_map[ $trans_id ] ) ) {
						$used_in = $usage_map[ $trans_id ];
						break;
					}
				}
			}

			$used          = ! empty( $used_in );
			$sizes         = array();
			$savings       = 0;
			$orphan_thumbs = array();

			if ( $in_media ) {
				$meta  = wp_get_attachment_metadata( $attach_id );
				$sizes = self::check_thumbnail_sizes( $meta, $registered_sizes, $base_dir, $year, $month );

				$ext_lower = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
				$base_name = pathinfo( $filename, PATHINFO_FILENAME );
				$dir_abs   = $base_dir . '/' . $year . '/' . $month;

				$known_thumb_files = array();
				if ( ! empty( $meta['sizes'] ) ) {
					foreach ( $meta['sizes'] as $size_data ) {
						if ( ! empty( $size_data['file'] ) ) {
							$known_thumb_files[ $size_data['file'] ] = true;
						}
					}
				}
				if ( ! empty( $meta['original_image'] ) ) {
					$known_thumb_files[ $meta['original_image'] ] = true;
				}

				$glob_bases = array( $base_name );
				if ( substr( $base_name, -7 ) === '-scaled' ) {

					$glob_bases[] = substr( $base_name, 0, -7 );
				}

				foreach ( $glob_bases as $glob_base ) {
					$matches = glob( $dir_abs . '/' . $glob_base . '-[0-9]*x[0-9]*.' . $ext_lower );
					if ( ! $matches ) {
						continue;
					}
					foreach ( $matches as $sib_abs ) {
						$sib_name = basename( $sib_abs );
						if ( isset( $known_thumb_files[ $sib_name ] ) ) {
							continue;
						}
						$sib_size = file_exists( $sib_abs ) ? filesize( $sib_abs ) : 0;
						preg_match( '/-(\d+x\d+)\.' . preg_quote( $ext_lower, '/' ) . '$/', $sib_name, $dim_m );
						$orphan_thumbs[] = array(
							'file'           => $sib_name,
							'rel_path'       => $year . '/' . $month . '/' . $sib_name,
							'size'           => $sib_size,
							'size_formatted' => size_format( $sib_size, 1 ),
							'dims'           => isset( $dim_m[1] ) ? $dim_m[1] : '',
						);
					}
				}

				if ( ! $used ) {
					$savings = $file_size;

					foreach ( $sizes as $size_info ) {
						if ( 'ok' === $size_info['status'] && ! empty( $size_info['file_size'] ) ) {
							$savings += $size_info['file_size'];
						}
					}
				}
			} else {

				$savings = $file_size;

				$ext_lower = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
				$base_name = pathinfo( $filename, PATHINFO_FILENAME );
				$dir_abs   = $base_dir . '/' . $year . '/' . $month;

				$glob_bases = array( $base_name );
				if ( substr( $base_name, -7 ) === '-scaled' ) {
					$glob_bases[] = substr( $base_name, 0, -7 );
				}

				$sibling_files = array();
				foreach ( $glob_bases as $glob_base ) {
					$matches = glob( $dir_abs . '/' . $glob_base . '-[0-9]*x[0-9]*.' . $ext_lower );
					if ( $matches ) {
						$sibling_files = array_merge( $sibling_files, $matches );
					}
				}
				if ( $sibling_files ) {
					foreach ( $sibling_files as $sib_abs ) {
						$sib_name = basename( $sib_abs );
						$sib_size = file_exists( $sib_abs ) ? filesize( $sib_abs ) : 0;
						$savings += $sib_size;
						preg_match( '/-(\d+x\d+)\.' . preg_quote( $ext_lower, '/' ) . '$/', $sib_name, $dim_m );
						$orphan_thumbs[] = array(
							'file'           => $sib_name,
							'rel_path'       => $year . '/' . $month . '/' . $sib_name,
							'size'           => $sib_size,
							'size_formatted' => size_format( $sib_size, 1 ),
							'dims'           => isset( $dim_m[1] ) ? $dim_m[1] : '',
						);
					}
				}
			}

			$thumb_url = '';
			if ( $in_media ) {
				$thumb_folder  = $base_dir . '/' . $year . '/' . $month;
				$meta_sizes    = isset( $meta['sizes'] ) ? $meta['sizes'] : array();
				$preview_sizes = array( 'thumbnail', 'medium', 'medium_large', 'large' );

				foreach ( $preview_sizes as $preview_size ) {
					if ( ! empty( $meta_sizes[ $preview_size ]['file'] ) && file_exists( $thumb_folder . '/' . $meta_sizes[ $preview_size ]['file'] ) ) {
						$src = wp_get_attachment_image_src( $attach_id, $preview_size );
						if ( $src ) {
							$thumb_url = $src[0];
							break;
						}
					}
				}

				if ( ! $thumb_url && file_exists( $base_dir . '/' . $rel_path ) ) {
					$thumb_url = $base_url . '/' . $rel_path;
				}
			} else {
				$thumb_url = $base_url . '/' . $rel_path;
			}

				$results[] = array(
					'year'             => $year,
					'month'            => $month,
					'name'             => $filename,
					'rel_path'         => $rel_path,
					'attachment_id'    => $attach_id,
					'in_media'         => $in_media,
					'used'             => $used,
					'size'             => $file_size,
					'size_formatted'   => size_format( $file_size, 1 ),
					'potential_saving' => size_format( $savings, 1 ),
					'savings_raw'      => $savings,
					'thumb_url'        => $thumb_url,
					'edit_url'         => $in_media ? get_edit_post_link( $attach_id, 'raw' ) : '',
					'used_in'          => $used_in,
					'sizes'            => $sizes,
					'orphan_thumbs'    => $orphan_thumbs,
				);

				if ( null !== $guard ) {
					$guard->tick();
				}
		}

		return $results;
	}

	/**
	 * Look up attachment IDs for a list of relative upload paths.
	 *
	 * @param string[] $rel_paths e.g. [ '2024/06/image.jpg' ].
	 * @return array Map of rel_path => attachment_id (0 if not found).
	 */
	private static function get_attachments_by_paths( $rel_paths ) {

		global $wpdb;

		if ( empty( $rel_paths ) ) {
			return array();
		}

		$map          = array_fill_keys( $rel_paths, 0 );
		$placeholders = implode( ',', array_fill( 0, count( $rel_paths ), '%s' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT post_id, meta_value FROM {$wpdb->postmeta}
				 WHERE meta_key = '_wp_attached_file'
				 AND meta_value IN ({$placeholders})",
				...$rel_paths
			)
		);

		foreach ( $rows as $row ) {
			$map[ $row->meta_value ] = (int) $row->post_id;
		}

		return $map;
	}

	/**
	 * Scan for broken media: DB attachments whose files are missing from disk.
	 *
	 * @return array Array of file info arrays (same shape as scan_batch results).
	 */
	public static function scan_broken_media() {

		global $wpdb;

		$upload_dir = wp_upload_dir();
		$base_dir   = $upload_dir['basedir'];
		$base_url   = $upload_dir['baseurl'];
		$results    = array();

		$rows = $wpdb->get_results(
			"SELECT p.ID, pm.meta_value AS attached_file
			 FROM {$wpdb->posts} p
			 INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
			 WHERE p.post_type = 'attachment'
			 AND p.post_status = 'inherit'
			 AND pm.meta_key = '_wp_attached_file'"
		);

		foreach ( $rows as $row ) {
			$attach_id = (int) $row->ID;
			$rel_path  = $row->attached_file;
			$abs_path  = $base_dir . '/' . $rel_path;

			if ( file_exists( $abs_path ) ) {
				continue;
			}

			$parts = explode( '/', $rel_path );
			$year  = isset( $parts[0] ) && preg_match( '/^\d{4}$/', $parts[0] ) ? $parts[0] : '';
			$month = isset( $parts[1] ) && preg_match( '/^\d{2}$/', $parts[1] ) ? $parts[1] : '';
			$name  = basename( $rel_path );

			$results[] = array(
				'year'             => $year,
				'month'            => $month,
				'name'             => $name,
				'rel_path'         => $rel_path,
				'attachment_id'    => $attach_id,
				'in_media'         => true,
				'used'             => false,
				'broken'           => true,
				'size'             => 0,
				'size_formatted'   => '0 B',
				'potential_saving' => '0 B',
				'savings_raw'      => 0,
				'thumb_url'        => '',
				'edit_url'         => get_edit_post_link( $attach_id, 'raw' ),
				'used_in'          => array(),
				'sizes'            => array(),
			);
		}

		return $results;
	}

	/**
	 * Find which posts use each attachment (featured image, content, excerpt, meta, widgets, theme).
	 *
	 * @param int[] $attach_ids Array of attachment IDs.
	 * @return array Map of attachment_id => array of post info arrays.
	 *               Each entry: [ 'id' => int, 'title' => string, 'type' => string, 'edit_url' => string, 'context' => string ].
	 */
	private static function get_usage_map( $attach_ids ) {
		if ( empty( $attach_ids ) ) {
			return array();
		}

		$used = array();

		$add_usage = function ( $aid, $entry ) use ( &$used ) {
			if ( ! isset( $used[ $aid ] ) ) {
				$used[ $aid ] = array();
			}

			foreach ( $used[ $aid ] as &$existing ) {
				if ( $existing['id'] === $entry['id'] ) {
					$a                   = is_array( $existing['context'] ) ? $existing['context'] : ( $existing['context'] ? array( $existing['context'] ) : array() );
					$b                   = is_array( $entry['context'] ) ? $entry['context'] : ( $entry['context'] ? array( $entry['context'] ) : array() );
					$existing['context'] = array_values( array_unique( array_merge( $a, $b ) ) );

					if ( 'woocommerce' === $entry['type'] && 'woocommerce' !== $existing['type'] ) {
						$existing['type'] = 'woocommerce';
					}
					return;
				}
			}
			unset( $existing );
			$used[ $aid ][] = $entry;
		};

		self::check_featured_image_usage( $attach_ids, $used, $add_usage );

		self::check_content_usage( $attach_ids, $used, $add_usage );

		self::check_postmeta_usage( $attach_ids, $used, $add_usage );

		self::check_widget_usage( $attach_ids, $used, $add_usage );

		self::check_theme_usage( $attach_ids, $used, $add_usage );

		self::check_block_usage( $attach_ids, $used, $add_usage );

		self::check_woocommerce_usage( $used, $add_usage );

		self::check_acf_usage( $used, $add_usage );

		/**
		 * Fires after the built-in scanners run, letting extension plugins
		 * register CPT-specific attachment usage (events, listings, portfolio
		 * plugins, …).
		 *
		 * @param int[]    $attach_ids Attachment IDs being scanned.
		 * @param array    $used       Usage map (modified by reference via $add_usage).
		 * @param callable $add_usage  Helper to add entries.
		 */
		do_action_ref_array( 'cwpm_scan_cpt_usage', array( $attach_ids, &$used, $add_usage ) );

		/**
		 * Fires after core usage collection so extension plugins can contribute
		 * additional attachment-usage entries (deep scans, third-party integrations).
		 *
		 * Listeners receive a callable that mirrors the local $add_usage helper —
		 * call it with ($attachment_id, $usage_entry) to register a usage record
		 * the same way the core scanner does.
		 *
		 * @param callable $add_usage Function ($aid, $entry) — registers a usage entry.
		 * @param array    $used      Snapshot of the in-progress used-attachments map (read-only inspection).
		 * @param array    $context   Scanner context (currently: ['attach_ids' => int[]]).
		 */
		$context = array( 'attach_ids' => $attach_ids );
		do_action( 'cwpm_scanner_collect_used', $add_usage, $used, $context );

		return $used;
	}

	/**
	 * Check featured images (_thumbnail_id meta) for attachment usage.
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used     Usage map.
	 * @param callable $add_usage Helper to add entries.
	 */
	private static function check_featured_image_usage( $attach_ids, &$used, $add_usage ) {

		global $wpdb;

		$ids    = implode( ',', array_map( 'absint', $attach_ids ) );
		$clause = self::get_scan_post_types_clause();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$featured_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT pm.post_id, pm.meta_value AS attach_id
				 FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE pm.meta_key = '_thumbnail_id'
				 AND pm.meta_value IN ({$ids})
				 AND p.post_status NOT IN ('trash','auto-draft')
				 AND p.post_type IN ({$clause['placeholders']})",
				...$clause['args']
			)
		);

		foreach ( $featured_rows as $row ) {
			$aid     = (int) $row->attach_id;
			$post_id = (int) $row->post_id;
			$post    = get_post( $post_id );
			if ( ! $post ) {
				continue;
			}
			$add_usage(
				$aid,
				array(
					'id'       => $post_id,
					'title'    => $post->post_title ?: '(no title)',
					'type'     => $post->post_type,
					'edit_url' => get_edit_post_link( $post_id, 'raw' ),
					'context'  => 'featured',
				)
			);
		}
	}

	/**
	 * Check post content and excerpt for attachment references (wp-image-{id} class or URL).
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used     Usage map.
	 * @param callable $add_usage Helper to add entries.
	 */
	private static function check_content_usage( $attach_ids, &$used, $add_usage ) {

		global $wpdb;

		$clause = self::get_scan_post_types_clause();

		foreach ( $attach_ids as $aid ) {
			$url     = wp_get_attachment_url( $aid );
			$pattern = 'wp-image-' . $aid;

			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$content_posts = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT ID, post_title, post_type FROM {$wpdb->posts}
					 WHERE post_status NOT IN ('trash','auto-draft')
					 AND post_type IN ({$clause['placeholders']})
					 AND ( post_content LIKE %s OR post_content LIKE %s
					    OR post_excerpt LIKE %s OR post_excerpt LIKE %s )",
					...array_merge(
						$clause['args'],
						array(
							'%' . $wpdb->esc_like( $pattern ) . '%',
							'%' . $wpdb->esc_like( $url ) . '%',
							'%' . $wpdb->esc_like( $pattern ) . '%',
							'%' . $wpdb->esc_like( $url ) . '%',
						)
					)
				)
			);

			foreach ( $content_posts as $post ) {
				$add_usage(
					(int) $aid,
					array(
						'id'       => (int) $post->ID,
						'title'    => $post->post_title ?: '(no title)',
						'type'     => $post->post_type,
						'edit_url' => get_edit_post_link( (int) $post->ID, 'raw' ),
						'context'  => 'content',
					)
				);
			}
		}
	}

	/**
	 * Check WooCommerce media references for attachment usage.
	 *
	 * @param array    &$used     Usage map.
	 * @param callable $add_usage Helper to add entries.
	 */
	private static function check_woocommerce_usage( &$used, $add_usage ) {
		if ( ! WooCommerce::is_active() ) {
			return;
		}

		$included = self::get_scan_post_types();
		if ( in_array( 'product', $included, true ) ) {

			foreach ( WooCommerce::get_category_thumbnail_usage() as $cat ) {
				$add_usage(
					(int) $cat['attach_id'],
					array(
						'id'       => $cat['term_id'],
						'title'    => $cat['title'],
						'type'     => $cat['type'],
						'edit_url' => $cat['edit_url'],
						'context'  => $cat['context'],
					)
				);
			}

			$placeholder_id = WooCommerce::get_placeholder_id();
			if ( $placeholder_id ) {
				$add_usage(
					$placeholder_id,
					array(
						'id'       => 0,
						'title'    => 'WooCommerce',
						'type'     => 'woocommerce',
						'edit_url' => admin_url( 'admin.php?page=wc-settings' ),
						'context'  => 'placeholder',
					)
				);
			}
		}
	}

	/**
	 * Check ACF-stored media references for attachment usage.
	 *
	 * @param array    &$used     Usage map.
	 * @param callable $add_usage Helper to add entries.
	 */
	private static function check_acf_usage( &$used, $add_usage ) {
		if ( ! Acf::is_active() ) {
			return;
		}

		foreach ( Acf::get_used_sources() as $source ) {
			$aid = $source['id'];
			if ( ! isset( $used[ $aid ] ) ) {
				$used[ $aid ] = array();
			}
			$label          = ! empty( $source['post_title'] ) ? $source['post_title'] : ucfirst( $source['source_type'] );
			$used[ $aid ][] = array(
				'id'       => 0,
				'title'    => $label . ' — ACF',
				'type'     => 'acf',
				'edit_url' => admin_url( 'edit.php?post_type=acf-field-group' ),
				'context'  => $source['field_name'],
			);
		}
	}

	/**
	 * Check post meta for attachment ID references (plain and serialized).
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used      Usage map (modified by reference via $add_usage).
	 * @param callable $add_usage  Helper to add entries.
	 */
	private static function check_postmeta_usage( $attach_ids, &$used, $add_usage ) {

		global $wpdb;

		$ids_set = array_flip( array_map( 'strval', $attach_ids ) );

		$exclude_keys         = array( '_thumbnail_id', '_wp_attached_file', '_wp_attachment_metadata', '_edit_lock', '_edit_last', '_product_image_gallery' );
		$exclude_placeholders = implode( ',', array_fill( 0, count( $exclude_keys ), '%s' ) );

		$clause = self::get_scan_post_types_clause();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT pm.post_id, pm.meta_key, pm.meta_value
				 FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE p.post_type != 'attachment'
				 AND p.post_status NOT IN ('trash','auto-draft')
				 AND p.post_type IN ({$clause['placeholders']})
				 AND pm.meta_key NOT IN ({$exclude_placeholders})
				 AND pm.meta_value != ''",
				...array_merge( $clause['args'], $exclude_keys )
			)
		);

		foreach ( $rows as $row ) {
			$found_ids = self::extract_ids_from_value( $row->meta_value );
			$matches   = array_intersect_key( array_flip( $found_ids ), $ids_set );

			if ( empty( $matches ) ) {
				continue;
			}

			$post = get_post( (int) $row->post_id );
			if ( ! $post ) {
				continue;
			}

			foreach ( array_keys( $matches ) as $aid_str ) {
				$add_usage(
					(int) $aid_str,
					array(
						'id'       => (int) $row->post_id,
						'title'    => $post->post_title ?: '(no title)',
						'type'     => $post->post_type,
						'edit_url' => get_edit_post_link( (int) $row->post_id, 'raw' ),
						'context'  => 'meta',
					)
				);
			}
		}
	}

	/**
	 * Extract numeric IDs from a meta value (plain, serialized, comma-separated, or JSON).
	 *
	 * @param string $value Raw meta value.
	 * @return int[] Array of possible attachment IDs found.
	 */
	private static function extract_ids_from_value( $value ) {
		$ids = array();

		if ( is_numeric( $value ) && (int) $value > 0 ) {
			$ids[] = (int) $value;
			return $ids;
		}

		if ( preg_match( '/^[\d\s,]+$/', $value ) && strpos( $value, ',' ) !== false ) {
			foreach ( explode( ',', $value ) as $part ) {
				$part = trim( $part );
				if ( is_numeric( $part ) && (int) $part > 0 ) {
					$ids[] = (int) $part;
				}
			}
			return $ids;
		}

		if ( is_serialized( $value ) ) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize,WordPress.PHP.NoSilencedErrors.Discouraged -- post_meta from third-party plugins / WP core; is_serialized + is_array gates.
			$decoded = @unserialize( $value );
			if ( is_array( $decoded ) ) {
				self::collect_ids_recursive( $decoded, $ids );
			}
			return $ids;
		}

		if ( '{' === $value[0] || '[' === $value[0] ) {
			$decoded = json_decode( $value, true );
			if ( is_array( $decoded ) ) {
				self::collect_ids_recursive( $decoded, $ids );
			}
		}

		return $ids;
	}

	/**
	 * Recursively collect numeric IDs from an array structure.
	 *
	 * @param array $data Array to traverse.
	 * @param int[] &$ids Collected IDs.
	 */
	private static function collect_ids_recursive( $data, &$ids ) {
		foreach ( $data as $key => $value ) {
			if ( is_array( $value ) ) {
				self::collect_ids_recursive( $value, $ids );
			} elseif ( is_numeric( $value ) && (int) $value > 0 && (int) $value < 999999999 ) {
				$ids[] = (int) $value;
			}
		}
	}

	/**
	 * Check active widgets for attachment ID references.
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used      Usage map.
	 * @param callable $add_usage  Helper to add entries.
	 */
	private static function check_widget_usage( $attach_ids, &$used, $add_usage ) {
		$ids_set = array_flip( array_map( 'strval', $attach_ids ) );
		$url_map = array();
		foreach ( $attach_ids as $aid ) {
			$url = wp_get_attachment_url( $aid );
			if ( $url ) {
				$url_map[ $url ] = $aid;
			}
		}

		$sidebars = get_option( 'sidebars_widgets', array() );
		if ( ! is_array( $sidebars ) ) {
			return;
		}

		foreach ( $sidebars as $sidebar_id => $widgets ) {
			if ( 'wp_inactive_widgets' === $sidebar_id || ! is_array( $widgets ) ) {
				continue;
			}

			foreach ( $widgets as $widget_id ) {

				if ( ! preg_match( '/^(.+)-(\d+)$/', $widget_id, $m ) ) {
					continue;
				}

				$type     = $m[1];
				$instance = (int) $m[2];
				$data     = get_option( "widget_{$type}" );

				if ( ! is_array( $data ) || ! isset( $data[ $instance ] ) ) {
					continue;
				}

				$widget_data = $data[ $instance ];
				if ( ! is_array( $widget_data ) ) {
					continue;
				}

				foreach ( array( 'attachment_id', 'ids', 'url', 'thumbnail' ) as $key ) {
					if ( ! empty( $widget_data[ $key ] ) ) {
						$found = self::extract_ids_from_value( (string) $widget_data[ $key ] );
						foreach ( $found as $fid ) {
							if ( isset( $ids_set[ (string) $fid ] ) ) {
								$add_usage(
									$fid,
									array(
										'id'       => 0,
										'title'    => "Widget: {$type}",
										'type'     => 'widget',
										'edit_url' => admin_url( 'widgets.php' ),
										'context'  => 'widget',
									)
								);
							}
						}
					}
				}

				if ( ! empty( $widget_data['text'] ) || ! empty( $widget_data['content'] ) ) {
					$text = ( $widget_data['text'] ?? '' ) . ( $widget_data['content'] ?? '' );
					foreach ( $url_map as $url => $aid ) {
						if ( stripos( $text, $url ) !== false ) {
							$add_usage(
								$aid,
								array(
									'id'       => 0,
									'title'    => "Widget: {$type}",
									'type'     => 'widget',
									'edit_url' => admin_url( 'widgets.php' ),
									'context'  => 'widget',
								)
							);
						}
					}
				}
			}
		}
	}

	/**
	 * Check theme assets for attachment usage (logo, site icon, header, background).
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used      Usage map.
	 * @param callable $add_usage  Helper to add entries.
	 */
	private static function check_theme_usage( $attach_ids, &$used, $add_usage ) {
		$ids_set = array_flip( $attach_ids );

		$theme_assets = array(
			'custom_logo' => array(
				'id'    => (int) get_theme_mod( 'custom_logo', 0 ),
				'label' => 'Custom Logo',
			),
			'site_icon'   => array(
				'id'    => (int) get_option( 'site_icon', 0 ),
				'label' => 'Site Icon',
			),
		);

		$header = get_custom_header();
		if ( ! empty( $header->attachment_id ) ) {
			$theme_assets['custom_header'] = array(
				'id'    => (int) $header->attachment_id,
				'label' => 'Custom Header',
			);
		}

		$bg_id = get_theme_mod( 'background_image', '' );
		if ( $bg_id && is_numeric( $bg_id ) ) {
			$theme_assets['background'] = array(
				'id'    => (int) $bg_id,
				'label' => 'Background Image',
			);
		}

		foreach ( $theme_assets as $asset ) {
			if ( $asset['id'] > 0 && isset( $ids_set[ $asset['id'] ] ) ) {
				$add_usage(
					$asset['id'],
					array(
						'id'       => 0,
						'title'    => $asset['label'],
						'type'     => 'theme',
						'edit_url' => admin_url( 'customize.php' ),
						'context'  => 'theme',
					)
				);
			}
		}
	}

	/**
	 * Check Gutenberg block attributes for attachment ID references.
	 *
	 * Parses post_content with parse_blocks() and extracts IDs from
	 * block attributes like id, mediaId, ids, and mediaLink.
	 *
	 * @param int[]    $attach_ids Attachment IDs.
	 * @param array    &$used      Usage map (modified by reference via $add_usage).
	 * @param callable $add_usage  Helper to add entries.
	 */
	private static function check_block_usage( $attach_ids, &$used, $add_usage ) {

		global $wpdb;

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

		$ids_set = array_flip( $attach_ids );

     // phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$posts = $wpdb->get_results(
			"SELECT ID, post_title, post_type, post_content FROM {$wpdb->posts}
			 WHERE post_status NOT IN ('trash','auto-draft')
			 AND post_content LIKE '%<!-- wp:%'"
		);

		foreach ( $posts as $post ) {
			$blocks = parse_blocks( $post->post_content );
			$found  = self::extract_ids_from_blocks( $blocks, $ids_set );

			foreach ( $found as $aid ) {
				$add_usage(
					$aid,
					array(
						'id'       => (int) $post->ID,
						'title'    => $post->post_title ?: '(no title)',
						'type'     => $post->post_type,
						'edit_url' => get_edit_post_link( (int) $post->ID, 'raw' ),
						'context'  => 'block',
					)
				);
			}
		}
	}

	/**
	 * Recursively extract attachment IDs from parsed blocks.
	 *
	 * Checks block attributes for known keys that reference media IDs:
	 * - id (core/image, core/cover, core/audio, core/video)
	 * - mediaId (core/media-text)
	 * - ids (core/gallery)
	 *
	 * @param array $blocks  Parsed blocks from parse_blocks().
	 * @param array $ids_set Flipped array of target attachment IDs.
	 * @return int[] Unique attachment IDs found.
	 */
	private static function extract_ids_from_blocks( $blocks, $ids_set ) {
		$found = array();

		foreach ( $blocks as $block ) {
			$attrs = $block['attrs'] ?? array();

			foreach ( array( 'id', 'mediaId' ) as $key ) {
				if ( ! empty( $attrs[ $key ] ) && is_numeric( $attrs[ $key ] ) ) {
					$id = (int) $attrs[ $key ];
					if ( isset( $ids_set[ $id ] ) ) {
						$found[ $id ] = $id;
					}
				}
			}

			if ( ! empty( $attrs['ids'] ) && is_array( $attrs['ids'] ) ) {
				foreach ( $attrs['ids'] as $id ) {
					$id = (int) $id;
					if ( $id > 0 && isset( $ids_set[ $id ] ) ) {
						$found[ $id ] = $id;
					}
				}
			}

			if ( ! empty( $block['innerBlocks'] ) ) {
				$inner = self::extract_ids_from_blocks( $block['innerBlocks'], $ids_set );
				$found = $found + $inner;
			}
		}

		return $found;
	}

	/**
	 * Public wrapper: derive sizes data for a single attachment by inspecting
	 * disk reality. Used after trash → restore so the results table reflects
	 * which thumbs are back vs still trashed.
	 *
	 * @param int $attachment_id Attachment post ID.
	 * @return array Sizes array (same shape as scan-time output) or empty array on failure.
	 */
	public static function derive_sizes_for_attachment( $attachment_id ) {
		$rel_path = get_post_meta( $attachment_id, '_wp_attached_file', true );
		if ( ! $rel_path ) {
			return array();
		}

		$upload_dir = wp_upload_dir();
		$base_dir   = $upload_dir['basedir'];

		$year  = '';
		$month = '';
		$parts = explode( '/', $rel_path );
		if ( count( $parts ) >= 3 ) {
			$year  = $parts[ count( $parts ) - 3 ];
			$month = $parts[ count( $parts ) - 2 ];
		}

		$meta             = wp_get_attachment_metadata( $attachment_id );
		$registered_sizes = wp_get_registered_image_subsizes();

		return self::check_thumbnail_sizes( $meta, $registered_sizes, $base_dir, $year, $month );
	}

	/**
	 * Check thumbnail sizes for an attachment.
	 *
	 * @param array|false $meta             Attachment metadata.
	 * @param array       $registered_sizes Output of wp_get_registered_image_subsizes().
	 * @param string      $base_dir         Uploads base directory.
	 * @param string      $year             Year folder.
	 * @param string      $month            Month folder.
	 * @return array Array of [ 'label' => string, 'status' => 'ok'|'missing'|'pending', 'file_size' => int ].
	 */
	private static function check_thumbnail_sizes( $meta, $registered_sizes, $base_dir, $year, $month ) {
		$sizes  = array();
		$folder = $base_dir . '/' . $year . '/' . $month;

		if ( empty( $meta ) || ! is_array( $meta ) ) {
			return $sizes;
		}

		$orig_width  = isset( $meta['width'] ) ? (int) $meta['width'] : 0;
		$orig_height = isset( $meta['height'] ) ? (int) $meta['height'] : 0;
		$meta_sizes  = isset( $meta['sizes'] ) ? $meta['sizes'] : array();
		$disabled    = Sizes::get_disabled();

		foreach ( $registered_sizes as $name => $size_data ) {
			$target_w = (int) $size_data['width'];
			$target_h = (int) $size_data['height'];

			if ( $target_w > 0 && $orig_width < $target_w && $target_h > 0 && $orig_height < $target_h ) {
				continue;
			}

			$actual_w = isset( $meta_sizes[ $name ]['width'] ) ? (int) $meta_sizes[ $name ]['width'] : $target_w;
			$actual_h = isset( $meta_sizes[ $name ]['height'] ) ? (int) $meta_sizes[ $name ]['height'] : $target_h;

			$dim_label = '';
			if ( $actual_w && $actual_h ) {
				$dim_label = " ({$actual_w}\u{00d7}{$actual_h})";
			} elseif ( $actual_w ) {
				$dim_label = " ({$actual_w}\u{00d7}auto)";
			} elseif ( $actual_h ) {
				$dim_label = " (auto\u{00d7}{$actual_h})";
			}

			$label     = Sizes::format_name( $name ) . $dim_label;
			$file_size = 0;

			$is_disabled = in_array( $name, $disabled, true );

			if ( isset( $meta_sizes[ $name ] ) ) {
				$thumb_file  = $folder . '/' . $meta_sizes[ $name ]['file'];
				$file_exists = file_exists( $thumb_file );
				$file_size   = $file_exists ? filesize( $thumb_file ) : 0;

				if ( $is_disabled && $file_exists ) {

					$status = 'pending';
				} elseif ( $file_exists ) {
					$status = 'ok';
				} else {
					$status = 'missing';
				}

				$sizes[] = array(
					'name'      => $name,
					'label'     => $label,
					'status'    => $status,
					'file_size' => $file_size,
				);
			} elseif ( $is_disabled ) {

				continue;
			} else {
				$sizes[] = array(
					'name'      => $name,
					'label'     => $label,
					'status'    => 'missing',
					'file_size' => 0,
				);
			}
		}

		return $sizes;
	}

	/**
	 * Build an index of all known thumbnail file paths from attachment metadata.
	 *
	 * This lets us skip derivative files (e.g. image-300x200.jpg) during the folder scan
	 * so we only process originals.
	 *
	 * @return array Map of relative_path => true.
	 */
	private static function build_thumbnail_index() {
		$cached = get_transient( self::cache_key( 'thumbnail_index' ) );
		if ( false !== $cached && is_array( $cached ) ) {
			return $cached;
		}

		global $wpdb;

		$index = array();

		$rows = $wpdb->get_results(
			"SELECT pm.meta_value AS metadata, pm2.meta_value AS attached_file
			 FROM {$wpdb->postmeta} pm
			 INNER JOIN {$wpdb->postmeta} pm2
			   ON pm.post_id = pm2.post_id AND pm2.meta_key = '_wp_attached_file'
			 WHERE pm.meta_key = '_wp_attachment_metadata'"
		);

		foreach ( $rows as $row ) {
			$meta = maybe_unserialize( $row->metadata );
			if ( ! is_array( $meta ) ) {
				continue;
			}

			$dir = dirname( $row->attached_file );
			if ( '.' === $dir ) {
				$dir = '';
			} else {
				$dir .= '/';
			}

			if ( ! empty( $meta['sizes'] ) ) {
				foreach ( $meta['sizes'] as $size_data ) {
					if ( ! empty( $size_data['file'] ) ) {
						$index[ $dir . $size_data['file'] ] = true;
					}
				}
			}

			if ( ! empty( $meta['original_image'] ) ) {
				$index[ $dir . $meta['original_image'] ] = true;
			}
		}

		set_transient( self::cache_key( 'thumbnail_index' ), $index, self::cache_ttl() );

		return $index;
	}

	/**
	 * Get the configured batch size.
	 *
	 * @return int
	 */
	private static function get_batch_size() {

		return (int) get_option( 'cwpm_batch_size', CWPM_SCAN_BATCH_DEFAULT );
	}
}
