<?php

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

/**
 * Image utility helpers for Niwa AI Admin Autonomy.
 *
 * Handles downloading, format detection, WebP conversion,
 * and sideloading images into the WordPress Media Library.
 *
 * Telegram photos arrive as JPEG (quality 80, max ~1280px).
 * Documents sent as files preserve original format.
 * All images are converted to WebP before sideloading for optimal web delivery.
 */
class Niwa_AI_Admin_Autonomy_Image_Utils {

	/**
	 * MIME → extension map used across the class.
	 */
	private static $mime_ext_map = array(
		'image/jpeg' => 'jpg',
		'image/png'  => 'png',
		'image/gif'  => 'gif',
		'image/webp' => 'webp',
		'image/bmp'  => 'bmp',
	);

	/**
	 * SSRF guard for outbound media downloads. Every hostname, including
	 * Telegram CDN hosts, must resolve exclusively to public addresses; DNS
	 * failures and private/loopback/link-local ranges fail closed.
	 *
	 * Tests inject mocked IPs via the `niwa_ai_ssrf_resolve_hostname` filter so
	 * no real DNS is required.
	 *
	 * @param string $url URL to validate.
	 * @return true|WP_Error True when allowed, WP_Error('niwa_ssrf') when blocked.
	 */
	public static function ssrf_check( $url ) {
		$result = Niwa_AI_Safe_HTTP::validate_url( $url );
		return is_wp_error( $result ) ? $result : true;
	}

	/**
	 * Download through the shared WordPress HTTP trust boundary with a hard
	 * timeout, redirect validation, TLS verification, and byte cap.
	 *
	 * @param string $url           Source URL (must already pass ssrf_check).
	 * @param int    $idle_seconds  Seconds the transfer may stay below 1 byte/sec.
	 * @param int    $max_seconds   Absolute hard cap for the entire transfer.
	 * @return string|WP_Error      Temp file path on success, WP_Error on failure.
	 */
	public static function fetch_with_idle_timeout( $url, $idle_seconds = 30, $max_seconds = 300 ) {
		$tmp = Niwa_AI_Admin_Autonomy_Temp_Files::create( 'niwa_img_' );
		if ( ! $tmp ) {
			return new WP_Error( 'niwa_img_tmpfile', 'Cannot create temp file.' );
		}
		$max_bytes = (int) apply_filters( 'niwa_ai_image_download_max_bytes', 25 * 1024 * 1024 );
		$response  = Niwa_AI_Safe_HTTP::download(
			$url,
			$tmp,
			array(
				'timeout'     => max( intval( $idle_seconds ) + 1, intval( $max_seconds ) ),
				'redirection' => 5,
			),
			$max_bytes
		);
		if ( is_wp_error( $response ) ) {
			@unlink( $tmp );
			return $response;
		}
		$status = intval( wp_remote_retrieve_response_code( $response ) );
		if ( $status < 200 || $status >= 300 ) {
			@unlink( $tmp );
			return new WP_Error( 'niwa_img_http_error', 'HTTP ' . $status . ' downloading image.' );
		}

		if ( ! is_file( $tmp ) || 0 === filesize( $tmp ) ) {
			@unlink( $tmp );
			return new WP_Error( 'niwa_img_empty_body', 'Downloaded image body is empty.' );
		}

		return $tmp;
	}

	/**
	 * Download an image from any URL (Telegram, public) to a local temp file.
	 *
	 * Pipeline:
	 *   1. SSRF guard (Openclaw parity — trust telegram.org, block private IPs).
	 *   2. Bounded WordPress HTTP streaming with per-hop redirect validation.
	 *
	 * @param string $url Image URL.
	 * @return string|WP_Error Temp file path on success, WP_Error on failure.
	 */
	public static function download_to_temp( $url ) {
		$url = esc_url_raw( trim( (string) $url ) );
		if ( '' === $url ) {
			return new WP_Error( 'niwa_img_empty_url', 'Image URL is empty.' );
		}

		// SSRF guard first — blocks private IPs for non-telegram hosts.
		$ssrf = self::ssrf_check( $url );
		if ( is_wp_error( $ssrf ) ) {
			return $ssrf;
		}

		$idle = (int) apply_filters( 'niwa_ai_image_download_idle_seconds', 30 );
		$max  = (int) apply_filters( 'niwa_ai_image_download_max_seconds', 300 );
		return self::fetch_with_idle_timeout( $url, $idle, $max );
	}

	/**
	 * Detect MIME type from file magic bytes.
	 *
	 * @param string $file_path Path to the file.
	 * @return string MIME type (e.g. 'image/webp') or '' if unknown.
	 */
	public static function detect_mime_type( $file_path ) {
		if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
			return '';
		}

		// Fallback first: magic bytes — more reliable than WP for temp files without extensions.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$fh = fopen( $file_path, 'rb' );
		if ( ! $fh ) {
			return '';
		}
		$header = fread( $fh, 16 );
		fclose( $fh );

		if ( false === $header || strlen( $header ) < 4 ) {
			return '';
		}

		// JPEG: FF D8 FF
		if ( "\xFF\xD8\xFF" === substr( $header, 0, 3 ) ) {
			return 'image/jpeg';
		}
		// PNG: 89 50 4E 47
		if ( "\x89\x50\x4E\x47" === substr( $header, 0, 4 ) ) {
			return 'image/png';
		}
		// GIF: 47 49 46 38
		if ( 'GIF8' === substr( $header, 0, 4 ) ) {
			return 'image/gif';
		}
		// WebP: 52 49 46 46 ... 57 45 42 50
		if ( 'RIFF' === substr( $header, 0, 4 ) && strlen( $header ) >= 12 && 'WEBP' === substr( $header, 8, 4 ) ) {
			return 'image/webp';
		}
		// BMP: 42 4D
		if ( 'BM' === substr( $header, 0, 2 ) ) {
			return 'image/bmp';
		}

		// PDF: 25 50 44 46 2D ("%PDF-")
		if ( '%PDF-' === substr( $header, 0, 5 ) ) {
			return 'application/pdf';
		}
		// ZIP container: 50 4B 03 04 — also covers DOCX, XLSX, .skill, EPUB, etc.
		// because they are all ZIP-packaged. Higher-level routing inspects the
		// file extension or central directory to disambiguate.
		if ( "PK\x03\x04" === substr( $header, 0, 4 ) ) {
			return 'application/zip';
		}
		// OGG container (used by Telegram voice messages).
		if ( 'OggS' === substr( $header, 0, 4 ) ) {
			return 'audio/ogg';
		}
		// MP3 with ID3v2 tag at start: "ID3"
		if ( 'ID3' === substr( $header, 0, 3 ) ) {
			return 'audio/mpeg';
		}
		// MP3 with raw MPEG audio frame sync: 11 leading bits set.
		// Byte 0 = 0xFF, byte 1 high three bits = 111. We accept 0xE0 mask which
		// covers MPEG-1, MPEG-2, and MPEG-2.5 layer III.
		if ( strlen( $header ) >= 2
			&& "\xFF" === substr( $header, 0, 1 )
			&& ( ord( substr( $header, 1, 1 ) ) & 0xE0 ) === 0xE0 ) {
			return 'audio/mpeg';
		}
		// MP4 family (and friends: M4A, MOV, 3GP). Bytes 4-7 = "ftyp".
		if ( strlen( $header ) >= 8 && 'ftyp' === substr( $header, 4, 4 ) ) {
			$brand = substr( $header, 8, 4 );
			// M4A (audio-only MP4 container) deserves an audio/* MIME.
			if ( 'M4A ' === $brand || 'M4A' === substr( $brand, 0, 3 ) ) {
				return 'audio/mp4';
			}
			return 'video/mp4';
		}

		// Last resort: WP native (depends on file extension which temp files lack).
		$check = wp_check_filetype_and_ext( $file_path, basename( $file_path ) );
		if ( ! empty( $check['type'] ) ) {
			return (string) $check['type'];
		}

		return '';
	}

	/**
	 * Retry-aware wrapper around download_to_temp().
	 *
	 * Wraps a transient-failure backoff around the existing single-shot
	 * downloader. On the last attempt, returns the original WP_Error so the
	 * caller can show a meaningful message instead of a generic "retry
	 * exhausted" text. Tests neutralize the sleep via the
	 * `niwa_ai_telegram_retry_sleep_us` filter (shared with the media
	 * fetcher's getFile retries).
	 *
	 * @param string $url      Image URL (Telegram file URL or any public URL).
	 * @param int    $attempts Maximum attempts (default 3, floor 1).
	 * @return string|WP_Error Temp file path on success, last WP_Error otherwise.
	 */
	public static function download_to_temp_with_retry( $url, $attempts = 3 ) {
		$attempts   = max( 1, intval( $attempts ) );
		$last_error = null;

		for ( $i = 0; $i < $attempts; $i++ ) {
			$result = self::download_to_temp( $url );
			if ( ! is_wp_error( $result ) ) {
				return $result;
			}

			$last_error = $result;

			// SSRF errors are deterministic — retrying never helps and just
			// burns time before the inevitable failure surfaces upstream.
			if ( 'niwa_ssrf' === $result->get_error_code() ) {
				return $result;
			}

			if ( $i + 1 < $attempts ) {
				$base   = 1000 * pow( 2, $i );
				$base   = (float) min( 4000, $base );
				$jitter = ( mt_rand( -20, 20 ) / 100.0 ) * $base;
				$delay  = max( 100, intval( $base + $jitter ) );

				$delay_us = (int) apply_filters( 'niwa_ai_telegram_retry_sleep_us', $delay * 1000, $delay );
				if ( $delay_us > 0 && function_exists( 'usleep' ) ) {
					usleep( $delay_us );
				}
			}
		}

		return $last_error;
	}

	/**
	 * Ensure a temp file has a proper extension matching its detected MIME type.
	 *
	 * WordPress media_handle_sideload relies on the file extension to determine
	 * MIME type. Temp files from download_url() often lack extensions, causing
	 * blank/broken attachments in Media Library.
	 *
	 * @param string $file_path Temp file path.
	 * @param string $mime      Detected MIME type.
	 * @return string File path with proper extension (may be renamed).
	 */
	private static function ensure_extension( $file_path, $mime ) {
		$ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );

		// Already has a valid image extension.
		$valid_exts = array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp' );
		if ( in_array( $ext, $valid_exts, true ) ) {
			return $file_path;
		}

		// Determine correct extension from MIME.
		$detected_ext = isset( self::$mime_ext_map[ $mime ] ) ? self::$mime_ext_map[ $mime ] : 'jpg';
		$new_path     = $file_path . '.' . $detected_ext;

		if ( @rename( $file_path, $new_path ) ) {
			return $new_path;
		}

		// Rename failed — copy instead.
		if ( @copy( $file_path, $new_path ) ) {
			@unlink( $file_path );
			return $new_path;
		}

		return $file_path;
	}

	// ─── FORMAT CONVERSION ──────────────────────────────────────────────

	/**
	 * Convert an image file to JPEG using wp_get_image_editor().
	 *
	 * @param string $file_path Source file path.
	 * @param int    $quality   JPEG quality 1-100. Default 82.
	 * @return string|WP_Error  Path to the new JPEG file, or WP_Error.
	 */
	public static function convert_to_jpeg( $file_path, $quality = 82 ) {
		if ( ! is_file( $file_path ) ) {
			return new WP_Error( 'niwa_img_missing', 'Source image file not found.' );
		}

		if ( ! function_exists( 'wp_get_image_editor' ) ) {
			require_once ABSPATH . 'wp-includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$editor = wp_get_image_editor( $file_path );
		if ( is_wp_error( $editor ) ) {
			// Fallback: try GD directly if available.
			return self::convert_to_jpeg_gd( $file_path, $quality );
		}

		$editor->set_quality( max( 1, min( 100, intval( $quality ) ) ) );

		$info     = pathinfo( $file_path );
		$dir      = isset( $info['dirname'] ) ? $info['dirname'] : sys_get_temp_dir();
		$basename = isset( $info['filename'] ) ? $info['filename'] : 'niwa_converted';
		$dest     = trailingslashit( $dir ) . $basename . '.jpg';

		$saved = $editor->save( $dest, 'image/jpeg' );
		if ( is_wp_error( $saved ) ) {
			return $saved;
		}

		return isset( $saved['path'] ) ? (string) $saved['path'] : $dest;
	}

	/**
	 * GD fallback for JPEG conversion.
	 *
	 * @param string $file_path Source file path.
	 * @param int    $quality   JPEG quality.
	 * @return string|WP_Error  Path to JPEG or error.
	 */
	private static function convert_to_jpeg_gd( $file_path, $quality = 82 ) {
		if ( ! function_exists( 'imagecreatefromstring' ) ) {
			return new WP_Error( 'niwa_img_no_gd', 'Neither wp_get_image_editor nor GD are available for image conversion.' );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents
		$data = file_get_contents( $file_path );
		if ( false === $data || '' === $data ) {
			return new WP_Error( 'niwa_img_read_fail', 'Cannot read source image.' );
		}

		$img = @imagecreatefromstring( $data );
		if ( false === $img ) {
			return new WP_Error( 'niwa_img_gd_fail', 'GD cannot parse the image.' );
		}

		$info     = pathinfo( $file_path );
		$dir      = isset( $info['dirname'] ) ? $info['dirname'] : sys_get_temp_dir();
		$basename = isset( $info['filename'] ) ? $info['filename'] : 'niwa_converted';
		$dest     = trailingslashit( $dir ) . $basename . '.jpg';

		$ok = imagejpeg( $img, $dest, max( 1, min( 100, intval( $quality ) ) ) );
		// imagedestroy() is deprecated in PHP 8.0+; GdImage objects are freed automatically.
		if ( PHP_VERSION_ID < 80000 ) {
			imagedestroy( $img );
		}
		$img = null;

		if ( ! $ok ) {
			return new WP_Error( 'niwa_img_gd_write', 'GD imagejpeg() failed.' );
		}

		return $dest;
	}

	/**
	 * Convert an image to WebP for optimal web delivery.
	 *
	 * Graceful: if conversion is not possible (GIF, already WebP, no server support),
	 * returns the original file path instead of an error so the pipeline never breaks.
	 *
	 * @param string $file_path Source file path.
	 * @param int    $quality   WebP quality 1-100. Default 80.
	 * @return string Path to WebP file, or original path if conversion skipped/failed.
	 */
	public static function convert_to_webp( $file_path, $quality = 80 ) {
		if ( ! is_file( $file_path ) ) {
			return $file_path;
		}

		$mime = self::detect_mime_type( $file_path );

		// Skip GIF — could be animated, WebP animation support is limited.
		if ( 'image/gif' === $mime ) {
			return $file_path;
		}
		// Already WebP — no conversion needed.
		if ( 'image/webp' === $mime ) {
			return $file_path;
		}

		if ( ! function_exists( 'wp_get_image_editor' ) ) {
			require_once ABSPATH . 'wp-includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		// Tier 1: wp_get_image_editor (Imagick or GD, whichever WP prefers).
		$editor = wp_get_image_editor( $file_path );
		if ( ! is_wp_error( $editor ) ) {
			$editor->set_quality( max( 1, min( 100, intval( $quality ) ) ) );

			$info     = pathinfo( $file_path );
			$dir      = isset( $info['dirname'] ) ? $info['dirname'] : sys_get_temp_dir();
			$basename = isset( $info['filename'] ) ? $info['filename'] : 'niwa_converted';
			$dest     = trailingslashit( $dir ) . $basename . '.webp';

			$saved = $editor->save( $dest, 'image/webp' );
			if ( ! is_wp_error( $saved ) && ! empty( $saved['path'] ) && is_file( $saved['path'] ) ) {
				return (string) $saved['path'];
			}
		}

		// Tier 2: GD fallback.
		return self::convert_to_webp_gd( $file_path, $quality );
	}

	/**
	 * GD fallback for WebP conversion.
	 *
	 * @param string $file_path Source file path.
	 * @param int    $quality   WebP quality.
	 * @return string Path to WebP or original path if GD cannot do it.
	 */
	private static function convert_to_webp_gd( $file_path, $quality = 80 ) {
		if ( ! function_exists( 'imagewebp' ) || ! function_exists( 'imagecreatefromstring' ) ) {
			// Server has no WebP GD support — gracefully return original.
			return $file_path;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_get_contents
		$data = file_get_contents( $file_path );
		if ( false === $data || '' === $data ) {
			return $file_path;
		}

		$img = @imagecreatefromstring( $data );
		if ( false === $img ) {
			return $file_path;
		}

		// Preserve alpha channel (important for PNG with transparency).
		imagesavealpha( $img, true );
		imagealphablending( $img, false );

		$info     = pathinfo( $file_path );
		$dir      = isset( $info['dirname'] ) ? $info['dirname'] : sys_get_temp_dir();
		$basename = isset( $info['filename'] ) ? $info['filename'] : 'niwa_converted';
		$dest     = trailingslashit( $dir ) . $basename . '.webp';

		$ok = imagewebp( $img, $dest, max( 1, min( 100, intval( $quality ) ) ) );

		if ( PHP_VERSION_ID < 80000 ) {
			imagedestroy( $img );
		}
		$img = null;

		if ( ! $ok || ! is_file( $dest ) ) {
			return $file_path;
		}

		return $dest;
	}

	// ─── MEDIA LIBRARY ──────────────────────────────────────────────────

	/**
	 * Sideload a local image file into the WordPress Media Library.
	 *
	 * @param string $file_path Local file path.
	 * @param string $title     Attachment title (optional).
	 * @param int    $parent_id Parent post ID (optional).
	 * @return array  { attachment_id: int, url: string } or { error: string }.
	 */
	public static function sideload_to_media_library( $file_path, $title = '', $parent_id = 0 ) {
		if ( ! is_file( $file_path ) ) {
			return array( 'error' => 'File not found for sideload.' );
		}

		if ( ! function_exists( 'media_handle_sideload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
		if ( '' !== trim( (string) $title ) ) {
			$filename = sanitize_file_name( $title ) . ( '' !== $ext ? '.' . $ext : '.jpg' );
		} else {
			$filename = basename( $file_path );
			// Ensure filename has an extension — WordPress needs it.
			if ( '' === pathinfo( $filename, PATHINFO_EXTENSION ) ) {
				$filename .= '.jpg';
			}
		}

		$file_array = array(
			'name'     => $filename,
			'tmp_name' => $file_path,
		);

		$attachment_id = media_handle_sideload( $file_array, intval( $parent_id ) );
		if ( is_wp_error( $attachment_id ) ) {
			return array( 'error' => $attachment_id->get_error_message() );
		}

		// Generate attachment metadata (subsizes, thumbnails).
		// media_handle_sideload does this internally but can skip in edge cases.
		$attached_file = get_attached_file( $attachment_id );
		if ( $attached_file ) {
			$attach_data = wp_generate_attachment_metadata( $attachment_id, $attached_file );
			if ( ! empty( $attach_data ) ) {
				wp_update_attachment_metadata( $attachment_id, $attach_data );
			}
		}

		$url = wp_get_attachment_url( $attachment_id );
		return array(
			'attachment_id' => intval( $attachment_id ),
			'url'           => $url ? (string) $url : '',
		);
	}

	// ─── TELEGRAM COMPATIBILITY ─────────────────────────────────────────

	/**
	 * Ensure image is Telegram-compatible (JPEG, PNG, or GIF) for OUTBOUND sending.
	 * If WebP, convert to JPEG. Used when Niwa sends images back to Telegram.
	 *
	 * @param string $file_path Local file path.
	 * @return string|WP_Error  Path to a Telegram-safe file, or WP_Error.
	 */
	public static function ensure_telegram_compatible( $file_path ) {
		$mime = self::detect_mime_type( $file_path );

		$safe_types = array( 'image/jpeg', 'image/png', 'image/gif' );
		if ( in_array( $mime, $safe_types, true ) ) {
			return $file_path;
		}

		// WebP, BMP, or unknown → convert to JPEG for Telegram.
		return self::convert_to_jpeg( $file_path );
	}

	// ─── FULL INGEST PIPELINE ───────────────────────────────────────────

	/**
	 * Full ingest pipeline: download → detect → fix extension → convert to WebP → sideload.
	 *
	 * Used when Niwa receives an image from Telegram — saves it permanently in WP
	 * as WebP for optimal web delivery.
	 *
	 * @param string $url       Image URL (Telegram or public).
	 * @param string $title     Attachment title (optional).
	 * @param int    $parent_id Parent post ID (optional).
	 * @return array  { attachment_id: int, url: string } or { error: string }.
	 */
	public static function ingest_from_url( $url, $title = '', $parent_id = 0 ) {
		// Phase 3 retry: a single transient HTTP glitch used to lose the image
		// outright. Now we get up to 3 attempts with backoff before giving up.
		$tmp = self::download_to_temp_with_retry( $url );
		if ( is_wp_error( $tmp ) ) {
			return array( 'error' => 'Download failed: ' . $tmp->get_error_message() );
		}

		return self::ingest_local_image( $tmp, $title, $parent_id, true /* cleanup source */ );
	}

	/**
	 * Phase 5 — sideload an already-downloaded image (typical caller:
	 * Telegram::download_inbound_media() result path) into the WP Media
	 * Library, applying magic-bytes detection, extension repair, and WebP
	 * conversion.
	 *
	 * @param string $path           Local image file path.
	 * @param string $title          Attachment title.
	 * @param int    $parent_id      Parent post id.
	 * @param bool   $cleanup_source Whether to delete the source path after sideload.
	 * @return array { attachment_id: int, url: string } or { error: string }
	 */
	public static function ingest_local_image( $path, $title = '', $parent_id = 0, $cleanup_source = false ) {
		if ( ! is_file( $path ) ) {
			return array( 'error' => 'Source image file not found: ' . (string) $path );
		}

		$mime = self::detect_mime_type( $path );
		$path = self::ensure_extension( $path, $mime );

		$sideload_path = $path;
		$converted     = self::convert_to_webp( $path );
		if ( $converted !== $path ) {
			$sideload_path = $converted;
			if ( $cleanup_source && is_file( $path ) ) {
				@unlink( $path );
			}
		}

		$webp_filter = function ( $mimes ) {
			$mimes['webp'] = 'image/webp';
			return $mimes;
		};
		add_filter( 'upload_mimes', $webp_filter );

		$result = self::sideload_to_media_library( $sideload_path, $title, $parent_id );

		remove_filter( 'upload_mimes', $webp_filter );

		if ( is_file( $sideload_path ) ) {
			@unlink( $sideload_path );
		}

		if ( ! empty( $result['error'] ) ) {
			return $result;
		}

		return array(
			'attachment_id' => intval( $result['attachment_id'] ),
			'url'           => (string) $result['url'],
		);
	}

	/**
	 * Phase 5 — persist a non-image inbound document into a stable temp area
	 * so consumers can reference the file path without juggling temp lifetimes.
	 * Files land under wp-content/uploads/niwa-ai-inbound/<sha1>/<filename>;
	 * the parent directory is sha1-bucketed to avoid filesystem fanout limits
	 * on shared hosts. A future cron sweep can clean this directory by mtime.
	 *
	 * @param string $tmp_path       Temp file produced by download.
	 * @param string $filename       Original filename (sanitized internally).
	 * @param bool   $cleanup_source Whether to delete the source temp after copy.
	 * @return array { path: string, filename: string } or { error: string }
	 */
	public static function save_inbound_document( $tmp_path, $filename = '', $cleanup_source = false ) {
		if ( ! is_file( $tmp_path ) ) {
			return array( 'error' => 'Source document file not found: ' . (string) $tmp_path );
		}

		$uploads = wp_upload_dir();
		if ( ! is_array( $uploads ) || empty( $uploads['basedir'] ) ) {
			return array( 'error' => 'WP upload directory unavailable.' );
		}

		$basedir   = trailingslashit( (string) $uploads['basedir'] ) . 'niwa-ai-inbound';
		$bucket    = sha1( (string) $tmp_path . '|' . microtime( true ) );
		$bucket    = substr( $bucket, 0, 8 );
		$dest_dir  = trailingslashit( $basedir ) . $bucket;

		if ( ! wp_mkdir_p( $dest_dir ) ) {
			return array( 'error' => 'Cannot create inbound document directory: ' . $dest_dir );
		}

		$safe_name = sanitize_file_name( '' !== trim( (string) $filename ) ? (string) $filename : basename( $tmp_path ) );
		if ( '' === $safe_name ) {
			$safe_name = 'document.bin';
		}
		$dest_path = trailingslashit( $dest_dir ) . $safe_name;

		// Avoid name collisions on the off-chance the same bucket+filename
		// already exists (shouldn't really happen given the microtime salt).
		$counter = 0;
		while ( is_file( $dest_path ) && $counter < 100 ) {
			$counter++;
			$info       = pathinfo( $safe_name );
			$base       = $info['filename'] ?? 'document';
			$ext        = isset( $info['extension'] ) ? '.' . $info['extension'] : '';
			$dest_path  = trailingslashit( $dest_dir ) . $base . '-' . $counter . $ext;
		}

		if ( ! @copy( $tmp_path, $dest_path ) ) {
			return array( 'error' => 'Cannot copy document to inbound storage: ' . $dest_path );
		}

		if ( $cleanup_source && is_file( $tmp_path ) ) {
			@unlink( $tmp_path );
		}

		return array(
			'path'     => $dest_path,
			'filename' => $safe_name,
		);
	}

	/**
	 * Extract a representative frame from a video / animation / video_note /
	 * video sticker into a JPG file so it can be sideloaded as an image and
	 * fed to the vision API. Lets the agent actually see what a GIF or short
	 * video contains rather than acknowledging metadata only.
	 *
	 * Uses ffmpeg via shell_exec when available. Picks a frame ~1/4 into the
	 * clip (or the very first frame if the clip is shorter than 1 s) so we
	 * skip leading black frames and intros without scrubbing the whole clip.
	 *
	 * @param string $video_path Local path to the source video / animation file.
	 * @return array { success: bool, path?: string, error?: string }
	 */
	public static function extract_video_frame( $video_path ) {
		$video_path = (string) $video_path;
		if ( '' === $video_path || ! is_file( $video_path ) ) {
			return array( 'success' => false, 'error' => 'video_path_missing' );
		}

		if ( ! function_exists( 'shell_exec' ) || ! function_exists( 'escapeshellarg' ) ) {
			return array( 'success' => false, 'error' => 'shell_exec_disabled' );
		}

		$ffmpeg = self::resolve_ffmpeg_binary();
		if ( '' === $ffmpeg ) {
			return array( 'success' => false, 'error' => 'ffmpeg_not_found' );
		}

		$seek = self::pick_frame_timestamp( $video_path, $ffmpeg );
		return self::ffmpeg_extract_single_frame( $video_path, $ffmpeg, $seek );
	}

	/**
	 * Extract up to N frames evenly distributed across the video so the agent
	 * sees the arc of the clip rather than just one moment. Used for longer
	 * videos where a single thumbnail loses too much. Falls back to the
	 * single-frame path on any error so callers always get at least one frame
	 * when ffmpeg is available.
	 *
	 * @param string $video_path Local path to the source video.
	 * @param int    $count      Desired number of frames (1..5; clamped).
	 * @return array { success: bool, paths?: string[], error?: string }
	 */
	public static function extract_video_frames( $video_path, $count = 3 ) {
		$count = max( 1, min( 5, intval( $count ) ) );
		if ( 1 === $count ) {
			$single = self::extract_video_frame( $video_path );
			if ( empty( $single['success'] ) || empty( $single['path'] ) ) {
				return array( 'success' => false, 'error' => isset( $single['error'] ) ? (string) $single['error'] : 'extract_failed' );
			}
			return array( 'success' => true, 'paths' => array( (string) $single['path'] ) );
		}

		if ( ! function_exists( 'shell_exec' ) || ! function_exists( 'escapeshellarg' ) ) {
			return array( 'success' => false, 'error' => 'shell_exec_disabled' );
		}
		$ffmpeg = self::resolve_ffmpeg_binary();
		if ( '' === $ffmpeg ) {
			return array( 'success' => false, 'error' => 'ffmpeg_not_found' );
		}

		$duration = self::probe_video_duration_seconds( $video_path, $ffmpeg );
		if ( $duration <= 1.0 ) {
			// Too short to bother with multiple frames — one frame is the whole
			// content. Fall back to single-frame path.
			$single = self::extract_video_frame( $video_path );
			if ( empty( $single['success'] ) || empty( $single['path'] ) ) {
				return array( 'success' => false, 'error' => isset( $single['error'] ) ? (string) $single['error'] : 'extract_failed' );
			}
			return array( 'success' => true, 'paths' => array( (string) $single['path'] ) );
		}

		// Spread $count frames inside the [5%, 95%] window so we skip the
		// title card at the very start and the cut-off at the very end.
		$start_pct = 0.05;
		$end_pct   = 0.95;
		$paths     = array();
		for ( $i = 0; $i < $count; $i++ ) {
			$pct = $count > 1
				? $start_pct + ( ( $end_pct - $start_pct ) * ( $i / ( $count - 1 ) ) )
				: 0.5;
			$ts  = max( 0.1, $duration * $pct );
			$frame = self::ffmpeg_extract_single_frame( $video_path, $ffmpeg, number_format( $ts, 3, '.', '' ) );
			if ( ! empty( $frame['success'] ) && ! empty( $frame['path'] ) ) {
				$paths[] = (string) $frame['path'];
			}
		}

		if ( empty( $paths ) ) {
			return array( 'success' => false, 'error' => 'all_extracts_failed' );
		}

		return array( 'success' => true, 'paths' => $paths );
	}

	/**
	 * Run ffmpeg for a single frame at $seek and return the JPG path.
	 */
	private static function ffmpeg_extract_single_frame( $video_path, $ffmpeg, $seek ) {
		$frame_path = Niwa_AI_Admin_Autonomy_Temp_Files::create( 'niwa-frame-' . wp_generate_password( 6, false ) . '.jpg' );
		if ( '' === $frame_path ) {
			return array( 'success' => false, 'error' => 'tempnam_failed' );
		}
		$jpg_path = $frame_path . '.jpg';
		@rename( $frame_path, $jpg_path );
		if ( ! file_exists( $jpg_path ) ) {
			$jpg_path = $frame_path;
		}

		// -y                overwrite output without prompting
		// -ss <seek>        seek to a representative timestamp (before -i = fast seek)
		// -i <input>        source video
		// -frames:v 1       extract a single frame
		// -vf scale=...     downscale large frames so vision payloads stay small
		// -q:v 4            JPG quality (1=best, 31=worst); 4 ≈ visually lossless
		$cmd = sprintf(
			'%s -y -ss %s -i %s -frames:v 1 -vf "scale=\'min(1280,iw)\':-2" -q:v 4 %s 2>&1',
			escapeshellcmd( $ffmpeg ),
			escapeshellarg( (string) $seek ),
			escapeshellarg( (string) $video_path ),
			escapeshellarg( $jpg_path )
		);

		$output = shell_exec( $cmd );

		if ( ! is_file( $jpg_path ) || filesize( $jpg_path ) <= 0 ) {
			if ( is_file( $jpg_path ) ) {
				@unlink( $jpg_path );
			}
			return array(
				'success' => false,
				'error'   => 'ffmpeg_no_output',
				'detail'  => is_string( $output ) ? trim( substr( $output, 0, 240 ) ) : '',
			);
		}

		return array( 'success' => true, 'path' => $jpg_path );
	}

	/**
	 * Read the duration of a video via ffprobe. Returns 0.0 on any failure.
	 */
	private static function probe_video_duration_seconds( $video_path, $ffmpeg ) {
		if ( ! function_exists( 'shell_exec' ) ) {
			return 0.0;
		}
		$dir     = dirname( (string) $ffmpeg );
		$ffprobe = '' !== $dir ? trailingslashit( $dir ) . 'ffprobe' : '';
		if ( '' === $ffprobe || ! is_file( $ffprobe ) || ! is_executable( $ffprobe ) ) {
			return 0.0;
		}
		$cmd = sprintf(
			'%s -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 %s 2>/dev/null',
			escapeshellcmd( $ffprobe ),
			escapeshellarg( (string) $video_path )
		);
		$out = trim( (string) @shell_exec( $cmd ) );
		return (float) $out;
	}

	/**
	 * Locate an ffmpeg binary on $PATH or one of the common managed-host
	 * install locations. Cached statically so repeated frame extracts in the
	 * same request don't re-probe every fallback.
	 */
	private static function resolve_ffmpeg_binary() {
		static $cached = null;
		if ( null !== $cached ) {
			return $cached;
		}

		$candidates = array();

		if ( function_exists( 'shell_exec' ) ) {
			$probe = trim( (string) @shell_exec( 'command -v ffmpeg 2>/dev/null' ) );
			if ( '' !== $probe ) {
				$candidates[] = $probe;
			}
		}

		// Common locations on managed hosts (Hostinger, Siteground, etc.).
		$candidates = array_merge(
			$candidates,
			array(
				'/usr/bin/ffmpeg',
				'/usr/local/bin/ffmpeg',
				'/opt/homebrew/bin/ffmpeg',
				'/opt/cpanel/ea-php82/root/usr/bin/ffmpeg',
			)
		);

		foreach ( $candidates as $candidate ) {
			$candidate = trim( (string) $candidate );
			if ( '' !== $candidate && is_file( $candidate ) && is_executable( $candidate ) ) {
				$cached = $candidate;
				return $cached;
			}
		}

		$cached = '';
		return $cached;
	}

	/**
	 * Pick a seek timestamp ~1/4 into the clip when ffprobe can read the
	 * duration, with a safe default for clips we can't inspect. Skipping the
	 * very first frame usually avoids leading black / cover frames that some
	 * encoders insert.
	 */
	private static function pick_frame_timestamp( $video_path, $ffmpeg ) {
		$duration = self::probe_video_duration_seconds( $video_path, $ffmpeg );
		if ( $duration > 1.0 ) {
			$ts = max( 0.5, min( $duration / 4.0, $duration - 0.5 ) );
			return number_format( $ts, 3, '.', '' );
		}

		// Fallback: half a second in. Shorter than this and ffmpeg will clamp
		// to the available frames anyway.
		return '0.5';
	}
}
