<?php
/**
 * Ability: wp-agent/upload-media
 *
 * Lädt ein Bild von einer öffentlichen http(s)-URL herunter und legt es per
 * media_handle_sideload() als neuen Medienbeitrag an. Der Alt-Text ist
 * Pflicht und wird als eigener, zurückgelesener Schritt gesetzt (Muster aus
 * set-seo-meta.php: schreiben, zurücklesen, verified true/false).
 *
 * SSRF-Schutz (VOR jedem Download-Versuch, auch bei jedem Redirect-Hop):
 * nur http/https erlaubt, kein file:// oder andere Schemata; localhost- und
 * .localhost-Hostnamen werden literal blockiert; direkte IP-Literale werden
 * sofort geprüft; jeder andere Hostname wird per DNS aufgelöst und JEDE
 * zurückgegebene Adresse (A- und AAAA-Records) gegen private/Loopback/
 * Link-Local-Bereiche geprüft, fail-closed bei nicht auflösbaren Hosts.
 * Der Download wird anschließend an genau eine der geprüften DNS-Adressen
 * gepinnt. Host-Header, TLS-SNI und Zertifikatsprüfung verwenden weiterhin
 * den ursprünglichen Hostnamen. Dadurch findet zwischen Prüfung und
 * Verbindung keine zweite, angreifbare DNS-Auflösung statt.
 *
 * wp_remote_get() folgt Redirects hier bewusst NICHT selbst (redirection=0);
 * stattdessen wird jeder Redirect-Hop einzeln geholt und seine Ziel-URL vor
 * dem nächsten Fetch erneut durch dieselbe SSRF-Prüfung geschickt (maximal
 * WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS Hops). WordPress' eingebauter
 * reject_unsafe_urls-Schutz läuft zusätzlich, ersetzt die eigene Prüfung
 * aber nicht: wp_http_validate_url() nutzt nur gethostbyname() und validiert
 * damit keine AAAA-Records. Die eigene Prüfung deckt IPv4 und IPv6 ab und
 * wird bei jedem Redirect-Ziel wiederholt.
 *
 * Spiegelt bewusst das Konzept aus packages/wp-agent/src/safety/ssrf.mjs
 * (assertAllowedUrlResolved) und packages/wp-agent/src/wp/media.mjs
 * (requestPinned), portiert auf PHP. Die IP-Prüfung nutzt eine explizite
 * CIDR-Liste, weil FILTER_FLAG_NO_RES_RANGE mehrere nicht öffentlich
 * routbare Bereiche (unter anderem CGNAT, Dokumentations-, Benchmark- und
 * Multicast-Netze) nicht vollständig abdeckt.
 *
 * Bekannter, bewusst in Kauf genommener Randfall: hat die Quell-URL keine
 * erkennbare Dateiendung (z. B. eine gehashte CDN-URL ohne Pfad-Endung),
 * kann wp_check_filetype_and_ext() innerhalb von media_handle_sideload() den
 * Dateityp nicht zuordnen und den Upload ablehnen, obwohl der Inhalt ein
 * gültiges Bild ist. Eine Content-Type-basierte Endungs-Korrektur (wie sie
 * WordPress' eigenes download_url() für genau diesen Fall mitbringt) wurde
 * hier bewusst weggelassen, um die Ability schlank zu halten; siehe die
 * Notizen der Integrationsphase für einen möglichen Nachzieh-Vorschlag.
 *
 * @package WPAgentCompanion
 */

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

if ( ! defined( 'WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS' ) ) {
	// Maximal so viele Redirect-Hops wird gefolgt, jeder erneut SSRF-geprüft.
	define( 'WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS', 3 );
}

if ( ! defined( 'WPAGENT_UPLOAD_MEDIA_MAX_BYTES' ) ) {
	// Begrenzung gegen Speicher-/Platten-DoS durch beliebig große Antworten.
	define( 'WPAGENT_UPLOAD_MEDIA_MAX_BYTES', 20 * MB_IN_BYTES );
}

if ( ! function_exists( 'wpagent_perm_upload_files' ) ) {
	/**
	 * Permission callback: der Aufrufer muss Dateien hochladen dürfen.
	 *
	 * Absichtlich als eigene, mit function_exists() abgesicherte Definition in
	 * dieser Datei statt in includes/helpers.php: Diese Phase legt
	 * ausschließlich neue Dateien an, geteilte Dateien bleiben der
	 * Integrationsphase vorbehalten. Dieselbe Definition steht identisch
	 * (function_exists()-geschützt) auch in list-media.php, damit keine
	 * Ladereihenfolge zwischen den beiden neuen Medien-Ability-Dateien
	 * vorausgesetzt werden muss. Die Integrationsphase sollte diese Funktion
	 * neben die anderen vier wpagent_perm_*-Helfer nach helpers.php heben.
	 *
	 * @param mixed $input Ungenutzter Ability-Input.
	 * @return bool
	 */
	function wpagent_perm_upload_files( $input = null ): bool {
		return current_user_can( 'upload_files' );
	}
}

function wpagent_ability_upload_media(): array {
	return array(
		'label'               => __( 'Medium von URL hochladen', 'wp-agent-companion' ),
		'description'         => __( 'Lädt ein Bild von einer öffentlichen http(s)-URL herunter (SSRF-geprüft inklusive jedes Redirect-Hops) und legt es als neuen Medienbeitrag an. Der Alt-Text ist Pflicht und wird per Re-Read verifiziert.', 'wp-agent-companion' ),
		'category'            => 'wp-agent',
		'input_schema'        => array(
			'type'                 => 'object',
			'properties'           => array(
				'source_url' => array(
					'type'        => 'string', 'minLength' => 1, 'maxLength' => 2048,
					'description' => __( 'Öffentliche http(s)-URL des Bildes. localhost, private Netze und andere Schemata (z. B. file://) werden verweigert.', 'wp-agent-companion' ),
				),
				'alt'        => array(
					'type'        => 'string', 'minLength' => 1, 'maxLength' => 1000,
					'description' => __( 'Pflicht: beschreibender, nicht leerer Alt-Text für das Bild.', 'wp-agent-companion' ),
				),
				'post_id'    => array(
					'type'        => 'integer', 'minimum' => 0, 'maximum' => 2147483647,
					'default'     => 0,
					'description' => __( 'Optional: Eltern-Beitrag, dem das Medium zugeordnet wird (post_parent). 0 (Default) = unangehängt in der Mediathek.', 'wp-agent-companion' ),
				),
			),
			'required'   => array( 'source_url', 'alt' ),
			'additionalProperties' => false,
		),
		'output_schema'       => array(
			'type'                 => 'object',
			'properties'           => array(
				'media_id'  => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 2147483647 ),
				'post_id'   => array( 'type' => 'integer', 'minimum' => 0, 'maximum' => 2147483647 ),
				'url'       => array( 'type' => 'string', 'maxLength' => 2048 ),
				'mime_type' => array( 'type' => 'string', 'maxLength' => 100 ),
				'alt'       => array( 'type' => 'string', 'maxLength' => 1000 ),
				'verified'  => array( 'type' => 'boolean' ),
			),
			'required'             => array( 'media_id', 'post_id', 'url', 'mime_type', 'alt', 'verified' ),
			'additionalProperties' => false,
		),
		'execute_callback'    => 'wpagent_exec_upload_media',
		'permission_callback' => 'wpagent_perm_upload_files',
		'meta'                => array(
			'show_in_rest' => true,
			'annotations'  => array(
				'readonly'    => false,
				'destructive' => false,
				// Bewusst false, nicht das sonst in diesem Plugin übliche true:
				// jeder Aufruf lädt eine neue Datei herunter und legt einen NEUEN
				// Medienbeitrag an (eigene media_id), ein Retry mit identischem
				// Input erzeugt also ein Duplikat statt denselben Endzustand
				// erneut zu melden. Anders als z. B. wp-agent/set-alt-text, das
				// einen bestehenden Datensatz auf denselben Zielwert setzt.
				'idempotent'  => false,
			),
		),
	);
}

/**
 * Prüft eine Quell-URL gegen die SSRF-Allowlist. Siehe Datei-Kommentar oben
 * für die Begründung der einzelnen Schritte.
 *
 * @param string $raw_url
 * @return true|WP_Error
 */
function wpagent_upload_media_assert_allowed_url( string $raw_url, bool $return_resolution = false ) {
	$parts = wp_parse_url( $raw_url );
	// Nur ein fehlendes Schema ist ein reiner Format-Fehler (wpagent_bad_url).
	// Zugangsdaten werden noch vor dem Schema geprüft, damit auch eine URL mit
	// Fremdschema keine darin enthaltenen Geheimnisse in einer Fehlermeldung
	// offenlegt. Das Schema selbst wird danach VOR der Host-Prüfung ausgewertet:
	// "file:///etc/hosts" wird mit leerem Host geparst (keine Authority zwischen
	// drittem und viertem Slash), würde sonst fälschlich als "Ungültige URL" statt als
	// SSRF-Verstoß gemeldet, obwohl genau das ein klassischer
	// SSRF-Umgehungsversuch ist.
	if ( $parts && ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			__( 'SSRF-Schutz: Zugangsdaten in der Quell-URL sind nicht erlaubt.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}
	if ( ! $parts || empty( $parts['scheme'] ) ) {
		return new WP_Error(
			'wpagent_bad_url',
			__( 'Ungültige Quell-URL.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}

	$scheme = strtolower( $parts['scheme'] );
	if ( 'http' !== $scheme && 'https' !== $scheme ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			/* translators: %s: submitted URL scheme. */
			sprintf( __( 'SSRF-Schutz: nur http/https erlaubt, nicht "%s".', 'wp-agent-companion' ), $scheme ),
			array( 'status' => 400 )
		);
	}

	if ( empty( $parts['host'] ) ) {
		return new WP_Error(
			'wpagent_bad_url',
			__( 'Ungültige Quell-URL.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}
	// Für Validierung und DNS-Abfrage wird der kanonische Host ohne finalen
	// Root-Punkt verwendet. CURLOPT_RESOLVE muss dagegen exakt den Host aus der
	// Request-URL erhalten, sonst löst cURL einen FQDN mit abschließendem Punkt
	// erneut über DNS auf und umgeht damit das Pinning.
	$request_host = strtolower( trim( (string) $parts['host'], '[]' ) );
	$host         = trim( $request_host, '.' );
	if ( '' === $host ) {
		return new WP_Error(
			'wpagent_bad_url',
			__( 'Ungültige Quell-URL.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}
	if ( 'localhost' === $host || str_ends_with( $host, '.localhost' ) ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			__( 'SSRF-Schutz: localhost/Loopback ist blockiert.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}

	// Öffentlich nie auflösbare beziehungsweise ausschließlich für lokale
	// Namensauflösung reservierte Suffixe vor dns_get_record() abweisen. Das
	// ist nicht nur ein zusätzlicher SSRF-Schutz: manche System-Resolver
	// warten bei diesen Namen mehrere Sekunden auf ein Timeout und würden
	// dadurch den gesamten REST-Request unnötig blockieren.
	$non_public_suffixes = array( '.invalid', '.test', '.example', '.local', '.home.arpa', '.onion' );
	foreach ( $non_public_suffixes as $suffix ) {
		if ( ltrim( $suffix, '.' ) === $host || str_ends_with( $host, $suffix ) ) {
			return new WP_Error(
				'wpagent_ssrf_blocked',
				/* translators: %s: submitted hostname. */
				sprintf( __( 'SSRF-Schutz: Host "%s" nicht auflösbar, Download verweigert.', 'wp-agent-companion' ), $host ),
				array( 'status' => 400 )
			);
		}
	}

	// Direktes IP-Literal (IPv4 oder IPv6, ohne eckige Klammern) sofort
	// prüfen, ohne DNS-Umweg.
	if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
		$check = wpagent_upload_media_check_ip( $host );
		if ( is_wp_error( $check ) || ! $return_resolution ) {
			return $check;
		}
		return wpagent_upload_media_resolution( $raw_url, $parts, $request_host, $host );
	}

	// Hostname per DNS auflösen und JEDE zurückgegebene Adresse prüfen
	// (fail-closed gegen DNS-Rebinding), analog zu
	// assertAllowedUrlResolved() in packages/wp-agent/src/safety/ssrf.mjs.
	$records = dns_get_record( $host, DNS_A + DNS_AAAA );
	if ( false === $records || array() === $records ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			/* translators: %s: submitted hostname. */
			sprintf( __( 'SSRF-Schutz: Host "%s" nicht auflösbar, Download verweigert.', 'wp-agent-companion' ), $host ),
			array( 'status' => 400 )
		);
	}

	$allowed_ips = array();
	foreach ( $records as $record ) {
		$type = isset( $record['type'] ) ? (string) $record['type'] : '';
		$ip   = ( 'AAAA' === $type ) ? (string) ( $record['ipv6'] ?? '' ) : (string) ( $record['ip'] ?? '' );
		if ( '' === $ip ) {
			continue;
		}
		$check = wpagent_upload_media_check_ip( $ip );
		if ( is_wp_error( $check ) ) {
			return $check;
		}
		$allowed_ips[] = $ip;
	}

	if ( array() === $allowed_ips ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			/* translators: %s: submitted hostname. */
			sprintf( __( 'SSRF-Schutz: Host "%s" nicht auflösbar, Download verweigert.', 'wp-agent-companion' ), $host ),
			array( 'status' => 400 )
		);
	}

	if ( ! $return_resolution ) {
		return true;
	}
	return wpagent_upload_media_resolution( $raw_url, $parts, $request_host, $allowed_ips[0] );
}

/**
 * @param string $raw_url
 * @param array  $parts
 * @param string $request_host
 * @param string $ip
 * @return array{url:string,host:string,port:int,ip:string}
 */
function wpagent_upload_media_resolution( string $raw_url, array $parts, string $request_host, string $ip ): array {
	$scheme = strtolower( (string) $parts['scheme'] );
	$port   = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === $scheme ? 443 : 80 );
	return array(
		'url'  => $raw_url,
		'host' => $request_host,
		'port' => $port,
		'ip'   => $ip,
	);
}

/**
 * @param string $ip Bereits geparste, literale IP-Adresse.
 * @return true|WP_Error
 */
function wpagent_upload_media_check_ip( string $ip ) {
	$packed = inet_pton( $ip );
	if ( false === $packed || wpagent_upload_media_ip_is_non_public( $packed ) ) {
		return new WP_Error(
			'wpagent_ssrf_blocked',
			/* translators: %s: resolved IP address. */
			sprintf( __( 'SSRF-Schutz: Ziel-IP "%s" liegt in einem nicht öffentlichen Bereich.', 'wp-agent-companion' ), $ip ),
			array( 'status' => 400 )
		);
	}
	return true;
}

/**
 * @param string $packed Binäre IPv4- oder IPv6-Adresse aus inet_pton().
 * @return bool
 */
function wpagent_upload_media_ip_is_non_public( string $packed ): bool {
	if ( 4 === strlen( $packed ) ) {
		foreach ( array( '192.0.0.9/32', '192.0.0.10/32' ) as $cidr ) {
			if ( wpagent_upload_media_packed_in_cidr( $packed, $cidr ) ) {
				return false;
			}
		}
		$cidrs = array(
			'0.0.0.0/8',
			'10.0.0.0/8',
			'100.64.0.0/10',
			'127.0.0.0/8',
			'169.254.0.0/16',
			'172.16.0.0/12',
			'192.0.0.0/24',
			'192.0.2.0/24',
			'192.168.0.0/16',
			'198.18.0.0/15',
			'198.51.100.0/24',
			'203.0.113.0/24',
			'224.0.0.0/4',
			'240.0.0.0/4',
		);
		foreach ( $cidrs as $cidr ) {
			if ( wpagent_upload_media_packed_in_cidr( $packed, $cidr ) ) {
				return true;
			}
		}
		return false;
	}

	if ( 16 !== strlen( $packed ) ) {
		return true;
	}

	// IPv4-mapped IPv6 und die standardisierte NAT64-Präfixabbildung müssen
	// anhand ihrer eingebetteten IPv4-Adresse geprüft werden.
	$mapped_prefix = inet_pton( '::ffff:0.0.0.0' );
	$nat64_prefix  = inet_pton( '64:ff9b::' );
	if (
		substr( $packed, 0, 12 ) === substr( $mapped_prefix, 0, 12 )
		|| substr( $packed, 0, 12 ) === substr( $nat64_prefix, 0, 12 )
	) {
		return wpagent_upload_media_ip_is_non_public( substr( $packed, 12, 4 ) );
	}

	foreach (
		array(
			'2001:1::1/128',
			'2001:1::2/128',
			'2001:1::3/128',
			'2001:3::/32',
			'2001:4:112::/48',
			'2001:20::/28',
			'2001:30::/28',
		) as $cidr
	) {
		if ( wpagent_upload_media_packed_in_cidr( $packed, $cidr ) ) {
			return false;
		}
	}

	$cidrs = array(
		'::/3',
		'::1/128',
		'64:ff9b:1::/48',
		'100::/64',
		'2001::/23',
		'2001:2::/48',
		'2001:10::/28',
		'2001:db8::/32',
		'2002::/16',
		'3fff::/20',
		'4000::/2',
		'5f00::/16',
		'8000::/1',
		'fc00::/7',
		'fe80::/10',
		'fec0::/10',
		'ff00::/8',
	);
	foreach ( $cidrs as $cidr ) {
		if ( wpagent_upload_media_packed_in_cidr( $packed, $cidr ) ) {
			return true;
		}
	}
	return false;
}

/**
 * @param string $packed
 * @param string $cidr
 * @return bool
 */
function wpagent_upload_media_packed_in_cidr( string $packed, string $cidr ): bool {
	list( $base, $prefix_raw ) = explode( '/', $cidr, 2 );
	$base_packed = inet_pton( $base );
	$prefix      = (int) $prefix_raw;
	if ( false === $base_packed || strlen( $base_packed ) !== strlen( $packed ) ) {
		return false;
	}

	$full_bytes = intdiv( $prefix, 8 );
	$remaining  = $prefix % 8;
	if ( $full_bytes > 0 && substr( $packed, 0, $full_bytes ) !== substr( $base_packed, 0, $full_bytes ) ) {
		return false;
	}
	if ( 0 === $remaining ) {
		return true;
	}
	$mask = ( 0xff << ( 8 - $remaining ) ) & 0xff;
	return ( ord( $packed[ $full_bytes ] ) & $mask ) === ( ord( $base_packed[ $full_bytes ] ) & $mask );
}

/**
 * Führt einen WordPress-HTTP-Request über den cURL-Transport aus und pinnt
 * den ursprünglichen Hostnamen auf genau die zuvor geprüfte IP-Adresse.
 *
 * @param array{url:string,host:string,port:int,ip:string} $resolution
 * @param array                                           $args
 * @return array|WP_Error
 */
function wpagent_upload_media_request_pinned( array $resolution, array $args ) {
	if (
		! function_exists( 'curl_init' )
		|| ! defined( 'CURLOPT_RESOLVE' )
		|| ! class_exists( 'WpOrg\\Requests\\Transport\\Curl' )
	) {
		return new WP_Error(
			'wpagent_download_failed',
			__( 'Bild-Download fehlgeschlagen: Der sichere, DNS-gepinnte cURL-Transport ist auf diesem Server nicht verfügbar.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}

	$expected_url = $resolution['url'];
	$resolve_ip   = str_contains( $resolution['ip'], ':' ) ? '[' . $resolution['ip'] . ']' : $resolution['ip'];
	$resolve      = $resolution['host'] . ':' . $resolution['port'] . ':' . $resolve_ip;
	$pin_applied  = false;
	$preempted    = false;

	$force_curl = static function ( &$url, &$headers, &$data, &$type, &$options ) use ( $expected_url ): void {
		if ( $url === $expected_url ) {
			$options['transport'] = 'WpOrg\\Requests\\Transport\\Curl';
		}
	};
	$pin_curl = static function ( &$handle, $parsed_args, $url ) use ( $expected_url, $resolve, &$pin_applied ): void {
		if ( $url !== $expected_url ) {
			return;
		}
		// Ein konfigurierter HTTP-Proxy könnte den Host selbst erneut auflösen
		// und damit das Pinning umgehen. Dieser Sicherheits-Download läuft
		// deshalb direkt zur geprüften Ziel-IP.
		if ( ! curl_setopt( $handle, CURLOPT_PROXY, '' ) || ! curl_setopt( $handle, CURLOPT_RESOLVE, array( $resolve ) ) ) {
			throw new RuntimeException( 'cURL-DNS-Pinning konnte nicht aktiviert werden.' );
		}
		$pin_applied = true;
	};
	$observe_preemption = static function ( $preempt, $parsed_args, $url ) use ( $expected_url, &$preempted ) {
		if ( $url === $expected_url && false !== $preempt ) {
			$preempted = true;
		}
		return $preempt;
	};

	add_action( 'requests-requests.before_request', $force_curl, PHP_INT_MAX, 5 );
	add_action( 'http_api_curl', $pin_curl, PHP_INT_MAX, 3 );
	add_filter( 'pre_http_request', $observe_preemption, PHP_INT_MAX, 3 );
	try {
		$response = wp_remote_get( $expected_url, $args );
	} catch ( Throwable $error ) {
		$response = new WP_Error(
			'wpagent_download_failed',
			/* translators: %s: secure HTTP transport error. */
			sprintf( __( 'Bild-Download fehlgeschlagen: %s', 'wp-agent-companion' ), $error->getMessage() ),
			array( 'status' => 400 )
		);
	} finally {
		remove_action( 'requests-requests.before_request', $force_curl, PHP_INT_MAX );
		remove_action( 'http_api_curl', $pin_curl, PHP_INT_MAX );
		remove_filter( 'pre_http_request', $observe_preemption, PHP_INT_MAX );
	}

	if ( ! $preempted && ! $pin_applied && ! is_wp_error( $response ) ) {
		return new WP_Error(
			'wpagent_download_failed',
			__( 'Bild-Download fehlgeschlagen: Der HTTP-Transport hat die geprüfte DNS-Adresse nicht verwendet.', 'wp-agent-companion' ),
			array( 'status' => 400 )
		);
	}
	return $response;
}

/**
 * Lädt eine Datei von einer öffentlichen http(s)-URL herunter. Jeder Hop
 * (Start-URL wie jedes Redirect-Ziel) wird VOR dem Fetch per
 * wpagent_upload_media_assert_allowed_url() geprüft.
 *
 * @param string $url
 * @return array{bytes:string, filename:string}|WP_Error
 */
function wpagent_upload_media_fetch( string $url ) {
	$current_url = $url;
	$max_bytes   = min( max( 1, (int) WPAGENT_UPLOAD_MEDIA_MAX_BYTES ), PHP_INT_MAX - 1 );

	for ( $hop = 0; $hop <= WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS; $hop++ ) {
		$resolution = wpagent_upload_media_assert_allowed_url( $current_url, true );
		if ( is_wp_error( $resolution ) ) {
			return $resolution;
		}

		$response = wpagent_upload_media_request_pinned(
			$resolution,
			array(
				'timeout'             => 30,
				'redirection'         => 0,
				'sslverify'           => true,
				'reject_unsafe_urls'  => true,
				// Ein zusätzliches Byte erlaubt, eine abgeschnittene Antwort
				// eindeutig als zu groß zu erkennen statt sie weiterzugeben.
				'limit_response_size' => $max_bytes + 1,
			)
		);
		if ( is_wp_error( $response ) ) {
			return new WP_Error(
				'wpagent_download_failed',
			/* translators: %s: secure HTTP transport error. */
				sprintf( __( 'Bild-Download fehlgeschlagen: %s', 'wp-agent-companion' ), $response->get_error_message() ),
				array( 'status' => 400 )
			);
		}

		$status = (int) wp_remote_retrieve_response_code( $response );

		if ( $status >= 300 && $status < 400 ) {
			if ( $hop === WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS ) {
				return new WP_Error(
					'wpagent_too_many_redirects',
					sprintf(
						/* translators: %d: maximum redirect count. */
						__( 'Bild-Download: zu viele Redirects (mehr als %d).', 'wp-agent-companion' ),
						WPAGENT_UPLOAD_MEDIA_MAX_REDIRECTS
					),
					array( 'status' => 400 )
				);
			}
			$location = wp_remote_retrieve_header( $response, 'location' );
			if ( ! $location ) {
				return new WP_Error(
					'wpagent_bad_redirect',
					__( 'Bild-Download: Redirect ohne Location-Header.', 'wp-agent-companion' ),
					array( 'status' => 400 )
				);
			}
			// Relative Redirect-Ziele gegen die aktuelle URL auflösen; die
			// nächste Schleifenrunde prüft das Ziel erneut per SSRF-Allowlist.
			$current_url = wpagent_upload_media_resolve_relative( (string) $location, $current_url );
			continue;
		}

		if ( $status < 200 || $status >= 300 ) {
			return new WP_Error(
				'wpagent_download_failed',
				/* translators: %d: HTTP status code. */
				sprintf( __( 'Bild-Download fehlgeschlagen (Status %d).', 'wp-agent-companion' ), $status ),
				array( 'status' => 400 )
			);
		}

		$content_length = (int) wp_remote_retrieve_header( $response, 'content-length' );
		if ( $content_length > $max_bytes ) {
			return new WP_Error(
				'wpagent_download_too_large',
				/* translators: %s: formatted maximum file size. */
				sprintf( __( 'Bild-Download ist zu groß (maximal %s).', 'wp-agent-companion' ), size_format( $max_bytes, 2 ) ),
				array( 'status' => 400 )
			);
		}

		$bytes = wp_remote_retrieve_body( $response );
		if ( '' === $bytes ) {
			return new WP_Error(
				'wpagent_download_empty',
				__( 'Bild-Download lieferte eine leere Antwort.', 'wp-agent-companion' ),
				array( 'status' => 400 )
			);
		}
		if ( strlen( $bytes ) > $max_bytes ) {
			return new WP_Error(
				'wpagent_download_too_large',
				/* translators: %s: formatted maximum file size. */
				sprintf( __( 'Bild-Download ist zu groß (maximal %s).', 'wp-agent-companion' ), size_format( $max_bytes, 2 ) ),
				array( 'status' => 400 )
			);
		}

		$path     = (string) wp_parse_url( $current_url, PHP_URL_PATH );
		$filename = ( '' !== $path ) ? sanitize_file_name( basename( $path ) ) : '';
		if ( '' === $filename ) {
			$filename = 'image';
		}

		return array(
			'bytes'    => $bytes,
			'filename' => $filename,
		);
	}

	// Unerreichbar (die Schleife oben gibt in jedem Durchlauf entweder ein
	// Ergebnis oder einen Fehler zurück), aber fail-closed statt null.
	return new WP_Error(
		'wpagent_too_many_redirects',
		__( 'Bild-Download: Redirect-Limit überschritten.', 'wp-agent-companion' ),
		array( 'status' => 400 )
	);
}

/**
 * Löst ein Redirect-Ziel mit WordPress' eigener, getesteter URL-Auflösung
 * gegen die aktuelle URL auf. Absolute URLs bleiben unverändert. Relative
 * Pfade, Query-Strings und "../"-Segmente werden korrekt aufgelöst. Das
 * Ergebnis wird vor dem nächsten Fetch erneut per
 * wpagent_upload_media_assert_allowed_url() geprüft.
 *
 * @param string $location
 * @param string $base
 * @return string
 */
function wpagent_upload_media_resolve_relative( string $location, string $base ): string {
	return WP_Http::make_absolute_url( $location, $base );
}

/**
 * @param mixed $input
 * @return array|WP_Error
 */
function wpagent_exec_upload_media( $input ) {
	$input      = is_array( $input ) ? $input : array();
	$source_url = wpagent_core_mutator_bounded_text( $input['source_url'] ?? null, 2048 );
	$alt        = wpagent_core_mutator_bounded_text( $input['alt'] ?? null, 1000 );
	$post_id    = 0;
	if ( array_key_exists( 'post_id', $input ) ) {
		$post_id = wpagent_core_schema_input_integer( $input['post_id'], 0, 2147483647, 'post_id' );
		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}
	}

	if ( is_wp_error( $source_url ) || trim( $source_url ) !== $source_url ) {
		return new WP_Error( 'wpagent_bad_url', __( 'source_url darf nicht leer sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	if ( is_wp_error( $alt ) ) {
		return new WP_Error( 'wpagent_missing_alt', __( 'alt ist Pflicht und darf nicht leer sein.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	if ( ! current_user_can( 'upload_files' ) ) {
		return new WP_Error( 'wpagent_forbidden', __( 'Keine Berechtigung zum Hochladen von Medien.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}

	if ( $post_id > 0 ) {
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return new WP_Error( 'wpagent_forbidden', __( 'Keine Berechtigung für den Eltern-Beitrag.', 'wp-agent-companion' ), array( 'status' => 403 ) );
		}
		if ( ! get_post( $post_id ) ) {
			return new WP_Error( 'wpagent_forbidden', __( 'Keine Berechtigung für den Eltern-Beitrag.', 'wp-agent-companion' ), array( 'status' => 403 ) );
		}
	}

	$fetched = wpagent_upload_media_fetch( $source_url );
	if ( is_wp_error( $fetched ) ) {
		return $fetched;
	}

	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';
	}

	$tmp_file = wp_tempnam( $fetched['filename'] );
	if ( ! $tmp_file ) {
		return new WP_Error( 'wpagent_media_write_failed', __( 'Konnte keine temporäre Datei anlegen.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	if ( false === file_put_contents( $tmp_file, $fetched['bytes'] ) ) {
		if ( file_exists( $tmp_file ) ) {
			wp_delete_file( $tmp_file );
		}
		return new WP_Error( 'wpagent_media_write_failed', __( 'Heruntergeladene Datei konnte nicht zwischengespeichert werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}

	$file_array = array(
		'name'     => $fetched['filename'],
		'tmp_name' => $tmp_file,
	);

	$media_id = media_handle_sideload( $file_array, $post_id );
	if ( is_wp_error( $media_id ) ) {
		// wp_handle_sideload() löscht die tmp-Datei nur im Erfolgsfall selbst
		// (copy()+unlink() intern); bei einem frühen Validierungsfehler (z. B.
		// unzulässiger Dateityp) bleibt sie sonst liegen.
		if ( file_exists( $tmp_file ) ) {
			wp_delete_file( $tmp_file );
		}
		return new WP_Error(
			'wpagent_upload_failed',
			/* translators: %s: WordPress media sideload error message. */
			sprintf( __( 'Medien-Upload fehlgeschlagen: %s', 'wp-agent-companion' ), $media_id->get_error_message() ),
			array( 'status' => 400 )
		);
	}

	update_post_meta( $media_id, '_wp_attachment_image_alt', $alt );
	do_action( 'wpagent_upload_media_after_alt_write', (int) $media_id );

	$alt_values = get_post_meta( $media_id, '_wp_attachment_image_alt', false );
	$attachment = get_post( $media_id );
	$verified = (
		$attachment instanceof WP_Post
		&& 'attachment' === $attachment->post_type
		&& (int) $attachment->post_parent === $post_id
		&& $alt_values === array( $alt )
		&& current_user_can( 'upload_files' )
		&& ( 0 === $post_id || current_user_can( 'edit_post', $post_id ) )
	);
	if ( ! $verified ) {
		return wpagent_upload_media_recovery_required( (int) $media_id, __( 'Das neue Medium konnte nach dem Alt-Text-Schreiben nicht vollständig verifiziert werden. Es bleibt zur manuellen Prüfung erhalten.', 'wp-agent-companion' ) );
	}
	$output = array(
		'url'       => wpagent_core_schema_output_text( (string) wp_get_attachment_url( $media_id ), 2048, 'url' ),
		'mime_type' => wpagent_core_schema_output_text( $attachment->post_mime_type, 100, 'mime_type' ),
		'alt'       => wpagent_core_schema_output_text( $alt, 1000, 'alt' ),
	);
	foreach ( $output as $value ) {
		if ( is_wp_error( $value ) ) {
			return wpagent_upload_media_recovery_required( (int) $media_id, __( 'Das neue Medium überschreitet eine veröffentlichte Ausgabegrenze. Es bleibt zur manuellen Prüfung erhalten.', 'wp-agent-companion' ) );
		}
	}

	return array(
		'media_id'  => (int) $media_id,
		'post_id'   => $post_id,
		'url'       => $output['url'],
		'mime_type' => $output['mime_type'],
		'alt'       => $output['alt'],
		'verified'  => true,
	);
}

/** @return WP_Error */
function wpagent_upload_media_recovery_required( int $media_id, string $message ): WP_Error {
	return new WP_Error(
		'wpagent_upload_media_recovery_required',
		$message,
		array( 'status' => 500, 'media_id' => $media_id, 'recovery_required' => true, 'rollback_verified' => false )
	);
}
