<?php
/**
 * Gallery REST API
 *
 * @package NextGEN Gallery
 * @subpackage REST API
 * @since 2.0.0
 */

namespace Imagely\NGG\REST\DataMappers;

use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use Imagely\NGG\DataMappers\Gallery as GalleryMapper;
use Imagely\NGG\DataMappers\DisplayType as DisplayTypeMapper;

use Imagely\NGG\DataTypes\Gallery;
use Imagely\NGG\Util\Security;
use Imagely\NGG\Util\Transient;

/**
 * Gallery REST API
 */
class GalleryREST {
	/**
	 * Sanitize per_page parameter to allow -1 for "all"
	 *
	 * @param mixed $value The value to sanitize.
	 * @return int
	 */
	public static function sanitize_per_page( $value ) {
		$int_value = (int) $value;
		// Allow -1 for "all", otherwise ensure positive
		return ( -1 === $int_value ) ? -1 : absint( $int_value );
	}

	/**
	 * Register the REST API routes
	 */
	public static function register_routes() {
		register_rest_route(
			'imagely/v1',
			'/galleries',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'get_galleries' ],
				'permission_callback' => [ self::class, 'check_read_permission' ],
				'args'                => [
					'orderby'           => [
						'type'              => 'string',
						'enum'              => [
							'gid',
							'title',
							'author',
							'is_ecommerce_enabled',
							'is_private',
							'date_created',
							'date_modified',
						],
						'default'           => 'gid',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'order'             => [
						'type'              => 'string',
						'enum'              => [ 'ASC', 'DESC' ],
						'default'           => 'ASC',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'per_page'          => [
						'type'              => 'integer',
						'default'           => 25,
						'sanitize_callback' => [ self::class, 'sanitize_per_page' ],
					],
					'page'              => [
						'type'              => 'integer',
						'default'           => 1,
						'sanitize_callback' => 'absint', // Keep absint for page (always positive)
					],
					'ecommerce_filter'  => [
						'type'              => 'string',
						'enum'              => [ 'enabled', 'disabled' ],
						'sanitize_callback' => 'sanitize_text_field',
					],
					'is_private_filter' => [
						'type'              => 'integer',
						'enum'              => [ 0, 1 ],
						'sanitize_callback' => 'absint',
					],
					'search'            => [
						'type'              => 'string',
						'description'       => 'Search galleries by title',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Get a single gallery.
		register_rest_route(
			'imagely/v1',
			'/galleries/(?P<id>\d+)',
			[
				'methods'             => 'GET',
				'callback'            => [ self::class, 'get_gallery' ],
				'permission_callback' => [ self::class, 'check_read_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		// Get multiple galleries by IDs (batch endpoint for performance).
		register_rest_route(
			'imagely/v1',
			'/galleries/batch',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'get_galleries_batch' ],
				'permission_callback' => [ self::class, 'check_read_permission' ],
				'args'                => [
					'ids' => [
						'required'          => true,
						'type'              => 'array',
						'items'             => [
							'type' => 'integer',
						],
						'sanitize_callback' => function ( $value ) {
							if ( ! is_array( $value ) ) {
								return [];
							}
							return array_map( 'absint', array_filter( $value ) );
						},
					],
				],
			]
		);

		// Create a new gallery.
		register_rest_route(
			'imagely/v1',
			'/galleries',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'create_gallery' ],
				'permission_callback' => [ self::class, 'check_create_permission' ],
				'args'                => [
					'title'   => [
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'galdesc' => [
						'type'              => 'string',
						'sanitize_callback' => 'wp_kses_post', // TODO get correct sanitize callback.
					],
					'path'    => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Update a gallery.
		register_rest_route(
			'imagely/v1',
			'/galleries/(?P<id>\d+)',
			[
				'methods'             => 'PUT',
				'callback'            => [ self::class, 'update_gallery' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'id'                    => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'name'                  => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'title'                 => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'path'                  => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'author'                => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'previewpic'            => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'pageid'                => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'galdesc'               => [
						'type'              => 'string',
						'sanitize_callback' => 'wp_kses_post',
					],
					'slug'                  => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_title',
					],
					'extras_post_id'        => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'parent_id'             => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'pricelist_id'          => [
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
					'display_type'          => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
					'display_type_settings' => [
						'type'              => 'object',
						'sanitize_callback' => [ self::class, 'sanitize_display_type_settings' ],
					],
					'external_source'       => [
						'type'              => 'object',
						'sanitize_callback' => [ self::class, 'sanitize_external_source' ],
					],
					'is_private'            => [
						'type'              => 'integer',
						'enum'              => [ 0, 1 ],
						'sanitize_callback' => 'absint',
					],
					'is_ecommerce_enabled'  => [
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					],
				],
			]
		);

		// Delete a gallery.
		register_rest_route(
			'imagely/v1',
			'/galleries/(?P<id>\d+)',
			[
				'methods'             => 'DELETE',
				'callback'            => [ self::class, 'delete_gallery' ],
				'permission_callback' => [ self::class, 'check_delete_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);

		// Scan folder for new images.
		register_rest_route(
			'imagely/v1',
			'/galleries/(?P<id>\d+)/scan-folder',
			[
				'methods'             => 'POST',
				'callback'            => [ self::class, 'scan_folder' ],
				'permission_callback' => [ self::class, 'check_edit_permission' ],
				'args'                => [
					'id' => [
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					],
				],
			]
		);
	}

	/**
	 * Check if user has permission to read galleries
	 *
	 * @return bool
	 */
	public static function check_read_permission() {
		return Security::is_allowed( 'NextGEN Gallery overview' );
	}

	/**
	 * Check if user has permission to create galleries
	 *
	 * @return bool
	 */
	public static function check_create_permission() {
		return Security::is_allowed( 'NextGEN Upload images' );
	}

	/**
	 * Check if user has permission to edit galleries
	 *
	 * @param WP_REST_Request $request Optional. The REST request object.
	 * @return bool
	 */
	public static function check_edit_permission( $request = null ) {
		if ( ! Security::is_allowed( 'NextGEN Manage gallery' ) ) {
			return false;
		}

		// If editing a specific gallery, check if user can manage it.
		if ( $request && $request->get_param( 'id' ) ) {
			$gallery = GalleryMapper::get_instance()->find( $request->get_param( 'id' ) );
			if ( $gallery ) {
				if ( get_current_user_id() !== $gallery->author && ! Security::is_allowed( 'NextGEN Manage others gallery' ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Check if user has permission to delete galleries
	 *
	 * @param WP_REST_Request $request Optional.
	 *
	 * @return bool
	 */
	public static function check_delete_permission( $request = null ) {
		return self::check_edit_permission( $request );
	}

	/**
	 * Get all galleries
	 *
	 * @param WP_REST_Request $request The REST request object.
	 * @return WP_REST_Response
	 */
	public static function get_galleries( WP_REST_Request $request ) {
		global $wpdb;
		$mapper = GalleryMapper::get_instance();

		// Get and validate order parameters.
		// Security fix (SQLi): whitelist orderby against the same enum declared in register_rest_route() args for this endpoint; unknown input falls back to 'gid' to block ORDER BY injection even if the REST enum check is bypassed.
		$allowed_orderby   = [ 'gid', 'title', 'author', 'is_ecommerce_enabled', 'is_private', 'date_created', 'date_modified' ];
		$requested_orderby = sanitize_key( (string) ( $request->get_param( 'orderby' ) ?? 'gid' ) );
		$orderby           = in_array( $requested_orderby, $allowed_orderby, true ) ? $requested_orderby : 'gid';
		// Security fix (SQLi): restrict order direction to ASC/DESC; anything else defaults to ASC.
		$order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'ASC' ) );
		$order = in_array( $order, [ 'ASC', 'DESC' ], true ) ? $order : 'ASC';

		// Get pagination parameters.
		$per_page_param = (int) $request->get_param( 'per_page' );
		// Normalize all negative values to -1 (treated as "all") for consistency.
		if ( $per_page_param < 0 ) {
			$per_page_param = -1;
		}
		// Handle -1 as "all" (WordPress standard for unlimited pagination)
		$per_page = ( -1 === $per_page_param ) ? PHP_INT_MAX : $per_page_param;
		$page     = $request->get_param( 'page' );
		$offset   = ( $page - 1 ) * $per_page;

		// Build filter conditions from request.
		$filters = self::build_filter_conditions( $request );

		// Author scope: callers without 'nextgen_edit_gallery_unowned' see only their own galleries (matches get_gallery / get_galleries_batch).
		$current_user_id   = get_current_user_id();
		$author_scope_user = Security::is_allowed( 'nextgen_edit_gallery_unowned' ) ? 0 : (int) $current_user_id;
		if ( $author_scope_user > 0 ) {
			$filters['conditions'][]    = [ 'author = %d', $author_scope_user ];
			$filters['where_clauses'][] = 'author = %d';
			$filters['params'][]        = $author_scope_user;
		}

		$cache_params  = [
			$current_user_id,
			$author_scope_user, // Distinct cache slot per scope to avoid leaking unowned-cap responses to non-cap callers.
			$orderby,
			$order,
			$per_page_param,
			$page,
			$request->get_param( 'ecommerce_filter' ),
			$request->get_param( 'is_private_filter' ),
			$request->get_param( 'search' ),
		];
		$cache_key     = Transient::create_key( 'rest_galleries', $cache_params );
		$cached_result = Transient::fetch( $cache_key, false );
		if ( $cached_result ) {
			// Normalize objects → arrays for REST response
			$response = json_decode( wp_json_encode( $cached_result['response'] ?? [] ), true );
			$result   = new WP_REST_Response( $response, 200 );
			$result->header( 'X-WP-Total', $cached_result['total_items'] ?? 0 );
			$result->header( 'X-WP-TotalPages', $cached_result['total_pages'] ?? 0 );
			return $result;
		}
		// Build the base query and apply filters.
		$query = $mapper->select();

		foreach ( $filters['conditions'] as $condition ) {
			$query->where( $condition );
		}

		// Calculate total items for pagination using the same filters.
		$table_name = $wpdb->nggallery;
		$sql        = "SELECT COUNT(*) FROM {$table_name}";

		if ( ! empty( $filters['where_clauses'] ) ) {
			$sql .= ' WHERE ' . implode( ' AND ', $filters['where_clauses'] );
		}

		if ( ! empty( $filters['params'] ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$sql = $wpdb->prepare( $sql, $filters['params'] );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared
		$total_items = (int) $wpdb->get_var( $sql );

		// Fetch current page of items.
		$query->order_by( $orderby, $order )
			->limit( $per_page, $offset );

		$galleries = $query->run_query();

		$response = [];
		foreach ( $galleries as $gallery ) {
			$response[] = self::prepare_gallery_list_item_for_response( $gallery );
		}

		$total_pages = ceil( $total_items / $per_page );
		$cache_data  = [
			'response'    => $response,
			'total_items' => $total_items,
			'total_pages' => $total_pages,
		];
		Transient::update( $cache_key, $cache_data );
		$result = new WP_REST_Response( $response, 200 );

		// Add pagination headers.
		$result->header( 'X-WP-Total', $total_items );
		$result->header( 'X-WP-TotalPages', $total_pages );

		return $result;
	}

	/**
	 * Build filter conditions from request parameters.
	 *
	 * Extracts filter logic to ensure consistency between query builder
	 * and count query.
	 *
	 * @param WP_REST_Request $request The REST request object.
	 * @return array {
	 *     Filter conditions in multiple formats.
	 *
	 *     @type array $conditions    Array of conditions for query builder.
	 *                                Each element is [ 'clause', value ].
	 *     @type array $where_clauses Array of WHERE clause strings for COUNT query.
	 *     @type array $params        Array of parameters for COUNT query.
	 * }
	 */
	private static function build_filter_conditions( WP_REST_Request $request ) {
		$conditions    = [];
		$where_clauses = [];
		$params        = [];

		if ( $request->has_param( 'ecommerce_filter' ) ) {
			$ecommerce_filter = $request->get_param( 'ecommerce_filter' );
			$is_enabled       = 'enabled' === $ecommerce_filter ? 1 : 0;

			$conditions[]    = [ 'is_ecommerce_enabled = %d', $is_enabled ];
			$where_clauses[] = 'is_ecommerce_enabled = %d';
			$params[]        = $is_enabled;
		}

		if ( $request->has_param( 'is_private_filter' ) ) {
			$is_private = (int) $request->get_param( 'is_private_filter' );

			$conditions[]    = [ 'is_private = %d', $is_private ];
			$where_clauses[] = 'is_private = %d';
			$params[]        = $is_private;
		}

		if ( $request->has_param( 'search' ) ) {
			$search_term          = $request->get_param( 'search' );
			$search_term_wildcard = '%' . $search_term . '%';

			$conditions[]    = [ 'title LIKE %s', $search_term_wildcard ];
			$where_clauses[] = 'title LIKE %s';
			$params[]        = $search_term_wildcard;
		}

		return [
			'conditions'    => $conditions,
			'where_clauses' => $where_clauses,
			'params'        => $params,
		];
	}

	/**
	 * Get a single gallery
	 *
	 * @param WP_REST_Request $request Optional. The REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_gallery( WP_REST_Request $request ) {
		$id      = $request->get_param( 'id' );
		$mapper  = GalleryMapper::get_instance();
		$gallery = $mapper->find( $id );

		if ( ! $gallery ) {
			return new WP_Error(
				'gallery_not_found',
				// translators: %d is the numeric ID of the gallery.
				sprintf( __( 'Gallery with ID %d not found', 'nggallery' ), $id ),
				[ 'status' => 404 ]
			);
		}

		// Check if user can view this gallery.
		// Users can only view galleries they own unless they have "nextgen_edit_gallery_unowned" capability.
		$current_user_id = get_current_user_id();
		$can_manage      = ( (int) $current_user_id === (int) $gallery->author ) || Security::is_allowed( 'nextgen_edit_gallery_unowned' );

		if ( ! $can_manage ) {
			return new WP_Error(
				'gallery_forbidden',
				__( 'Sorry, you do not have permission to view this gallery', 'nggallery' ),
				[ 'status' => 403 ]
			);
		}

		return new WP_REST_Response( self::prepare_gallery_for_response( $gallery ), 200 );
	}

	/**
	 * Get multiple galleries by IDs in a single batch request (performance optimization)
	 *
	 * @param WP_REST_Request $request The REST request object containing array of gallery IDs.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_galleries_batch( WP_REST_Request $request ) {
		$ids = $request->get_param( 'ids' );

		if ( empty( $ids ) || ! is_array( $ids ) ) {
			return new WP_Error(
				'invalid_ids',
				__( 'Invalid gallery IDs provided', 'nggallery' ),
				[ 'status' => 400 ]
			);
		}

		// Limit batch size to prevent abuse (max 100 galleries per request)
		if ( count( $ids ) > 100 ) {
			return new WP_Error(
				'batch_too_large',
				__( 'Maximum 100 galleries can be requested at once', 'nggallery' ),
				[ 'status' => 400 ]
			);
		}

		$mapper          = GalleryMapper::get_instance();
		$current_user_id = get_current_user_id();
		$can_edit_all    = Security::is_allowed( 'nextgen_edit_gallery_unowned' );

		$galleries = [];
		foreach ( $ids as $id ) {
			$gallery = $mapper->find( $id );

			if ( ! $gallery ) {
				// Skip galleries that don't exist instead of failing entire request
				continue;
			}

			// Check permissions for each gallery
			$can_manage = ( (int) $current_user_id === (int) $gallery->author ) || $can_edit_all;

			if ( ! $can_manage ) {
				// Skip galleries user doesn't have permission to view
				continue;
			}

			$galleries[] = self::prepare_gallery_for_response( $gallery );
		}

		return new WP_REST_Response( $galleries, 200 );
	}

	/**
	 * Create a new gallery
	 *
	 * @param WP_REST_Request $request Optional. The REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create_gallery( WP_REST_Request $request ) {
		$mapper  = GalleryMapper::get_instance();
		$gallery = new Gallery();

		$gallery->name    = sanitize_title( $request->get_param( 'title' ) );
		$gallery->title   = $request->get_param( 'title' );
		$gallery->path    = $request->get_param( 'path' );
		$gallery->author  = get_current_user_id();
		$gallery->galdesc = $request->get_param( 'galdesc' );

		try {
			$mapper->save( $gallery );
			Transient::flush( 'rest_galleries' );
			return new WP_REST_Response(
				self::prepare_gallery_for_response( $gallery ),
				201
			);
		} catch ( \Exception $e ) {
			return new WP_Error(
				'create_failed',
				$e->getMessage(),
				[ 'status' => 500 ]
			);
		}
	}

	/**
	 * Update a gallery
	 *
	 * @param WP_REST_Request $request Optional. The REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_gallery( WP_REST_Request $request ) {
		$id      = $request->get_param( 'id' );
		$mapper  = GalleryMapper::get_instance();
		$gallery = $mapper->find( $id );

		if ( ! $gallery ) {
			return new WP_Error(
				'gallery_not_found',
				// translators: %d is the numeric ID of the gallery.
				sprintf( __( 'Gallery with ID %d not found', 'nggallery' ), $id ),
				[ 'status' => 404 ]
			);
		}

		if ( $request->has_param( 'name' ) ) {
			$gallery->name = sanitize_title( $request->get_param( 'name' ) );
		}
		if ( $request->has_param( 'title' ) ) {
			$gallery->title = $request->get_param( 'title' );
		}
		if ( $request->has_param( 'path' ) ) {
			$gallery->path = $request->get_param( 'path' );
		}
		if ( $request->has_param( 'author' ) ) {
			$gallery->author = $request->get_param( 'author' );
		}
		if ( $request->has_param( 'previewpic' ) ) {
			$gallery->previewpic = $request->get_param( 'previewpic' );
		}
		if ( $request->has_param( 'pageid' ) ) {
			$gallery->pageid = $request->get_param( 'pageid' );
		}
		if ( $request->has_param( 'galdesc' ) ) {
			$gallery->galdesc = $request->get_param( 'galdesc' );
		}
		if ( $request->has_param( 'slug' ) ) {
			$gallery->slug = $request->get_param( 'slug' );
		}
		if ( $request->has_param( 'extras_post_id' ) ) {
			$gallery->extras_post_id = $request->get_param( 'extras_post_id' );
		}
		if ( $request->has_param( 'parent_id' ) ) {
			$gallery->parent_id = $request->get_param( 'parent_id' );
		}
		if ( $request->has_param( 'pricelist_id' ) ) {
			$gallery->pricelist_id = $request->get_param( 'pricelist_id' );

			// Also update the WordPress post meta for ecommerce requirements check
			if ( $gallery->extras_post_id ) {
				update_post_meta( $gallery->extras_post_id, 'pricelist_id', $request->get_param( 'pricelist_id' ) );
			}
		}
		if ( $request->has_param( 'display_type' ) ) {
			$gallery->display_type = $request->get_param( 'display_type' );
		}
		if ( $request->has_param( 'display_type_settings' ) ) {
			$gallery->display_type_settings = $request->get_param( 'display_type_settings' );
		}
		if ( $request->has_param( 'external_source' ) ) {
			$gallery->external_source = $request->get_param( 'external_source' );
		}
		if ( $request->has_param( 'is_private' ) ) {
			$gallery->is_private = (bool) $request->get_param( 'is_private' );
		}
		if ( $request->has_param( 'is_ecommerce_enabled' ) ) {
			$gallery->is_ecommerce_enabled = $request->get_param( 'is_ecommerce_enabled' );
		}

		try {
			$mapper->save( $gallery );
			Transient::flush( 'rest_galleries' );
			return new WP_REST_Response(
				[
					'gallery' => self::prepare_gallery_for_response( $gallery ),
					'message' => __( 'Gallery updated successfully', 'nggallery' ),
				],
				200
			);
		} catch ( \Exception $e ) {
			return new WP_Error(
				'update_failed',
				$e->getMessage(),
				[ 'status' => 500 ]
			);
		}
	}

	/**
	 * Delete a gallery
	 *
	 * @param WP_REST_Request $request Optional. The REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_gallery( WP_REST_Request $request ) {
		$id      = $request->get_param( 'id' );
		$mapper  = GalleryMapper::get_instance();
		$gallery = $mapper->find( $id );

		if ( ! $gallery ) {
			return new WP_Error(
				'gallery_not_found',
				// translators: %d is the numeric ID of the gallery.
				sprintf( __( 'Gallery with ID %d not found', 'nggallery' ), $id ),
				[ 'status' => 404 ]
			);
		}

		try {
			$mapper->destroy( $gallery, true );
			Transient::flush( 'rest_galleries' );
			return new WP_REST_Response(
				[
					'message' => __( 'Gallery deleted successfully', 'nggallery' ),
				],
				200
			);
		} catch ( \Exception $e ) {
			return new WP_Error(
				'delete_failed',
				$e->getMessage(),
				[ 'status' => 500 ]
			);
		}
	}

	/**
	 * Scan gallery folder for new images that were added to the filesystem.
	 *
	 * This imports any images that exist in the gallery's folder but are not
	 * yet in the database (e.g., images added via FTP).
	 *
	 * @param WP_REST_Request $request The REST request object.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function scan_folder( WP_REST_Request $request ) {
		global $wpdb;

		$id      = $request->get_param( 'id' );
		$mapper  = GalleryMapper::get_instance();
		$gallery = $mapper->find( $id );

		if ( ! $gallery ) {
			return new WP_Error(
				'gallery_not_found',
				// translators: %d is the numeric ID of the gallery.
				sprintf( __( 'Gallery with ID %d not found', 'nggallery' ), $id ),
				[ 'status' => 404 ]
			);
		}

		// Get the gallery path from the storage manager.
		$storage      = \Imagely\NGG\DataStorage\Manager::get_instance();
		$gallery_path = $storage->get_gallery_abspath( $id );

		if ( ! is_dir( $gallery_path ) ) {
			return new WP_Error(
				'folder_not_found',
				// translators: %s is the gallery folder path.
				sprintf( __( 'Gallery folder not found: %s', 'nggallery' ), $gallery_path ),
				[ 'status' => 404 ]
			);
		}

		// Scan folder for image files.
		$new_images_list = \nggAdmin::scandir( $gallery_path );

		if ( empty( $new_images_list ) ) {
			return new WP_REST_Response(
				[
					'success'        => true,
					'message'        => __( 'No images found in the gallery folder.', 'nggallery' ),
					'images_added'   => 0,
					'images_skipped' => 0,
				],
				200
			);
		}

		// Get existing images in the database for this gallery.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$old_images_list = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT `filename` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d",
				$id
			)
		);

		if ( null === $old_images_list ) {
			$old_images_list = [];
		}

		// Find new images (exist in folder but not in database).
		$new_images = array_diff( $new_images_list, $old_images_list );

		if ( empty( $new_images ) ) {
			return new WP_REST_Response(
				[
					'success'        => true,
					'message'        => __( 'No new images found. All images in the folder are already in the gallery.', 'nggallery' ),
					'images_added'   => 0,
					'images_skipped' => count( $old_images_list ),
				],
				200
			);
		}

		// Import the new images.
		$image_mapper = \Imagely\NGG\DataMappers\Image::get_instance();
		$added_count  = 0;
		$errors       = [];

		foreach ( $new_images as $filename ) {
			// Apply filter for renaming/modifying image before import.
			$filename = apply_filters( 'ngg_pre_add_new_image', $filename, $id );

			// Verify the file exists and is readable.
			$filepath = trailingslashit( $gallery_path ) . $filename;
			if ( ! file_exists( $filepath ) || ! is_readable( $filepath ) ) {
				$errors[] = sprintf(
					// translators: %s is the filename.
					__( 'File not readable: %s', 'nggallery' ),
					$filename
				);
				continue;
			}

			try {
				// Create a new image entity.
				$image             = new \Imagely\NGG\DataTypes\Image();
				$image->filename   = $filename;
				$image->galleryid  = $id;
				$image->alttext    = \Imagely\NGG\Display\I18N::mb_basename( $filename );
				$image->image_slug = sanitize_title( $image->alttext );
				$image->exclude    = 0;

				// Save the image to the database.
				$result = $image_mapper->save( $image );

				if ( $result ) {
					// Generate thumbnail for the newly imported image.
					\nggAdmin::create_thumbnail( $image );

					// Import metadata from the image file.
					\nggAdmin::import_MetaData( $image->pid );

					++$added_count;
				} else {
					$errors[] = sprintf(
						// translators: %s is the filename.
						__( 'Failed to save image: %s', 'nggallery' ),
						$filename
					);
				}
			} catch ( \Exception $e ) {
				$errors[] = sprintf(
					// translators: %1$s is the filename, %2$s is the error message.
					__( 'Error importing %1$s: %2$s', 'nggallery' ),
					$filename,
					$e->getMessage()
				);
			}
		}

		// Set gallery preview image if none is set and we added images.
		if ( $added_count > 0 && empty( $gallery->previewpic ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$first_image = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT `pid` FROM {$wpdb->nggpictures} WHERE `galleryid` = %d ORDER BY `sortorder` ASC LIMIT 1",
					$id
				)
			);
			if ( $first_image ) {
				$gallery->previewpic = (int) $first_image;
				$mapper->save( $gallery );
			}
		}

		// Build response message.
		if ( $added_count > 0 ) {
			$message = sprintf(
				// translators: %d is the number of images imported.
				_n(
					'Successfully imported %d new image.',
					'Successfully imported %d new images.',
					$added_count,
					'nggallery'
				),
				$added_count
			);
		} else {
			$message = __( 'No new images were imported.', 'nggallery' );
		}

		return new WP_REST_Response(
			[
				'success'        => true,
				'message'        => $message,
				'images_added'   => $added_count,
				'images_skipped' => count( $old_images_list ),
				'errors'         => $errors,
			],
			200
		);
	}

	/**
	 * Prepare gallery list item for API response.
	 *
	 * @param Gallery $gallery The gallery object.
	 *
	 * @return array {
	 *     Gallery data.
	 *
	 *     @type int    $id                  Gallery ID.
	 *     @type string $galleryTitle        Gallery title.
	 *     @type string $shortcode           Gallery shortcode.
	 *     @type int    $count               Number of images.
	 *     @type bool   $eCommerce           Whether eCommerce is enabled.
	 *     @type bool   $is_private          Whether the gallery is private.
	 *     @type string $thumbnail           Preview image URL.
	 *     @type string $created             Creation date in GMT.
	 *     @type string $modified            Last modified date in GMT.
	 *     @type string $displayType         Gallery display type.
	 * }
	 */
	private static function prepare_gallery_list_item_for_response( $gallery ) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$gallery->counter = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->nggpictures} WHERE galleryid = %d",
				$gallery->{$gallery->id_field}
			)
		);

		if ( $gallery->previewpic ) {
			$storage   = \Imagely\NGG\DataStorage\Manager::get_instance();
			$thumbnail = $storage->get_image_url( $gallery->previewpic, 'thumb' );
		}

		// Check if current user can manage this gallery
		// User can manage if they own it OR have "NextGEN Manage others gallery" capability
		$current_user_id = get_current_user_id();
		$can_manage      = ( (int) $current_user_id === (int) $gallery->author ) || Security::is_allowed( 'nextgen_edit_gallery_unowned' );

		return [
			'id'           => $gallery->gid,
			'galleryTitle' => $gallery->title,
			'shortcode'    => '[imagely id="' . $gallery->gid . '"]',
			'count'        => $gallery->counter,
			'eCommerce'    => $gallery->is_ecommerce_enabled,
			'is_private'   => (bool) $gallery->is_private,
			'thumbnail'    => $thumbnail ?? '',
			'created'      => $gallery->date_created,
			'modified'     => $gallery->date_modified,
			'displayType'  => $gallery->display_type,
			'canManage'    => $can_manage,
			'author'       => $gallery->author,
		];
	}

	/**
	 * Prepare gallery data for API response
	 *
	 * @param Gallery $gallery The gallery object.
	 * @return array
	 */
	private static function prepare_gallery_for_response( $gallery ) {
		global $wpdb;

		if ( $gallery->previewpic ) {
			$storage   = \Imagely\NGG\DataStorage\Manager::get_instance();
			$thumbnail = $storage->get_image_url( $gallery->previewpic, 'thumb' );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$gallery->counter = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->nggpictures} WHERE galleryid = %d",
				$gallery->{$gallery->id_field}
			)
		);

		return [
			'gid'                   => $gallery->gid,
			'name'                  => $gallery->name,
			'title'                 => $gallery->title,
			'path'                  => $gallery->path,
			'author'                => $gallery->author,
			'previewpic'            => $gallery->previewpic,
			'pageid'                => $gallery->pageid,
			'galdesc'               => $gallery->galdesc,
			'slug'                  => $gallery->slug,
			'extras_post_id'        => $gallery->extras_post_id,
			'parent_id'             => $gallery->parent_id ?? null,
			'pricelist_id'          => $gallery->pricelist_id ?? null,
			'counter'               => $gallery->counter ?? 0,
			'previewpic_url'        => $thumbnail ?? '',
			'display_type'          => $gallery->display_type ?? 'photocrati-nextgen_basic_thumbnails',
			'display_type_settings' => $gallery->display_type_settings ?? [],
			'external_source'       => $gallery->external_source ?? [],
			'is_private'            => (bool) ( $gallery->is_private ?? false ),
			'is_ecommerce_enabled'  => $gallery->is_ecommerce_enabled ?? false,
			'date_created'          => $gallery->date_created,
			'date_modified'         => $gallery->date_modified,
		];
	}

	/**
	 * Sanitize display type settings.
	 *
	 * @param array $settings The settings to sanitize.
	 * @return array
	 */
	public static function sanitize_display_type_settings( $settings ) {
		if ( ! is_array( $settings ) ) {
			return [];
		}

		$sanitized = [];
		foreach ( $settings as $display_type => $type_settings ) {
			$display_type = sanitize_text_field( $display_type );

			if ( ! is_array( $type_settings ) ) {
				continue;
			}

			$sanitized_type_settings = [];
			foreach ( $type_settings as $key => $value ) {
				$key = sanitize_text_field( $key );

				switch ( gettype( $value ) ) {
					case 'boolean':
						$sanitized_type_settings[ $key ] = (bool) $value;
						break;
					case 'integer':
						$sanitized_type_settings[ $key ] = (int) $value;
						break;
					case 'double':
						$sanitized_type_settings[ $key ] = (float) $value;
						break;
					case 'string':
						$sanitized_type_settings[ $key ] = wp_kses_post( $value );
						break;
					case 'array':
						$sanitized_type_settings[ $key ] = array_map( 'sanitize_text_field', $value );
						break;
					default:
						$sanitized_type_settings[ $key ] = null;
				}
			}
			$sanitized[ $display_type ] = $sanitized_type_settings;
		}

		return $sanitized;
	}

	/**
	 * Sanitize external source settings.
	 *
	 * @param array $settings The settings to sanitize.
	 * @return array
	 */
	public static function sanitize_external_source( $settings ) {
		if ( ! is_array( $settings ) ) {
			return [];
		}

		$allowed_types         = [ 'video', 'tiktok', 'instagram', 'dribbble', 'youtube', 'google_photos', 'dropbox' ];
		$allowed_settings_mode = [ 'default', 'custom', 'global' ];

		// Flat keys (cast rule).
		$schema = [
			'type'                     => 'string',
			'settings_mode'            => 'string',
			'show_video_controls'      => 'bool',
			'show_play_pause_controls' => 'bool',
			'autoplay_videos'          => 'bool',
			'google_photos_account'    => 'int',
		];

		// Per-integration prefixed keys — one rule per field, applied to each
		// allowed addon prefix (tiktok/instagram/dribbble).
		$per_type_fields = [
			'_account'          => 'string',
			'_number'           => 'int',
			'_link'             => 'url',
			'_link_target'      => 'int_bool',
			'_image_size'       => 'string',
			'_caption'          => 'int_bool',
			'_caption_length'   => 'int',
			'_cache'            => 'int_bool',
			'_show_play_button' => 'int_bool',
		];
		foreach ( [ 'tiktok', 'instagram', 'dribbble' ] as $prefix ) {
			foreach ( $per_type_fields as $suffix => $cast ) {
				$schema[ $prefix . $suffix ] = $cast;
			}
		}

		$filtered  = array_intersect_key( $settings, $schema );
		$sanitized = [];

		foreach ( $schema as $key => $cast ) {
			if ( ! array_key_exists( $key, $filtered ) ) {
				continue;
			}
			$value = $filtered[ $key ];

			switch ( $cast ) {
				case 'bool':
					$sanitized[ $key ] = (bool) $value;
					break;
				case 'int':
					$sanitized[ $key ] = (int) $value;
					break;
				case 'int_bool':
					$sanitized[ $key ] = ( (int) $value ) ? 1 : 0;
					break;
				case 'url':
					$sanitized[ $key ] = esc_url_raw( (string) $value );
					break;
				case 'string':
				default:
					$clean = sanitize_text_field( (string) $value );
					if ( 'type' === $key && '' !== $clean && ! in_array( $clean, $allowed_types, true ) ) {
						break;
					}
					if ( 'settings_mode' === $key && ! in_array( $clean, $allowed_settings_mode, true ) ) {
						break;
					}
					$sanitized[ $key ] = $clean;
					break;
			}
		}

		return $sanitized;
	}
}
