<?php
/**
 * Abilities für kurzlebige Direkt-Uploads in die WordPress-Mediathek.
 *
 * Der Dateiinhalt läuft nicht durch REST oder MCP. Die Ability gibt stattdessen
 * einen kurzlebigen Einmal-Token zurück, der ausschließlich in einem Header an
 * den streamenden admin-post-Handler übergeben wird. Gespeichert wird nur der
 * SHA-256-Hash des Tokens.
 *
 * @package WPAgentCompanion
 */

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

define( 'WPAGENT_COMPANION_DIRECT_UPLOAD_PREFIX', 'wp_agent_direct_media_upload_' );
define( 'WPAGENT_COMPANION_DIRECT_UPLOAD_TTL', 300 );
define( 'WPAGENT_COMPANION_DIRECT_UPLOAD_HEADER', 'X-WPAgent-Upload-Token' );
define( 'WPAGENT_COMPANION_DIRECT_UPLOAD_HARD_MAX_BYTES', 64 * MB_IN_BYTES );

/** @return array<string,mixed> */
function wpagent_ability_create_direct_media_upload(): array {
	return array(
		'label'               => __( 'Direkten Medien-Upload vorbereiten', 'wp-agent-companion' ),
		'description'         => __( 'Erstellt für den sicheren Redakteur einen kurzlebigen Einmal-Endpunkt, über den eine lokale Datei ohne öffentliche Zwischen-URL direkt und größenbegrenzt in die Mediathek gestreamt wird.', 'wp-agent-companion' ),
		'category'            => 'wp-agent',
		'input_schema'        => array(
			'type'                 => 'object',
			'properties'           => array(
				'filename'  => array( 'type' => 'string', 'minLength' => 1, 'maxLength' => 240 ),
				'alt'       => array( 'type' => 'string', 'maxLength' => 1000, 'default' => '' ),
				'post_id'   => array( 'type' => 'integer', 'minimum' => 0, 'default' => 0 ),
				'max_bytes' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => WPAGENT_COMPANION_DIRECT_UPLOAD_HARD_MAX_BYTES ),
			),
			'required'             => array( 'filename' ),
			'additionalProperties' => false,
		),
		'output_schema'       => array(
			'type'                 => 'object',
			'additionalProperties' => false,
			'properties'           => array(
				'url'        => array( 'type' => 'string', 'maxLength' => 2000 ),
				'method'     => array( 'type' => 'string', 'enum' => array( 'POST' ) ),
				'expires_at' => array( 'type' => 'string', 'maxLength' => 40 ),
				'user_id'    => array( 'type' => 'integer' ),
				'filename'   => array( 'type' => 'string', 'maxLength' => 240 ),
				'post_id'    => array( 'type' => 'integer' ),
				'max_bytes'  => array( 'type' => 'integer' ),
				'one_time'   => array( 'type' => 'boolean' ),
				'headers'    => array(
					'type'                 => 'object',
					'properties'           => array(
						WPAGENT_COMPANION_DIRECT_UPLOAD_HEADER => array( 'type' => 'string', 'minLength' => 43, 'maxLength' => 43, 'pattern' => '^[A-Za-z0-9_-]{43}$' ),
						'Content-Type' => array( 'type' => 'string', 'enum' => array( 'application/octet-stream' ) ),
					),
					'required'             => array( WPAGENT_COMPANION_DIRECT_UPLOAD_HEADER, 'Content-Type' ),
					'additionalProperties' => false,
				),
			),
			'required'             => array( 'url', 'method', 'expires_at', 'user_id', 'filename', 'post_id', 'max_bytes', 'one_time', 'headers' ),
		),
		'execute_callback'    => 'wpagent_exec_create_direct_media_upload',
		'permission_callback' => 'wpagent_perm_direct_media_upload',
		'meta'                => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ) ),
	);
}

/** @return array<string,mixed> */
function wpagent_ability_revoke_direct_media_upload(): array {
	return array(
		'label'               => __( 'Direkten Medien-Upload widerrufen', 'wp-agent-companion' ),
		'description'         => __( 'Widerruft den noch offenen direkten Medien-Upload des aktuell authentifizierten sicheren Redakteurs.', 'wp-agent-companion' ),
		'category'            => 'wp-agent',
		'input_schema'        => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, 'default' => array() ),
		'output_schema'       => array(
			'type'                 => 'object',
			'properties'           => array( 'revoked' => array( 'type' => 'boolean' ) ),
			'required'             => array( 'revoked' ),
			'additionalProperties' => false,
		),
		'execute_callback'    => 'wpagent_exec_revoke_direct_media_upload',
		'permission_callback' => 'wpagent_perm_direct_media_upload',
		'meta'                => array( 'show_in_rest' => true, 'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => false ) ),
	);
}

function wpagent_perm_direct_media_upload( $input = null ): bool {
	$user = wp_get_current_user();
	return wpagent_companion_gate_open()
		&& $user instanceof WP_User
		&& $user->exists()
		&& wpagent_companion_is_safe_connection_user( $user )
		&& user_can( $user, 'upload_files' );
}

function wpagent_companion_direct_media_upload_option( int $user_id ): string {
	return WPAGENT_COMPANION_DIRECT_UPLOAD_PREFIX . $user_id;
}

/**
 * Signs the persisted authorization context without retaining the bearer token.
 * A database write alone therefore cannot retarget an intercepted credential.
 */
function wpagent_companion_direct_media_upload_binding_hash( array $record ): string {
	$binding = array(
		'action'        => (string) ( $record['action'] ?? '' ),
		'alt'           => (string) ( $record['alt'] ?? '' ),
		'blog_id'       => (int) ( $record['blog_id'] ?? 0 ),
		'created_at'    => (int) ( $record['created_at'] ?? 0 ),
		'expected_mime' => (string) ( $record['expected_mime'] ?? '' ),
		'expires'       => (int) ( $record['expires'] ?? 0 ),
		'filename'      => (string) ( $record['filename'] ?? '' ),
		'max_bytes'     => (int) ( $record['max_bytes'] ?? 0 ),
		'post_id'       => (int) ( $record['post_id'] ?? 0 ),
		'token_hash'    => (string) ( $record['token_hash'] ?? '' ),
		'user_id'       => (int) ( $record['user_id'] ?? 0 ),
	);
	return hash_hmac( 'sha256', (string) wp_json_encode( $binding, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ), wp_salt( 'auth' ) );
}

/** Löscht nur den exakt zuvor gelesenen Datensatz. */
function wpagent_companion_delete_direct_media_upload_record( string $option, array $record ): bool {
	global $wpdb;
	$deleted = $wpdb->delete(
		$wpdb->options,
		array( 'option_name' => $option, 'option_value' => maybe_serialize( $record ) ),
		array( '%s', '%s' )
	);
	if ( 1 === $deleted ) {
		wp_cache_delete( $option, 'options' );
		return true;
	}
	return false;
}

function wpagent_companion_direct_media_upload_server_max_bytes(): int {
	$wordpress_max = function_exists( 'wp_max_upload_size' ) ? (int) wp_max_upload_size() : 0;
	if ( $wordpress_max < 1 ) {
		return WPAGENT_COMPANION_DIRECT_UPLOAD_HARD_MAX_BYTES;
	}
	return min( $wordpress_max, WPAGENT_COMPANION_DIRECT_UPLOAD_HARD_MAX_BYTES );
}

function wpagent_companion_direct_media_upload_text_length( string $value ): int {
	return function_exists( 'mb_strlen' ) ? (int) mb_strlen( $value, 'UTF-8' ) : strlen( $value );
}

/** @return array{filename:string,expected_mime:string,alt:string,post_id:int,max_bytes:int}|WP_Error */
function wpagent_companion_validate_direct_media_upload_input( $input ) {
	$input = is_array( $input ) ? $input : array();
	$raw_filename = $input['filename'] ?? '';
	if ( ! is_string( $raw_filename ) ) {
		return new WP_Error( 'wpagent_direct_upload_filename', __( 'Der Dateiname für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$raw_filename = trim( wp_unslash( $raw_filename ) );
	$normalized   = str_replace( '\\', '/', $raw_filename );
	if ( '' === $normalized || strlen( $normalized ) > 240 || basename( $normalized ) !== $normalized || str_contains( $normalized, "\0" ) ) {
		return new WP_Error( 'wpagent_direct_upload_filename', __( 'Der Dateiname für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$filename = sanitize_file_name( $normalized );
	if ( '' === $filename || '.' === $filename || '..' === $filename ) {
		return new WP_Error( 'wpagent_direct_upload_filename', __( 'Der Dateiname für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}

	$filetype = wp_check_filetype( $filename, get_allowed_mime_types() );
	if ( empty( $filetype['ext'] ) || empty( $filetype['type'] ) ) {
		return new WP_Error( 'wpagent_direct_upload_type', __( 'Dieser Dateityp ist für den direkten Medien-Upload nicht erlaubt.', 'wp-agent-companion' ), array( 'status' => 415 ) );
	}
	$expected_mime = (string) $filetype['type'];

	$raw_alt = $input['alt'] ?? '';
	if ( ! is_string( $raw_alt ) ) {
		return new WP_Error( 'wpagent_direct_upload_alt', __( 'Der Alt-Text für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$raw_alt = wp_unslash( $raw_alt );
	if ( wpagent_companion_direct_media_upload_text_length( $raw_alt ) > 1000 ) {
		return new WP_Error( 'wpagent_direct_upload_alt', __( 'Der Alt-Text für den direkten Upload ist zu lang.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$alt = sanitize_text_field( $raw_alt );
	if ( str_starts_with( $expected_mime, 'image/' ) && '' === trim( $alt ) ) {
		return new WP_Error( 'wpagent_direct_upload_alt', __( 'Für Bilder ist ein nicht leerer Alt-Text erforderlich.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}

	$raw_post_id = $input['post_id'] ?? 0;
	if ( ! is_int( $raw_post_id ) && ! ( is_string( $raw_post_id ) && ctype_digit( $raw_post_id ) ) ) {
		return new WP_Error( 'wpagent_direct_upload_post_id', __( 'Die Eltern-ID für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$post_id = (int) $raw_post_id;
	if ( $post_id > 0 ) {
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return new WP_Error( 'wpagent_direct_upload_parent_forbidden', __( 'Der aktuelle Nutzer darf den angegebenen Eltern-Beitrag nicht bearbeiten.', 'wp-agent-companion' ), array( 'status' => 403 ) );
		}
		$post = get_post( $post_id );
		if ( ! $post instanceof WP_Post ) {
			return new WP_Error( 'wpagent_direct_upload_parent_forbidden', __( 'Der aktuelle Nutzer darf den angegebenen Eltern-Beitrag nicht bearbeiten.', 'wp-agent-companion' ), array( 'status' => 403 ) );
		}
	}

	$server_max    = wpagent_companion_direct_media_upload_server_max_bytes();
	$raw_max_bytes = $input['max_bytes'] ?? $server_max;
	if ( ! is_int( $raw_max_bytes ) && ! ( is_string( $raw_max_bytes ) && ctype_digit( $raw_max_bytes ) ) ) {
		return new WP_Error( 'wpagent_direct_upload_max_bytes', __( 'Die Größenbegrenzung für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400 ) );
	}
	$max_bytes = (int) $raw_max_bytes;
	if ( $max_bytes < 1 || $max_bytes > $server_max ) {
		return new WP_Error( 'wpagent_direct_upload_max_bytes', __( 'Die Größenbegrenzung für den direkten Upload ist ungültig.', 'wp-agent-companion' ), array( 'status' => 400, 'maximum' => $server_max ) );
	}

	return compact( 'filename', 'expected_mime', 'alt', 'post_id', 'max_bytes' );
}

/** @return array<string,mixed>|WP_Error */
function wpagent_exec_create_direct_media_upload( $input = array() ) {
	if ( ! wpagent_perm_direct_media_upload() ) {
		return new WP_Error( 'wpagent_direct_upload_forbidden', __( 'Direkte Medien-Uploads sind nur für verbundene sichere Redakteure mit Upload-Rechten verfügbar.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	if ( ! is_ssl() && ! in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ) {
		return new WP_Error( 'wpagent_direct_upload_https_required', __( 'Direkte Medien-Uploads sind außerhalb lokaler Entwicklungsumgebungen nur über HTTPS verfügbar.', 'wp-agent-companion' ), array( 'status' => 503 ) );
	}
	$validated = wpagent_companion_validate_direct_media_upload_input( $input );
	if ( is_wp_error( $validated ) ) {
		return $validated;
	}

	try {
		$token = rtrim( strtr( base64_encode( random_bytes( 32 ) ), '+/', '-_' ), '=' );
	} catch ( Throwable $error ) {
		return new WP_Error( 'wpagent_direct_upload_entropy', __( 'Für den direkten Upload konnte kein sicherer Zufallswert erzeugt werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	$user_id    = get_current_user_id();
	$created_at = time();
	$expires    = $created_at + WPAGENT_COMPANION_DIRECT_UPLOAD_TTL;
	$record  = array_merge(
		$validated,
		array(
			'action'     => 'direct_media_upload',
			'blog_id'    => get_current_blog_id(),
			'token_hash' => hash( 'sha256', $token ),
			'expires'    => $expires,
			'created_at' => $created_at,
			'user_id'    => $user_id,
		)
	);
	$record['binding_hash'] = wpagent_companion_direct_media_upload_binding_hash( $record );
	$option = wpagent_companion_direct_media_upload_option( $user_id );
	update_option( $option, $record, false );
	$stored = get_option( $option, array() );
	if ( ! is_array( $stored ) || $stored !== $record ) {
		if ( is_array( $stored ) && hash_equals( $record['token_hash'], (string) ( $stored['token_hash'] ?? '' ) ) ) {
			wpagent_companion_delete_direct_media_upload_record( $option, $stored );
		}
		return new WP_Error( 'wpagent_direct_upload_persist_failed', __( 'Der direkte Upload konnte nicht verifiziert gespeichert werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}

	return array(
		'url'        => add_query_arg( array( 'action' => 'wpagently_direct_media_upload', 'user' => $user_id ), admin_url( 'admin-post.php' ) ),
		'method'     => 'POST',
		'expires_at' => gmdate( 'c', $expires ),
		'user_id'    => $user_id,
		'filename'   => $validated['filename'],
		'post_id'    => $validated['post_id'],
		'max_bytes'  => $validated['max_bytes'],
		'one_time'   => true,
		'headers'    => array(
			WPAGENT_COMPANION_DIRECT_UPLOAD_HEADER => $token,
			'Content-Type' => 'application/octet-stream',
		),
	);
}

/** @return array{revoked:bool}|WP_Error */
function wpagent_exec_revoke_direct_media_upload( $input = array() ) {
	if ( ! wpagent_perm_direct_media_upload() ) {
		return new WP_Error( 'wpagent_direct_upload_forbidden', __( 'Direkte Medien-Uploads sind nur für verbundene sichere Redakteure mit Upload-Rechten verfügbar.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	$option  = wpagent_companion_direct_media_upload_option( get_current_user_id() );
	$record  = get_option( $option, false );
	$existed = is_array( $record );
	if ( $existed && ! wpagent_companion_delete_direct_media_upload_record( $option, $record ) ) {
		return new WP_Error( 'wpagent_direct_upload_revoke_failed', __( 'Der direkte Upload konnte nicht widerrufen werden.', 'wp-agent-companion' ), array( 'status' => 500 ) );
	}
	return array( 'revoked' => $existed );
}

function wpagent_companion_direct_media_upload_record_is_valid( array $record, WP_User $user ): bool {
	$expected_keys = array( 'action', 'alt', 'binding_hash', 'blog_id', 'created_at', 'expected_mime', 'expires', 'filename', 'max_bytes', 'post_id', 'token_hash', 'user_id' );
	$actual_keys   = array_keys( $record );
	sort( $actual_keys, SORT_STRING );
	if ( $actual_keys !== $expected_keys ) {
		return false;
	}
	if ( ! is_string( $record['filename'] ) || '' === $record['filename'] || strlen( $record['filename'] ) > 240 || basename( $record['filename'] ) !== $record['filename'] || sanitize_file_name( $record['filename'] ) !== $record['filename'] ) {
		return false;
	}
	if ( ! is_string( $record['expected_mime'] ) || ! is_string( $record['alt'] ) || wpagent_companion_direct_media_upload_text_length( $record['alt'] ) > 1000 || ! is_string( $record['token_hash'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $record['token_hash'] ) || ! is_string( $record['binding_hash'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $record['binding_hash'] ) ) {
		return false;
	}
	if ( ! is_int( $record['user_id'] ) || (int) $record['user_id'] !== (int) $user->ID || ! is_int( $record['blog_id'] ) || (int) $record['blog_id'] !== get_current_blog_id() || ! is_string( $record['action'] ) || ! hash_equals( 'direct_media_upload', $record['action'] ) ) {
		return false;
	}
	if ( ! hash_equals( wpagent_companion_direct_media_upload_binding_hash( $record ), $record['binding_hash'] ) ) {
		return false;
	}
	if ( ! is_int( $record['post_id'] ) || $record['post_id'] < 0 || ! is_int( $record['max_bytes'] ) || $record['max_bytes'] < 1 || $record['max_bytes'] > wpagent_companion_direct_media_upload_server_max_bytes() ) {
		return false;
	}
	if ( ! is_int( $record['created_at'] ) || $record['created_at'] < 1 || $record['created_at'] > time() || ! is_int( $record['expires'] ) || $record['expires'] !== $record['created_at'] + WPAGENT_COMPANION_DIRECT_UPLOAD_TTL ) {
		return false;
	}
	$filetype = wp_check_filetype( $record['filename'], get_allowed_mime_types( $user ) );
	if ( empty( $filetype['type'] ) || ! hash_equals( (string) $filetype['type'], $record['expected_mime'] ) ) {
		return false;
	}
	return ! str_starts_with( $record['expected_mime'], 'image/' ) || '' !== trim( $record['alt'] );
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_validate_direct_media_upload( int $user_id, string $token, bool $consume ) {
	$option = wpagent_companion_direct_media_upload_option( $user_id );
	$record = get_option( $option, false );
	$user   = get_user_by( 'id', $user_id );
	if ( ! is_array( $record ) ) {
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	$ability_enabled = in_array( 'wp-agent/create-direct-media-upload', wpagent_companion_enabled_ability_names(), true );
	if ( ! wpagent_companion_gate_open() || ! $ability_enabled || ! $user instanceof WP_User || ! wpagent_companion_is_safe_connection_user( $user ) || ! user_can( $user, 'upload_files' ) ) {
		wpagent_companion_delete_direct_media_upload_record( $option, $record );
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	if ( ! wpagent_companion_direct_media_upload_record_is_valid( $record, $user ) ) {
		wpagent_companion_delete_direct_media_upload_record( $option, $record );
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	if ( (int) ( $record['expires'] ?? 0 ) <= time() ) {
		wpagent_companion_delete_direct_media_upload_record( $option, $record );
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	$expected_hash = (string) ( $record['token_hash'] ?? '' );
	if ( ! preg_match( '/^[A-Za-z0-9_-]{43}$/D', $token ) || 64 !== strlen( $expected_hash ) || ! hash_equals( $expected_hash, hash( 'sha256', $token ) ) ) {
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	if ( $consume && ! wpagent_companion_delete_direct_media_upload_record( $option, $record ) ) {
		return new WP_Error( 'wpagent_direct_upload_invalid', __( 'Der direkte Upload ist ungültig oder abgelaufen.', 'wp-agent-companion' ), array( 'status' => 403 ) );
	}
	return $record;
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_peek_direct_media_upload( int $user_id, string $token ) {
	return wpagent_companion_validate_direct_media_upload( $user_id, $token, false );
}

/** @return array<string,mixed>|WP_Error */
function wpagent_companion_consume_direct_media_upload( int $user_id, string $token ) {
	return wpagent_companion_validate_direct_media_upload( $user_id, $token, true );
}

function wpagent_companion_direct_media_upload_request_token(): string {
	$raw = $_SERVER['HTTP_X_WPAGENT_UPLOAD_TOKEN'] ?? '';
	if ( ! is_string( $raw ) ) {
		return '';
	}
	$token = trim( wp_unslash( $raw ) );
	return preg_match( '/^[A-Za-z0-9_-]{43}$/D', $token ) ? $token : '';
}
