<?php
/**
 * KooKoa Folders — Download ZIP AJAX handlers.
 *
 * Two GET endpoints triggered from a hidden form: download_zip (a folder and
 * its sub-tree as a ZIP) and download_selected (a comma-separated list of
 * attachment IDs). Both stream the resulting archive to the browser.
 *
 * @package KookoaWPF
 */

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

/**
 * Handles ZIP download requests via AJAX.
 */
class KookoaWPF_Ajax_Download {

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

	/**
	 * Running total size for ZIP safety cap.
	 *
	 * @var int
	 */
	private $zip_total_size = 0;

	/**
	 * Running file count for ZIP safety cap.
	 *
	 * @var int
	 */
	private $zip_file_count = 0;

	/**
	 * Tracks used filenames to avoid duplicates.
	 *
	 * @var array<string, int>
	 */
	private $zip_used_names = array();

	/**
	 * Whether the ZIP was truncated due to limits.
	 *
	 * @var bool
	 */
	private $zip_truncated = false;

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

	/**
	 * Sanitize a folder name for safe use as a ZIP entry.
	 *
	 * WordPress's sanitize_file_name() does NOT block '..' — a folder literally
	 * named '..' would survive and produce a ZIP that escapes the extraction
	 * directory on the client (classic ZIP-slip). Reject any dot-only name and
	 * fall back to a deterministic 'folder-{term_id}' label.
	 *
	 * @param string $name    Raw folder name.
	 * @param int    $term_id Term ID used for the fallback label.
	 * @return string
	 */
	private static function safe_zip_name( $name, $term_id ) {
		$safe = sanitize_file_name( (string) $name );
		$safe = preg_replace( '/^\.+$/', '', $safe );
		if ( null === $safe || '' === $safe || '.' === $safe || '..' === $safe ) {
			$safe = 'folder-' . (int) $term_id;
		}
		return $safe;
	}

	/**
	 * Register AJAX actions.
	 */
	private function __construct() {
		add_action( 'wp_ajax_kookoawpf_download_zip', array( $this, 'download_zip' ) );
		add_action( 'wp_ajax_kookoawpf_download_selected', array( $this, 'download_selected' ) );
		add_action( 'kookoawpf_cleanup_tmp_zip', array( __CLASS__, 'cleanup_tmp_zip' ) );
	}

	/**
	 * Download folder contents as ZIP.
	 * Called via POST: admin-ajax.php?action=kookoawpf_download_zip with folder_id + nonce in body.
	 */
	public function download_zip(): void {
		// Nonce check first — check_ajax_referer short-circuits on failure,
		// so the $_POST reads below execute only on an authenticated request.
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );

		if ( ! current_user_can( 'upload_files' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'kookoa-folders' ) );
		}

		if ( ! kookoawpf_user_has_access() ) {
			wp_die( esc_html__( 'Permission denied.', 'kookoa-folders' ) );
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_die( esc_html__( 'ZipArchive is not available on this server.', 'kookoa-folders' ) );
		}

		$folder_id = isset( $_POST['folder_id'] ) ? absint( wp_unslash( $_POST['folder_id'] ) ) : 0;
		if ( ! $folder_id ) {
			wp_die( esc_html__( 'Invalid folder.', 'kookoa-folders' ) );
		}

		$taxonomy = KookoaWPF_Folders::get_taxonomy( 'attachment' );
		$term     = get_term( $folder_id, $taxonomy );

		if ( ! $term || is_wp_error( $term ) ) {
			wp_die( esc_html__( 'Folder not found.', 'kookoa-folders' ) );
		}

		$is_dynamic  = (bool) get_term_meta( $folder_id, 'is_dynamic', true );
		$folder_name = self::safe_zip_name( $term->name, $term->term_id );
		$tmp_file    = wp_tempnam( $folder_name . '.zip' );

		$zip = new ZipArchive();
		if ( $zip->open( $tmp_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
			wp_die( esc_html__( 'Could not create ZIP file.', 'kookoa-folders' ) );
		}

		$this->zip_total_size = 0;
		$this->zip_file_count = 0;
		$this->zip_used_names = array();
		$this->zip_truncated  = false;

		if ( $is_dynamic ) {
			// Smart folders are virtual — flatten the matching attachments into
			// the archive root instead of recursing through subfolders.
			$matching  = KookoaWPF_Dynamic::get_matching_ids( $folder_id, 'attachment' );
			$has_files = $this->zip_add_attachment_ids( $zip, $matching, '' );
		} else {
			$has_files = $this->zip_add_folder_contents( $zip, $folder_id, $taxonomy, '' );
		}

		// Tell the user the archive is incomplete instead of failing silently —
		// otherwise people using the download as a backup don't notice files
		// above the 500-file / 512 MB cap never made it in.
		if ( $this->zip_truncated ) {
			$zip->addFromString(
				'TRUNCATED.txt',
				"This archive was truncated: the source folder exceeded the safety limits\n" .
				"(500 files / 512 MB). Some files were not included.\n" .
				"Download smaller subfolders individually to get the full contents.\n"
			);
		}

		$zip->close();

		if ( ! $has_files ) {
			wp_delete_file( $tmp_file );
			wp_die( esc_html__( 'This folder is empty.', 'kookoa-folders' ) );
		}

		$this->move_to_public_and_redirect( $tmp_file, $folder_name . '.zip' );
	}

	/**
	 * Add a list of attachment IDs to the ZIP at the given path prefix.
	 *
	 * Honours the safety caps (max files / total size) and the dedup table
	 * shared across the recursion so smart-folder and regular-folder paths
	 * stay consistent.
	 *
	 * @param ZipArchive $zip  The ZIP archive object.
	 * @param int[]      $ids  Attachment post IDs to add.
	 * @param string     $path Path prefix within the ZIP.
	 * @return bool Whether any files were added.
	 */
	private function zip_add_attachment_ids( $zip, $ids, $path ) {
		$has_files = false;

		$max_files      = 500;
		$max_total_size = 512 * 1024 * 1024;

		// Hide PRO Cleanup soft-trashed attachments from the ZIP, consistent
		// with the smart-folder results, the "All Items" counters and the grid.
		// Inert in pure FREE (no _kookoawpf_cleanup_trash flag can exist).
		$ids = KookoaWPF_Folders::exclude_cleanup_trashed_ids( array_map( 'intval', (array) $ids ) );

		if ( empty( $ids ) ) {
			return false;
		}

		foreach ( (array) $ids as $att_id ) {
			$att_id = (int) $att_id;
			if ( ! $att_id ) {
				continue;
			}
			// Per-attachment capability check — consistent with Replace Media / WebP.
			if ( ! current_user_can( 'edit_post', $att_id ) ) {
				continue;
			}
			$file_path = get_attached_file( $att_id );
			if ( ! $file_path || ! file_exists( $file_path ) ) {
				continue;
			}

			++$this->zip_file_count;
			$this->zip_total_size += filesize( $file_path );

			if ( $this->zip_file_count > $max_files || $this->zip_total_size > $max_total_size ) {
				$this->zip_truncated = true;
				return $has_files;
			}

			$name     = basename( $file_path );
			$zip_path = $path . $name;

			if ( isset( $this->zip_used_names[ $zip_path ] ) ) {
				$ext  = pathinfo( $name, PATHINFO_EXTENSION );
				$base = pathinfo( $name, PATHINFO_FILENAME );
				++$this->zip_used_names[ $zip_path ];
				$name     = $base . '-' . $this->zip_used_names[ $zip_path ] . '.' . $ext;
				$zip_path = $path . $name;
			} else {
				$this->zip_used_names[ $zip_path ] = 1;
			}

			$zip->addFile( $file_path, $zip_path );
			$has_files = true;
		}

		return $has_files;
	}

	/**
	 * Recursively add folder contents to ZIP, preserving subfolder structure.
	 *
	 * @param ZipArchive $zip       The ZIP archive object.
	 * @param int        $folder_id The folder term ID.
	 * @param string     $taxonomy  The taxonomy name.
	 * @param string     $path      The current path prefix within the ZIP.
	 * @return bool Whether any files were added.
	 */
	private function zip_add_folder_contents( $zip, $folder_id, $taxonomy, $path ) {
		// Resolve folder membership via get_objects_in_term() (a single SELECT
		// on term_relationships), then fetch the matching attachments by ID.
		// This keeps the per-folder recursion predictable for ZIP generation
		// and stays on the public WordPress API throughout.
		$object_ids = get_objects_in_term( (int) $folder_id, $taxonomy );
		$ids        = ( is_wp_error( $object_ids ) || empty( $object_ids ) ) ? array() : array_map( 'intval', (array) $object_ids );

		$has_files = $this->zip_add_attachment_ids( $zip, $ids, $path );

		if ( $this->zip_truncated ) {
			return $has_files;
		}

		$children = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'parent'     => $folder_id,
				'hide_empty' => false,
			)
		);

		if ( ! is_wp_error( $children ) && ! empty( $children ) ) {
			foreach ( $children as $child ) {
				if ( $this->zip_truncated ) {
					break;
				}
				if ( get_term_meta( $child->term_id, 'is_dynamic', true ) ) {
					continue;
				}
				$child_path = $path . self::safe_zip_name( $child->name, $child->term_id ) . '/';
				$zip->addEmptyDir( $child_path );
				$child_has_files = $this->zip_add_folder_contents( $zip, $child->term_id, $taxonomy, $child_path );
				if ( $child_has_files ) {
					$has_files = true;
				}
			}
		}

		return $has_files;
	}

	/**
	 * Download selected attachments as ZIP.
	 * Called via POST: admin-ajax.php?action=kookoawpf_download_selected with ids + nonce in body.
	 */
	public function download_selected(): void {
		check_ajax_referer( 'kookoawpf_nonce', 'nonce' );

		if ( ! current_user_can( 'upload_files' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'kookoa-folders' ) );
		}

		if ( ! kookoawpf_user_has_access() ) {
			wp_die( esc_html__( 'Permission denied.', 'kookoa-folders' ) );
		}

		if ( ! class_exists( 'ZipArchive' ) ) {
			wp_die( esc_html__( 'ZipArchive is not available on this server.', 'kookoa-folders' ) );
		}

		$ids_raw = isset( $_POST['ids'] ) ? sanitize_text_field( wp_unslash( $_POST['ids'] ) ) : '';
		if ( empty( $ids_raw ) ) {
			wp_die( esc_html__( 'No items selected.', 'kookoa-folders' ) );
		}

		$ids = array_filter( array_map( 'absint', explode( ',', $ids_raw ) ) );
		if ( empty( $ids ) ) {
			wp_die( esc_html__( 'No valid items.', 'kookoa-folders' ) );
		}

		// Single file — stream directly with Content-Disposition: attachment so the
		// browser always saves the file to disk instead of deciding whether to open
		// it inline based on Content-Type (which varies by browser state/settings).
		if ( 1 === count( $ids ) ) {
			$attachment_id = reset( $ids );

			if ( get_post_type( $attachment_id ) !== 'attachment' ) {
				wp_die( esc_html__( 'No files to download.', 'kookoa-folders' ) );
			}

			if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
				wp_die( esc_html__( 'Permission denied.', 'kookoa-folders' ) );
			}

			$file_path = get_attached_file( $attachment_id );
			if ( ! $file_path || ! file_exists( $file_path ) ) {
				wp_die( esc_html__( 'No files to download.', 'kookoa-folders' ) );
			}

			$filename  = basename( $file_path );
			$mime_info = wp_check_filetype( $filename );
			$mime_type = ( $mime_info['type'] ) ? $mime_info['type'] : 'application/octet-stream';

			// Defense-in-depth: WP already sanitises uploaded filenames via
			// wp_unique_filename(), but the value reaches us through
			// get_attached_file() / basename() and lands inside a quoted
			// header value. sanitize_file_name() drops control chars (CR,
			// LF, NUL) and the quote characters that would otherwise let a
			// crafted filename break out of the Content-Disposition value
			// or inject a second header line.
			$filename = sanitize_file_name( $filename );

			// Output download headers before any content.
			header( 'Content-Type: ' . $mime_type );
			header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
			header( 'Content-Length: ' . (string) filesize( $file_path ) );
			header( 'Cache-Control: no-store' );

			// readfile() streams without buffering the whole file in PHP memory.
			// WP_Filesystem has no equivalent streaming primitive (get_contents()
			// would load the full file into RAM), so the direct call is correct here.
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
			readfile( $file_path );
			exit;
		}

		$max_files      = 500;
		$max_total_size = 512 * 1024 * 1024;

		if ( count( $ids ) > $max_files ) {
			wp_die(
				sprintf(
					/* translators: %d: maximum number of files allowed */
					esc_html__( 'Too many files selected (%d max).', 'kookoa-folders' ),
					(int) $max_files
				)
			);
		}

		$tmp_file = wp_tempnam( 'kookoawpf-selected.zip' );

		$zip = new ZipArchive();
		if ( $zip->open( $tmp_file, ZipArchive::CREATE | ZipArchive::OVERWRITE ) !== true ) {
			wp_die( esc_html__( 'Could not create ZIP file.', 'kookoa-folders' ) );
		}

		$has_files  = false;
		$used_names = array();
		$total_size = 0;

		foreach ( $ids as $attachment_id ) {
			if ( get_post_type( $attachment_id ) !== 'attachment' ) {
				continue;
			}

			// Per-attachment capability check.
			if ( ! current_user_can( 'edit_post', $attachment_id ) ) {
				continue;
			}

			$file_path = get_attached_file( $attachment_id );
			if ( ! $file_path || ! file_exists( $file_path ) ) {
				continue;
			}

			$file_size   = filesize( $file_path );
			$total_size += $file_size;

			if ( $total_size > $max_total_size ) {
				$zip->close();
				wp_delete_file( $tmp_file );
				wp_die( esc_html__( 'Total file size exceeds the 512 MB limit.', 'kookoa-folders' ) );
			}

			$name = basename( $file_path );

			if ( isset( $used_names[ $name ] ) ) {
				$ext  = pathinfo( $name, PATHINFO_EXTENSION );
				$base = pathinfo( $name, PATHINFO_FILENAME );
				++$used_names[ $name ];
				$name = $base . '-' . $used_names[ $name ] . '.' . $ext;
			} else {
				$used_names[ $name ] = 1;
			}

			$zip->addFile( $file_path, $name );
			$has_files = true;
		}

		$zip->close();

		if ( ! $has_files ) {
			wp_delete_file( $tmp_file );
			wp_die( esc_html__( 'No files to download.', 'kookoa-folders' ) );
		}

		$this->move_to_public_and_redirect( $tmp_file, 'media-' . gmdate( 'Y-m-d' ) . '.zip' );
	}

	/**
	 * Move the finished ZIP into a per-request public subdirectory under
	 * uploads/, schedule its deletion, and redirect the browser to the
	 * static URL so the web server (Apache/nginx) handles the streaming.
	 *
	 * Why this and not readfile()/fpassthru()?
	 *  - WP_Filesystem has no streaming primitive — get_contents() would
	 *    buffer multi-hundred-MB archives entirely in PHP memory.
	 *  - The web server is already optimised to serve static files, so the
	 *    actual byte stream is delivered without re-entering PHP after the
	 *    redirect.
	 *  - The directory name is a 32-char unguessable random token, scoped
	 *    to a single download, deleted by the cron event below.
	 *
	 * @param string $tmp_file      Path to the on-disk ZIP.
	 * @param string $download_name Filename presented to the browser
	 *                              (becomes the static file name on disk).
	 * @return void
	 */
	private function move_to_public_and_redirect( $tmp_file, $download_name ) {
		// WP-Cron is the primary cleanup mechanism (per-download single
		// event scheduled below), but any new download is also a chance
		// to sweep stragglers if cron is disabled / delayed / broken.
		self::prune_expired_tmp_dirs();

		$upload   = wp_upload_dir();
		$root_dir = trailingslashit( $upload['basedir'] ) . 'kookoawpf-tmp';
		$root_url = trailingslashit( $upload['baseurl'] ) . 'kookoawpf-tmp';

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		global $wp_filesystem;

		if ( ! $wp_filesystem->is_dir( $root_dir ) ) {
			wp_mkdir_p( $root_dir );
			// Silence-is-golden index, plus a tiny .htaccess that disables
			// directory listing on Apache (no-op on nginx).
			$wp_filesystem->put_contents( $root_dir . '/index.php', "<?php\n// Silence is golden.\n" );
			$wp_filesystem->put_contents( $root_dir . '/.htaccess', "Options -Indexes\n" );
		}

		$token    = wp_generate_password( 32, false, false );
		$dir_path = $root_dir . '/' . $token;
		wp_mkdir_p( $dir_path );

		$safe_name = self::safe_zip_name( $download_name, 0 );
		$dest_path = $dir_path . '/' . $safe_name;

		if ( ! $wp_filesystem->move( $tmp_file, $dest_path, true ) ) {
			wp_delete_file( $tmp_file );
			$wp_filesystem->delete( $dir_path, true );
			wp_die( esc_html__( 'Could not prepare the download.', 'kookoa-folders' ) );
		}

		// Cleanup in 1h: deletes the random subdir (and the ZIP inside).
		wp_schedule_single_event( time() + HOUR_IN_SECONDS, 'kookoawpf_cleanup_tmp_zip', array( $dir_path ) );

		wp_safe_redirect( $root_url . '/' . $token . '/' . rawurlencode( $safe_name ) );
		exit;
	}

	/**
	 * Cron callback: remove a temp download subdirectory.
	 *
	 * Wired to the `kookoawpf_cleanup_tmp_zip` action scheduled by
	 * {@see move_to_public_and_redirect()}. Restricted to paths inside
	 * uploads/kookoawpf-tmp/ so a stray scheduled arg cannot delete
	 * unrelated files.
	 *
	 * @param string $dir_path Absolute path to the per-download subdir.
	 * @return void
	 */
	public static function cleanup_tmp_zip( $dir_path ) {
		if ( ! is_string( $dir_path ) || '' === $dir_path ) {
			return;
		}
		$upload   = wp_upload_dir();
		$root_dir = trailingslashit( $upload['basedir'] ) . 'kookoawpf-tmp';

		// Resolve symlinks and any "../" segments on both sides BEFORE the
		// containment check. A plain string-prefix guard would accept a
		// crafted path like ".../kookoawpf-tmp/../../etc" because the
		// literal characters still start with the tmp root; realpath()
		// folds the traversal away and lets us compare the canonical form.
		$real_root = realpath( $root_dir );
		$real_path = realpath( $dir_path );
		if ( false === $real_root || false === $real_path ) {
			return;
		}
		$real_root = wp_normalize_path( $real_root );
		$real_path = wp_normalize_path( $real_path );
		if ( 0 !== strpos( $real_path . '/', trailingslashit( $real_root ) ) ) {
			return;
		}

		if ( ! function_exists( 'WP_Filesystem' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		WP_Filesystem();
		global $wp_filesystem;
		// Delete the resolved path, not the caller's literal — a symlink
		// inside the tmp root that points elsewhere will have been
		// rejected by the realpath guard above.
		if ( $wp_filesystem->is_dir( $real_path ) ) {
			$wp_filesystem->delete( $real_path, true );
		}
	}

	/**
	 * Opportunistic backup cleanup: delete per-download subdirectories
	 * under uploads/kookoawpf-tmp/ that are older than 2 hours.
	 *
	 * The per-download `wp_schedule_single_event` (T + 1 hour) is the
	 * primary mechanism — when WP-Cron is healthy, dirs are gone within
	 * ~1 hour. This sweep covers sites where WP-Cron is disabled,
	 * throttled, or has fallen behind: a 1-hour transient throttles the
	 * scan to at most once per hour, and the 2-hour age threshold means
	 * a recent download (still mid-redirect) is never pruned.
	 *
	 * Path guards (layered):
	 *   - this method only enumerates direct children of uploads/kookoawpf-tmp/
	 *     and skips anything that isn't a directory, so the index.php /
	 *     .htaccess infrastructure files at the root are never touched;
	 *   - the actual delete is delegated to cleanup_tmp_zip(), which runs
	 *     realpath() + wp_normalize_path() containment on the resolved path
	 *     before unlinking. That keeps a single canonical guard for "what is
	 *     safe to wipe under the tmp root" — if it's ever tightened further,
	 *     this sweep inherits the new check for free.
	 *
	 * @return void
	 */
	public static function prune_expired_tmp_dirs() {
		if ( get_transient( 'kookoawpf_tmp_zip_last_prune' ) ) {
			return;
		}
		set_transient( 'kookoawpf_tmp_zip_last_prune', 1, HOUR_IN_SECONDS );

		$upload   = wp_upload_dir();
		$root_dir = trailingslashit( $upload['basedir'] ) . 'kookoawpf-tmp';
		if ( ! is_dir( $root_dir ) ) {
			return;
		}

		$entries = scandir( $root_dir );
		if ( ! is_array( $entries ) ) {
			return;
		}

		$threshold = time() - ( 2 * HOUR_IN_SECONDS );
		foreach ( $entries as $entry ) {
			if ( '.' === $entry || '..' === $entry ) {
				continue;
			}
			$path = $root_dir . '/' . $entry;
			// Must be a per-download subdir directly under the root.
			// Excludes the index.php / .htaccess infrastructure files and
			// blocks any traversal that landed outside the tmp tree.
			if ( ! is_dir( $path ) || dirname( $path ) !== $root_dir ) {
				continue;
			}
			$mtime = filemtime( $path );
			if ( false === $mtime || $mtime > $threshold ) {
				continue;
			}
			// Delegate the actual delete so the realpath() + wp_normalize_path()
			// containment guard inside cleanup_tmp_zip() is the single source
			// of truth for "what is safe to wipe under the tmp root".
			self::cleanup_tmp_zip( $path );
		}
	}
}
