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

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

/**
 * Media library integration: query filtering, folder column, upload assignment,
 * and EXIF injection for the media hover tooltip.
 *
 * @since 1.0.0
 */
class KookoaWPF_Media {

	/**
	 * Custom WP_Query var that flags an "Uncategorized" filter (i.e. attachments
	 * that have no term in the folder taxonomy). The marker carries the taxonomy
	 * slug as its value and is converted into a real "NOT EXISTS" tax_query by
	 * apply_tax_not_exists_marker() once a live WP_Query object is available.
	 *
	 * The marker travels through static query-arg arrays and is rewritten into
	 * a real tax_query only at runtime. WordPress then applies its standard
	 * NOT EXISTS taxonomy join at SQL level, with no precomputed exclusion
	 * list ever materialised in PHP.
	 *
	 * @var string
	 */
	private const TAX_NOT_EXISTS_MARKER = 'kookoawpf_tax_not_exists';

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

	/**
	 * Track which taxonomies have had their term cache primed.
	 *
	 * @var array<string, bool>
	 */
	private $term_cache_primed = array();

	/**
	 * Return the singleton instance.
	 *
	 * @return self
	 */
	public static function instance(): self {
		if ( is_null( self::$instance ) ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Register all media-related hooks.
	 */
	private function __construct() {
		// Filter media library queries.
		add_action( 'pre_get_posts', array( $this, 'filter_media_query' ) );

		// Add folder filter to media modal (grid view).
		add_filter( 'ajax_query_attachments_args', array( $this, 'filter_media_modal' ) );

		// Bulk actions.
		add_filter( 'bulk_actions-upload', array( $this, 'add_bulk_actions' ) );
		add_filter( 'handle_bulk_actions-upload', array( $this, 'handle_bulk_actions' ), 10, 3 );

		// Add folder column.
		add_filter( 'manage_media_columns', array( $this, 'add_folder_column' ) );
		add_action( 'manage_media_custom_column', array( $this, 'render_folder_column' ), 10, 2 );

		// Upload pre-filter to assign folder.
		add_filter( 'wp_handle_upload_prefilter', array( $this, 'set_upload_folder' ) );
		add_action( 'add_attachment', array( $this, 'assign_uploaded_attachment' ) );

		// Inject EXIF metadata into the attachment JS model for the hover tooltip.
		add_filter( 'wp_prepare_attachment_for_js', array( $this, 'inject_exif_data' ), 20, 3 );
	}

	/**
	 * Filter the upload.php main query by the selected folder.
	 *
	 * @param WP_Query $query The current query object.
	 */
	public function filter_media_query( $query ): void {
		// Convert any incoming TAX_NOT_EXISTS_MARKER (e.g. from filter_media_modal
		// AJAX queries) into a real "NOT EXISTS" tax_query before the early-out
		// below — this hook fires for every WP_Query, including AJAX, so the
		// upload.php gate must not skip the marker translation.
		self::apply_tax_not_exists_marker( $query );

		global $pagenow;

		if ( ! is_admin() || 'upload.php' !== $pagenow || ! $query->is_main_query() ) {
			return;
		}

		$folder = self::read_folder_from_query_string();

		if ( empty( $folder ) ) {
			return;
		}

		$taxonomy = KookoaWPF_Folders::get_taxonomy( 'attachment' );
		foreach ( self::folder_query_clause( $taxonomy, $folder ) as $key => $value ) {
			$query->set( $key, $value );
		}

		// folder_query_clause may have just set the marker on the main query;
		// rewrite it into a real tax_query before WP_Query parses its args.
		self::apply_tax_not_exists_marker( $query );
	}

	/**
	 * Filter the media modal (grid view) AJAX query by folder.
	 *
	 * Folder source priority: query array (media.js) → plugin's own query
	 * string. We deliberately do NOT trust HTTP_REFERER as a fallback —
	 * client-controlled and lets a remote page coerce the modal view.
	 *
	 * @param array<string, mixed> $query Attachment query arguments.
	 * @return array<string, mixed>
	 */
	public function filter_media_modal( $query ) {
		$folder = '';

		// Source 1: Inside the query array (Backbone props from media.js).
		// Note: WordPress whitelists the keys it accepts inside `query[]` for
		// `wp_ajax_query_attachments`, so custom keys are usually stripped before
		// they reach this filter. Kept for defense-in-depth in case WP changes.
		if ( isset( $query['kookoawpf_folder'] ) ) {
			$folder = sanitize_text_field( $query['kookoawpf_folder'] );
			unset( $query['kookoawpf_folder'] );
		}

		// Source 2: top-level POST param injected by admin.js _initMediaGridFilter
		// via $.ajaxPrefilter (since the `query[]` whitelist strips custom keys).
		// The prefilter also injects our own `kookoawpf_nonce` so the value is
		// authenticated here — wp_ajax_query_attachments itself only enforces a
		// capability check, so this nonce verification is genuine defence-in-depth.
		if (
			empty( $folder )
			&& isset( $_POST['kookoawpf_nonce'], $_POST['kookoawpf_folder'] )
			&& wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['kookoawpf_nonce'] ) ), 'kookoawpf_nonce' )
		) {
			$folder = sanitize_text_field( wp_unslash( $_POST['kookoawpf_folder'] ) );
		}

		// Source 3: the plugin's own query string. The value is read-only (we
		// never mutate state from it) and is sanitized via sanitize_text_field
		// before use. Covers non-AJAX page renders where the URL has
		// ?kookoawpf_folder=X.
		if ( empty( $folder ) ) {
			$folder = self::read_folder_from_query_string();
		}

		if ( empty( $folder ) ) {
			return $query;
		}

		$taxonomy = KookoaWPF_Folders::get_taxonomy( 'attachment' );
		foreach ( self::folder_query_clause( $taxonomy, $folder ) as $key => $value ) {
			$query[ $key ] = $value;
		}

		return $query;
	}

	/**
	 * Build a WP_Query clause that narrows attachments to a folder.
	 *
	 * Returns an associative array of WP_Query args. Two shapes are possible:
	 *
	 *  - For numeric folder IDs (regular and smart folders): an inclusive
	 *    `post__in` list resolved upfront via get_objects_in_term() or via
	 *    KookoaWPF_Dynamic::get_matching_ids().
	 *
	 *  - For the special folder 'uncategorized': the custom marker
	 *    self::TAX_NOT_EXISTS_MARKER carrying the taxonomy slug. The caller
	 *    merges the marker into the live query, then apply_tax_not_exists_marker()
	 *    rewrites it as a real "NOT EXISTS" tax_query on the WP_Query object.
	 *    WordPress's standard NOT EXISTS taxonomy semantics handle the
	 *    exclusion in the SQL JOIN — no exclusion list is materialised in PHP.
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @param string $folder   Folder identifier: 'uncategorized' or a numeric term id.
	 * @return array<string,mixed> Mapping of WP_Query key => value.
	 */
	private static function folder_query_clause( $taxonomy, $folder ) {
		if ( 'uncategorized' === $folder ) {
			return array( self::TAX_NOT_EXISTS_MARKER => $taxonomy );
		}
		if ( is_numeric( $folder ) ) {
			$folder_id = absint( $folder );

			// Smart folder: resolve to matching IDs via the dynamic query layer.
			if ( class_exists( 'KookoaWPF_Dynamic' ) && KookoaWPF_Dynamic::is_dynamic( $folder_id ) ) {
				$post_type = self::post_type_from_taxonomy( $taxonomy );
				$matching  = KookoaWPF_Dynamic::get_matching_ids( $folder_id, $post_type );
				if ( empty( $matching ) ) {
					return array( 'post__in' => array( 0 ) );
				}
				return array( 'post__in' => $matching );
			}

			$objects = get_objects_in_term( $folder_id, $taxonomy );
			if ( is_wp_error( $objects ) || empty( $objects ) ) {
				// Force the query to return zero rows when the folder is empty
				// or invalid. post__in => array( 0 ) is the canonical "no match".
				return array( 'post__in' => array( 0 ) );
			}
			return array( 'post__in' => array_map( 'intval', $objects ) );
		}
		return array();
	}

	/**
	 * Convert the TAX_NOT_EXISTS_MARKER query var (set on a live WP_Query by
	 * either filter_media_query or filter_media_modal) into a real "NOT EXISTS"
	 * tax_query on that same WP_Query object.
	 *
	 * Setting tax_query via WP_Query::set on the live object keeps the
	 * exclusion at SQL level (a single NOT EXISTS subquery), without
	 * precomputing or enumerating attachment IDs in PHP.
	 *
	 * Safe to call unconditionally: it is a no-op when the marker is missing
	 * or empty.
	 *
	 * @param WP_Query $query The live query to rewrite.
	 */
	private static function apply_tax_not_exists_marker( WP_Query $query ): void {
		$taxonomy = $query->get( self::TAX_NOT_EXISTS_MARKER );
		if ( empty( $taxonomy ) || ! is_string( $taxonomy ) ) {
			return;
		}

		$query->set( self::TAX_NOT_EXISTS_MARKER, '' );

		$not_exists_clause = array(
			'taxonomy' => $taxonomy,
			'operator' => 'NOT EXISTS',
		);

		// Combine with any tax_query already attached by WordPress core, other
		// plugins, or admin filters. The "Uncategorized" filter must always
		// narrow the result set, so wrap the existing tax_query as a nested
		// group inside an outer AND. This preserves an inner relation (e.g.
		// 'OR') without letting it widen the final result via that same OR.
		$existing = $query->get( 'tax_query' );
		if ( ! is_array( $existing ) || empty( $existing ) ) {
			$tax_query = array( $not_exists_clause );
		} else {
			$tax_query = array(
				'relation' => 'AND',
				$existing,
				$not_exists_clause,
			);
		}

		$query->set( 'tax_query', $tax_query );
	}

	/**
	 * Convert a kookoawpf_folder_* taxonomy slug back to its post type.
	 *
	 * @param string $taxonomy Taxonomy slug.
	 * @return string Post type slug (defaults to 'attachment').
	 */
	private static function post_type_from_taxonomy( $taxonomy ) {
		$pt = (string) preg_replace( '/^kookoawpf_folder_/', '', (string) $taxonomy );
		return '' === $pt ? 'attachment' : $pt;
	}

	/**
	 * Read the `kookoawpf_folder` marker from the raw QUERY_STRING. The value
	 * is purely a read-only display filter (no state mutation), sanitized via
	 * sanitize_text_field before use — it is the request equivalent of a
	 * sort/filter URL parameter and therefore does not require a nonce.
	 *
	 * @return string
	 */
	private static function read_folder_from_query_string() {
		$qs = isset( $_SERVER['QUERY_STRING'] ) ? sanitize_text_field( wp_unslash( $_SERVER['QUERY_STRING'] ) ) : '';
		if ( '' === $qs ) {
			return '';
		}
		parse_str( $qs, $parts );
		if ( ! isset( $parts['kookoawpf_folder'] ) || ! is_string( $parts['kookoawpf_folder'] ) ) {
			return '';
		}
		return sanitize_text_field( $parts['kookoawpf_folder'] );
	}

	/**
	 * Register the "Move to Folder" bulk action on upload.php.
	 *
	 * @param  string[] $actions Existing bulk actions.
	 * @return string[]
	 */
	public function add_bulk_actions( $actions ) {
		$actions['kookoawpf_move_to_folder'] = __( 'Move to Folder', 'kookoa-folders' );
		return $actions;
	}

	/**
	 * Handle the "Move to Folder" bulk action.
	 *
	 * @param  string $redirect_to Current redirect URL.
	 * @param  string $doaction    The action being processed.
	 * @param  int[]  $post_ids    Selected attachment IDs.
	 * @return string
	 */
	public function handle_bulk_actions( $redirect_to, $doaction, $post_ids ) {
		if ( 'kookoawpf_move_to_folder' !== $doaction ) {
			return $redirect_to;
		}

		// Explicit nonce + capability check (WP core also verifies 'bulk-media' in upload.php
		// before firing this hook, but we re-verify so the security contract is visible here).
		check_admin_referer( 'bulk-media' );

		if ( ! current_user_can( 'upload_files' ) ) {
			return $redirect_to;
		}

		// Bulk action submits via POST — read explicitly from $_POST instead
		// of $_REQUEST so we don't accept a GET-flavoured override.
		$folder_id = isset( $_POST['kookoawpf_target_folder'] ) ? absint( wp_unslash( $_POST['kookoawpf_target_folder'] ) ) : 0;

		if ( $folder_id && ! empty( $post_ids ) ) {
			// Filter to only attachments the current user can edit.
			$post_ids = array_values(
				array_filter(
					$post_ids,
					function ( $pid ) {
						return $pid && current_user_can( 'edit_post', (int) $pid );
					}
				)
			);
			if ( empty( $post_ids ) ) {
				return $redirect_to;
			}
			$taxonomy = 'kookoawpf_folder_attachment';
			$term     = get_term( $folder_id, $taxonomy );
			if ( $term && ! is_wp_error( $term ) ) {
				KookoaWPF_Folders::assign_items( $post_ids, $folder_id, 'attachment' );
			}
		}

		return $redirect_to;
	}

	/**
	 * Add a "Folder" column to the media list table.
	 *
	 * @param  string[] $columns Existing columns.
	 * @return string[]
	 */
	public function add_folder_column( $columns ) {
		$columns['kookoawpf_folder'] = __( 'Folder', 'kookoa-folders' );
		return $columns;
	}

	/**
	 * Render the folder name(s) in the media list column.
	 *
	 * @param string $column_name Current column slug.
	 * @param int    $post_id     Attachment ID for this row.
	 */
	public function render_folder_column( $column_name, $post_id ): void {
		if ( 'kookoawpf_folder' !== $column_name ) {
			return;
		}

		$taxonomy = KookoaWPF_Folders::get_taxonomy( 'attachment' );

		// Prime term cache for all attachments on this page (once per request).
		if ( ! isset( $this->term_cache_primed[ $taxonomy ] ) ) {
			$this->term_cache_primed[ $taxonomy ] = true;
			global $wp_query;
			if ( $wp_query && ! empty( $wp_query->posts ) ) {
				$ids = wp_list_pluck( $wp_query->posts, 'ID' );
				update_object_term_cache( $ids, 'attachment' );
			}
		}

		$terms = wp_get_object_terms( $post_id, $taxonomy );

		if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
			$names = wp_list_pluck( $terms, 'name' );
			echo esc_html( implode( ', ', $names ) );
		} else {
			echo '<span class="kookoawpf-uncategorized">' . esc_html__( 'Uncategorized', 'kookoa-folders' ) . '</span>';
		}
	}

	/**
	 * Store the target folder ID in a global before the upload is processed.
	 *
	 * @param array<string, mixed> $file The file data array from wp_handle_upload_prefilter.
	 * @return array<string, mixed>
	 */
	public function set_upload_folder( $file ) {
		if ( ! current_user_can( 'upload_files' ) ) {
			return $file;
		}

		// Verify the standard WP media upload nonce. async-upload.php and the
		// upload-attachment AJAX action both POST the 'media-form' nonce, so
		// any legitimate upload path carries it in $_POST. Read $_POST directly
		// rather than $_REQUEST to keep the source of trust explicit.
		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
		if ( ! $nonce || ! wp_verify_nonce( $nonce, 'media-form' ) ) {
			return $file;
		}

		if ( isset( $_POST['kookoawpf_upload_folder'] ) ) {
			$fid = absint( wp_unslash( $_POST['kookoawpf_upload_folder'] ) );
			// Defense in depth: only accept term IDs that actually live in our attachment taxonomy.
			if ( $fid && term_exists( $fid, 'kookoawpf_folder_attachment' ) ) {
				$GLOBALS['kookoawpf_upload_folder_id'] = $fid;
			}
		}
		return $file;
	}

	/**
	 * Assign a newly uploaded attachment to the active folder and store file size meta.
	 *
	 * @param int $post_id The new attachment post ID.
	 */
	public function assign_uploaded_attachment( $post_id ): void {
		if ( ! empty( $GLOBALS['kookoawpf_upload_folder_id'] ) ) {
			KookoaWPF_Folders::assign_items( $post_id, $GLOBALS['kookoawpf_upload_folder_id'], 'attachment' );
		}
	}

	/**
	 * Inject photography metadata (EXIF) into the attachment JS model.
	 *
	 * Runs on every media grid/modal refresh. Only injects when both the master
	 * "metadata tooltip" setting and the "show EXIF" sub-setting are enabled,
	 * and the attachment is an image with EXIF data present in image_meta.
	 * Fields are whitelisted server-side to avoid leaking GPS or copyright info.
	 *
	 * @param array<string, mixed> $response   Attachment data for JS.
	 * @param WP_Post              $attachment Attachment post object.
	 * @param array<string, mixed> $meta       Attachment metadata array.
	 * @return array<string, mixed> Modified response.
	 */
	public function inject_exif_data( $response, $attachment, $meta ) {
		// Master toggle: skip if hover tooltip itself is disabled.
		if ( 'yes' !== get_option( 'kookoawpf_show_metadata_tooltip', 'yes' ) ) {
			return $response;
		}
		// Sub toggle: skip if EXIF display is disabled.
		if ( 'yes' !== get_option( 'kookoawpf_show_exif', 'no' ) ) {
			return $response;
		}

		// Only images carry EXIF.
		if ( empty( $response['type'] ) || 'image' !== $response['type'] ) {
			return $response;
		}

		if ( ! is_array( $meta ) || empty( $meta['image_meta'] ) || ! is_array( $meta['image_meta'] ) ) {
			return $response;
		}

		$im   = $meta['image_meta'];
		$exif = array();

		// Normalize each field: strip tags/control chars and cap length at 80
		// chars to defend against misbehaving camera firmware writing garbage
		// into the EXIF. Frontend renders with .text() so XSS is already
		// prevented, but this keeps the tooltip layout sane.
		$clean_exif = function ( $raw ) {
			return mb_substr( sanitize_text_field( (string) $raw ), 0, 80 );
		};

		if ( ! empty( $im['camera'] ) ) {
			$exif['camera'] = $clean_exif( $im['camera'] );
		}
		if ( ! empty( $im['aperture'] ) ) {
			$exif['aperture'] = $clean_exif( $im['aperture'] );
		}
		if ( ! empty( $im['shutter_speed'] ) ) {
			$exif['shutter'] = $clean_exif( $im['shutter_speed'] );
		}
		if ( ! empty( $im['iso'] ) ) {
			$exif['iso'] = $clean_exif( $im['iso'] );
		}
		if ( ! empty( $im['focal_length'] ) ) {
			$exif['focal'] = $clean_exif( $im['focal_length'] );
		}

		if ( ! empty( $exif ) ) {
			$response['kookoawpfExif'] = $exif;
		}

		return $response;
	}
}
