<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, Squiz.PHP.DiscouragedFunctions.Discouraged
// Justification: Public download AJAX endpoint. Authorization is per-gallery: a galery-scoped CSRF nonce (`ccgal_download_<id>`) plus IDOR-check via the storage adapter's sanitize_file_id() and file_in_folder() against the gallery folder before any download. If the gallery is embedded on a password-protected WordPress page, validate_request() additionally verifies post_password_required() against the referrer post-id so File-IDs and bytes are not retrievable after the page-password cookie has expired but the nonce is still within its 24h validity window. $_REQUEST['file'] / $_REQUEST['files'] go through sanitize_file_id(). ini_set() and set_time_limit() are required for streaming-ZIP downloads of original-resolution photos that can exceed PHP's default execution-time and output-compression limits.

class CCGAL_Downloads {

	public static function init() {
		add_action( 'wp_ajax_ccgal_download_single', array( __CLASS__, 'handle_single' ) );
		add_action( 'wp_ajax_nopriv_ccgal_download_single', array( __CLASS__, 'handle_single' ) );
		add_action( 'wp_ajax_ccgal_download_zip', array( __CLASS__, 'handle_zip' ) );
		add_action( 'wp_ajax_nopriv_ccgal_download_zip', array( __CLASS__, 'handle_zip' ) );
		add_action( 'wp_ajax_ccgal_download_manifest', array( __CLASS__, 'handle_manifest' ) );
		add_action( 'wp_ajax_nopriv_ccgal_download_manifest', array( __CLASS__, 'handle_manifest' ) );
		add_action( 'wp_ajax_ccgal_notify_bulk_complete', array( __CLASS__, 'handle_notify_bulk' ) );
		add_action( 'wp_ajax_nopriv_ccgal_notify_bulk_complete', array( __CLASS__, 'handle_notify_bulk' ) );
	}

	private static function validate_request() {
		$gallery_id = isset( $_REQUEST['gallery'] ) ? absint( wp_unslash( $_REQUEST['gallery'] ) ) : 0;
		if ( ! $gallery_id ) {
			status_header( 400 );
			exit( 'Missing gallery' );
		}

		$nonce = isset( $_REQUEST['nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'ccgal_download_' . $gallery_id ) ) {
			status_header( 403 );
			exit( 'Invalid nonce' );
		}

		$gallery = CCGAL_CPT::get_gallery_config( $gallery_id );
		if ( ! $gallery ) {
			status_header( 404 );
			exit( 'Gallery not found' );
		}
		// Draft / private / trashed galleries must not leak original files or ZIPs via the
		// public AJAX path (mirrors the guard in CCGAL_REST_API::serve_image()/stream_video()).
		$gallery_post = get_post( $gallery['id'] );
		if ( ! $gallery_post || 'publish' !== $gallery_post->post_status ) {
			if ( ! current_user_can( 'edit_post', $gallery['id'] ) ) {
				status_header( 403 );
				exit( 'Gallery not available' );
			}
		}
		// Admins (z. B. aus Bestellungs-Detailansicht) dürfen Dateien auch herunterladen,
		// wenn der öffentliche Download für die Galerie deaktiviert ist.
		if ( ! $gallery['allow_downloads'] && ! current_user_can( 'manage_options' ) ) {
			status_header( 403 );
			exit( 'Downloads disabled' );
		}
		// Wenn die Galerie auf einer passwortgeschützten WordPress-Seite eingebettet ist,
		// muss der Besucher das Page-Passwort-Cookie aktiv haben — sonst wären File-Listen
		// und Bytes auch nach Cookie-Ablauf bis zu 24h via gültigem Nonce erreichbar.
		// Greift für jeden referenzierenden Post, auf dem die Galerie eingebettet wurde.
		if ( ! current_user_can( 'manage_options' ) ) {
			$ref_post_id = self::resolve_referer_post_id();
			if ( $ref_post_id && post_password_required( $ref_post_id ) ) {
				status_header( 403 );
				exit( 'Gallery page is password-protected' );
			}
		}
		if ( empty( $gallery['folder_id'] ) ) {
			status_header( 400 );
			exit( 'Gallery has no storage folder' );
		}
		return $gallery;
	}

	/**
	 * Versucht aus dem Referer den Post-/Page-Kontext zu rekonstruieren, in dem die
	 * Galerie eingebettet wurde. Nicht 100 % verlässlich (Referer kann fehlen oder
	 * gefälscht sein), aber für den Page-Passwort-Check ausreichend: ein Angreifer,
	 * der eine fremde Page kompromittieren will, müsste den Referer fälschen UND
	 * einen gültigen Nonce besitzen — und der Nonce gelangt nur auf die Page, wenn
	 * das Passwort-Cookie aktiv war.
	 */
	private static function resolve_referer_post_id() {
		$ref = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
		if ( '' === $ref ) {
			return 0;
		}
		$post_id = url_to_postid( $ref );
		return $post_id ? (int) $post_id : 0;
	}

	public static function handle_single() {
		$gallery = self::validate_request();
		$adapter = CCGAL_Storage::adapter_for_gallery( $gallery );
		$file_id = isset( $_REQUEST['file'] ) ? $adapter->sanitize_file_id( wp_unslash( $_REQUEST['file'] ) ) : '';
		if ( ! $file_id ) {
			status_header( 400 );
			exit( 'Missing file' );
		}

		if ( ! $adapter->file_in_folder( $file_id, $gallery['folder_id'] ) ) {
			status_header( 403 );
			exit( 'File not in gallery' );
		}

		$meta = $adapter->get_file_meta( $file_id );
		if ( is_wp_error( $meta ) ) {
			status_header( 500 );
			exit( esc_html( $meta->get_error_message() ) );
		}

		$filename  = isset( $meta['name'] ) ? self::sanitize_filename( $meta['name'] ) : ( $file_id . '.jpg' );
		$mime_raw  = isset( $meta['mimeType'] ) ? $meta['mimeType'] : 'application/octet-stream';
		$mime      = preg_replace( '/[^a-zA-Z0-9\/+.\-]/', '', $mime_raw );
		if ( '' === $mime ) {
			$mime = 'application/octet-stream';
		}
		$size = isset( $meta['size'] ) ? (int) $meta['size'] : 0;

		self::prepare_stream_headers();
		header( 'Content-Type: ' . $mime );
		header( 'Content-Disposition: ' . self::content_disposition( $filename ) );
		if ( $size > 0 ) {
			header( 'Content-Length: ' . $size );
		}
		header( 'X-Content-Type-Options: nosniff' );

		/**
		 * Fires when a single-file download is about to be streamed to the
		 * client. Pro statistics module hooks in here to record the event.
		 * Free has no listeners — the action is a no-op there.
		 *
		 * @param int    $gallery_id Gallery post ID.
		 * @param string $file_id    Storage file ID.
		 * @param string $type       Download type (single|zip_folder|zip_selection|zip_selection_file).
		 * @param int    $count      File count (always 1 for single downloads).
		 */
		do_action( 'ccgal_download_completed', $gallery['id'], $file_id, 'single', 1 );

		$result = $adapter->stream_to_output( $file_id );
		if ( is_wp_error( $result ) ) {
			exit;
		}
		exit;
	}

	public static function handle_zip() {
		$gallery = self::validate_request();

		$mode_raw = isset( $_REQUEST['mode'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['mode'] ) ) : 'folder';
		// Erlaubt: folder (komplette Galerie), selection (manuelle Auswahl), favorites (Herz-Markierung).
		// 'favorites' wird wie 'selection' verarbeitet, bekommt aber einen eigenen Dateinamen.
		$mode = in_array( $mode_raw, array( 'folder', 'selection', 'favorites' ), true ) ? $mode_raw : 'folder';

		$files = array();

		$adapter = CCGAL_Storage::adapter_for_gallery( $gallery );
		if ( 'selection' === $mode || 'favorites' === $mode ) {
			$files = self::parse_file_ids_from_request( $adapter );
			if ( empty( $files ) ) {
				status_header( 400 );
				exit( 'No files selected' );
			}
			$track_type = 'zip_selection';
		} else {
			$mode        = 'folder';
			$track_type  = 'zip_folder';
			$page_token  = '';
			// Konsistent mit handle_manifest()-Folder-Branch. Sonst hätte ein
			// User mit >5000 Bildern beim Server-Fallback einen abgeschnittenen
			// ZIP, während der schnelle Pfad alle 25.000 enthielte.
			$hard_limit  = 25000;

			do {
				$batch = $adapter->list_folder( $gallery['folder_id'], 1000, $page_token, $gallery['sort_order'] );
				if ( is_wp_error( $batch ) ) {
					status_header( 500 );
					exit( esc_html( $batch->get_error_message() ) );
				}
				foreach ( $batch['files'] as $file ) {
					if ( isset( $file['id'] ) && CCGAL_Storage_Adapter::is_media_mime( isset( $file['mimeType'] ) ? $file['mimeType'] : '' ) ) {
						$files[] = array(
							'id'   => $adapter->sanitize_file_id( $file['id'] ),
							'name' => isset( $file['name'] ) ? (string) $file['name'] : $file['id'],
						);
					}
				}
				$page_token = isset( $batch['next_page_token'] ) ? $batch['next_page_token'] : '';
				if ( count( $files ) >= $hard_limit ) {
					break;
				}
			} while ( $page_token );

			if ( empty( $files ) ) {
				status_header( 404 );
				exit( 'Gallery is empty' );
			}
		}

		$zip_name = self::build_zip_name( $gallery, $mode, count( $files ) );

		self::prepare_stream_headers();
		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: ' . self::content_disposition( $zip_name ) );
		header( 'X-Content-Type-Options: nosniff' );

		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 0 );
		}
		ignore_user_abort( true );

		$zip = new CCGAL_Zip_Stream();

		if ( 'selection' === $mode || 'favorites' === $mode ) {
			$seen = array();
			foreach ( $files as $file_id ) {
				if ( connection_aborted() ) {
					break;
				}
				if ( isset( $seen[ $file_id ] ) ) {
					continue;
				}
				$seen[ $file_id ] = true;

				if ( ! $adapter->file_in_folder( $file_id, $gallery['folder_id'] ) ) {
					continue;
				}

				$meta = $adapter->get_file_meta( $file_id );
				if ( is_wp_error( $meta ) ) {
					continue;
				}
				$archive_name = self::archive_entry_name( isset( $meta['name'] ) ? $meta['name'] : $file_id, $file_id );
				$zip->add_file_from_callable(
					$archive_name,
					function( $writer ) use ( $file_id, $adapter ) {
						$res = $adapter->stream_to_output( $file_id, $writer );
						return ! is_wp_error( $res );
					}
				);
				do_action( 'ccgal_download_completed', $gallery['id'], $file_id, 'zip_selection_file', 1 );
				@flush();
			}
		} else {
			$seen_names = array();
			foreach ( $files as $file ) {
				if ( connection_aborted() ) {
					break;
				}
				$file_id      = $file['id'];
				$archive_name = self::archive_entry_name( $file['name'], $file_id );

				if ( isset( $seen_names[ $archive_name ] ) ) {
					$seen_names[ $archive_name ]++;
					$dot  = strrpos( $archive_name, '.' );
					$base = false !== $dot ? substr( $archive_name, 0, $dot ) : $archive_name;
					$ext  = false !== $dot ? substr( $archive_name, $dot ) : '';
					$archive_name = $base . '_' . $seen_names[ $archive_name ] . $ext;
				} else {
					$seen_names[ $archive_name ] = 0;
				}

				$zip->add_file_from_callable(
					$archive_name,
					function( $writer ) use ( $file_id, $adapter ) {
						$res = $adapter->stream_to_output( $file_id, $writer );
						return ! is_wp_error( $res );
					}
				);
				@flush();
			}
		}

		$zip->finish();

		do_action( 'ccgal_download_completed', $gallery['id'], null, $track_type, count( $files ) );
		exit;
	}

	/**
	 * Liefert die Datei-Liste einer Galerie als JSON. Vom Frontend-Bulk-Downloader
	 * (FSA / Browser-ZIP) verwendet, um vorher zu wissen, welche Dateien zu fetchen
	 * sind und wie groß sie sind. Kein Bytes-Transfer — reine Listen-Antwort.
	 */
	public static function handle_manifest() {
		$gallery = self::validate_request();

		$mode_raw = isset( $_REQUEST['mode'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['mode'] ) ) : 'folder';
		$mode     = in_array( $mode_raw, array( 'folder', 'selection', 'favorites' ), true ) ? $mode_raw : 'folder';

		$adapter = CCGAL_Storage::adapter_for_gallery( $gallery );

		if ( 'selection' === $mode || 'favorites' === $mode ) {
			$clean_ids = self::parse_file_ids_from_request( $adapter );
			if ( empty( $clean_ids ) ) {
				wp_send_json_error( array( 'message' => 'No files selected' ), 400 );
				return;
			}

			// Pro File ein get_file_meta()-API-Call. Bei großen Selektionen kann
			// das die PHP-Default-Execution-Time (30s) sprengen, deshalb explizit
			// aufheben — analog zu handle_zip.
			if ( function_exists( 'set_time_limit' ) ) {
				@set_time_limit( 0 );
			}

			$files      = array();
			$total_size = 0;
			foreach ( $clean_ids as $file_id ) {
				if ( ! $adapter->file_in_folder( $file_id, $gallery['folder_id'] ) ) {
					continue;
				}
				$meta = $adapter->get_file_meta( $file_id );
				if ( is_wp_error( $meta ) ) {
					continue;
				}
				$size = isset( $meta['size'] ) ? (int) $meta['size'] : 0;
				$files[] = array(
					'id'   => $file_id,
					'name' => isset( $meta['name'] ) ? self::sanitize_filename( $meta['name'] ) : $file_id,
					'size' => $size,
				);
				$total_size += $size;
			}

			if ( empty( $files ) ) {
				wp_send_json_error( array( 'message' => 'No accessible files in selection' ), 404 );
				return;
			}

			$base = $gallery['title'] ? $gallery['title'] : $gallery['couple_name'];
			if ( '' === (string) $base ) {
				$base = 'galerie';
			}

			wp_send_json_success(
				array(
					'gallery_id'    => (int) $gallery['id'],
					'gallery_title' => $base,
					'zip_name'      => self::build_zip_name( $gallery, $mode, count( $files ) ),
					'files'         => $files,
					'total_size'    => $total_size,
					'count'         => count( $files ),
					'mode'          => $mode,
				)
			);
			return;
		}

		// Folder-Modus — komplette Galerie. Per-Gallery 60s-Cache verhindert,
		// dass wiederholte Klicks auf „Alle herunterladen" die Drive/Dropbox-
		// API-Quota auffressen.
		$cache_key = 'ccgal_manifest_' . (int) $gallery['id'];
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			wp_send_json_success( $cached );
			return;
		}

		$files      = array();
		$total_size = 0;
		$page_token = '';
		$hard_limit = 25000;

		do {
			$batch = $adapter->list_folder( $gallery['folder_id'], 1000, $page_token, $gallery['sort_order'] );
			if ( is_wp_error( $batch ) ) {
				wp_send_json_error( array( 'message' => $batch->get_error_message() ), 500 );
				return;
			}
			foreach ( $batch['files'] as $file ) {
				if ( ! isset( $file['id'] ) ) {
					continue;
				}
				if ( ! CCGAL_Storage_Adapter::is_media_mime( isset( $file['mimeType'] ) ? $file['mimeType'] : '' ) ) {
					continue;
				}
				$size = isset( $file['size'] ) ? (int) $file['size'] : 0;
				$files[] = array(
					'id'   => $adapter->sanitize_file_id( $file['id'] ),
					'name' => isset( $file['name'] ) ? self::sanitize_filename( $file['name'] ) : $file['id'],
					'size' => $size,
				);
				$total_size += $size;
			}
			$page_token = isset( $batch['next_page_token'] ) ? $batch['next_page_token'] : '';
			if ( count( $files ) >= $hard_limit ) {
				break;
			}
		} while ( $page_token );

		$base = $gallery['title'] ? $gallery['title'] : $gallery['couple_name'];
		if ( '' === (string) $base ) {
			$base = 'galerie';
		}

		$payload = array(
			'gallery_id'    => (int) $gallery['id'],
			'gallery_title' => $base,
			'zip_name'      => self::build_zip_name( $gallery, 'folder', count( $files ) ),
			'files'         => $files,
			'total_size'    => $total_size,
			'count'         => count( $files ),
			'mode'          => 'folder',
		);

		set_transient( $cache_key, $payload, 60 );

		wp_send_json_success( $payload );
	}

	/**
	 * Completion-notification endpoint fired by the frontend bulk-downloader
	 * when the client-side ZIP has finished. Fires the generic
	 * `ccgal_download_completed` action so that extension modules (e.g. a Pro
	 * statistics module) can react. The Free build has no listener attached,
	 * so this endpoint is a no-op there — it exists to keep the JS contract
	 * stable regardless of which extensions are active.
	 */
	public static function handle_notify_bulk() {
		$gallery = self::validate_request();

		$count_raw = isset( $_REQUEST['count'] ) ? absint( wp_unslash( $_REQUEST['count'] ) ) : 0;
		// Cap auf das gleiche Limit wie das Manifest — verhindert, dass ein
		// gültiger Nonce missbraucht werden kann, um Listener mit
		// fantasievollen Counts zu fluten.
		$count = max( 1, min( $count_raw, 25000 ) );

		do_action( 'ccgal_download_completed', $gallery['id'], null, 'zip_folder', $count );

		wp_send_json_success( array( 'ok' => true ) );
	}

	/**
	 * Liest die Datei-ID-Liste aus $_REQUEST['files']. Akzeptiert drei Formate
	 * (Backwards-Kompatibilität):
	 *   - JSON-Array als String: "[\"id1\",\"id2\"]" (neuer Standard, umgeht
	 *     PHP-`max_input_vars`-Cap, der bei >1000 Einträgen `files[]` cappt).
	 *   - Echtes PHP-Array via `files[]=…&files[]=…` (alte Frontend-Variante).
	 *   - Komma-getrennter String (Server-Form Fallback im alten Code-Pfad).
	 *
	 * Hard-Cap auf 5000 verhindert, dass ein Nonce-Träger den AJAX-Worker mit
	 * arbiträr vielen Drive-API-Calls beschäftigen kann.
	 */
	private static function parse_file_ids_from_request( $adapter ) {
		// `$_REQUEST['files']` can arrive either as a JSON-encoded string (the
		// frontend's primary path, avoids PHP max_input_vars) or as a `files[]`
		// PHP array. `wp_unslash` on the top level is enough for the string
		// branch; for the array branch we re-apply it to each element below
		// because PHPCS does not detect the outer call on the array path.
		$raw = isset( $_REQUEST['files'] ) ? wp_unslash( $_REQUEST['files'] ) : '';
		$ids = array();

		if ( is_string( $raw ) && '' !== $raw && '[' === $raw[0] ) {
			// Depth limit hardens against JSON-bomb payloads. We expect a flat array
			// of file-ID strings; anything deeper is rejected at parse time.
			$decoded = json_decode( $raw, true, 4 );
			if ( is_array( $decoded ) ) {
				$ids = array_map( 'wp_unslash', $decoded );
			}
		} elseif ( is_array( $raw ) ) {
			$ids = array_map( 'wp_unslash', $raw );
		} elseif ( is_string( $raw ) && '' !== $raw ) {
			$ids = explode( ',', $raw );
		}

		$clean = array();
		$seen  = array();
		foreach ( $ids as $id ) {
			$c = $adapter->sanitize_file_id( (string) $id );
			if ( '' !== $c && ! isset( $seen[ $c ] ) ) {
				$seen[ $c ] = true;
				$clean[]    = $c;
				if ( count( $clean ) >= 5000 ) {
					break;
				}
			}
		}
		return $clean;
	}

	private static function content_disposition( $filename ) {
		$ascii = preg_replace( '/[^\x20-\x7E]/', '_', (string) $filename );
		$ascii = str_replace( '"', '_', $ascii );
		if ( '' === $ascii ) {
			$ascii = 'download';
		}
		return 'attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . rawurlencode( $filename );
	}

	private static function prepare_stream_headers() {
		if ( function_exists( 'apache_setenv' ) ) {
			@apache_setenv( 'no-gzip', '1' );
		}
		@ini_set( 'zlib.output_compression', 'Off' );
		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}
		nocache_headers();
	}

	private static function sanitize_filename( $name ) {
		$name = (string) $name;
		$name = str_replace( array( '"', "\r", "\n", "\0" ), '', $name );
		$name = preg_replace( '/[\\/:*?<>|]+/', '_', $name );
		$name = trim( $name );
		if ( '' === $name ) {
			$name = 'image.jpg';
		}
		return $name;
	}

	private static function archive_entry_name( $name, $file_id ) {
		return self::sanitize_filename( $name );
	}

	private static function build_zip_name( $gallery, $mode, $count ) {
		// Galerie-Titel hat Vorrang (was der Kunde sieht); Brautpaar-Name nur als Fallback,
		// wenn der Galerie-Post keinen Titel hat. Davor war es umgekehrt — das wirkt für den
		// Kunden weniger professionell, weil im ZIP plötzlich „Anna_und_Tom.zip" landet
		// während die Site-Galerie z. B. „Hochzeit Anna & Tom 2026" heißt.
		$base = $gallery['title'] ? $gallery['title'] : $gallery['couple_name'];
		if ( '' === (string) $base ) {
			$base = 'galerie';
		}
		$base = self::sanitize_filename( $base );
		$base = str_replace( ' ', '_', $base );

		switch ( $mode ) {
			case 'favorites':
				$suffix = self::sanitize_filename( __( 'Favorites', 'customer-cloud-gallery' ) );
				return $base . '_' . $suffix . '.zip';
			case 'selection':
				$suffix = self::sanitize_filename( __( 'Selection', 'customer-cloud-gallery' ) );
				return $base . '_' . $suffix . '.zip';
			case 'folder':
			default:
				return $base . '.zip';
		}
	}
}
