<?php
/**
 * Checksum-verified update channel for the wpagently WordPress plugins.
 *
 * @package WPAgentCompanion
 */

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

define( 'WPAGENT_UPDATE_CACHE_SECONDS', 6 * HOUR_IN_SECONDS );
define( 'WPAGENT_UPDATE_MAX_MANIFEST_BYTES', 128 * 1024 );
define( 'WPAGENT_UPDATE_SIGNATURE_VERSION', '1' );
define( 'WPAGENT_UPDATE_SIGNATURE_ALGORITHM', 'Ed25519' );

/** @var list<string> */
const WPAGENT_UPDATE_SIGNED_FIELDS = array(
	'signature_version',
	'signature_algorithm',
	'key_id',
	'name',
	'slug',
	'version',
	'package',
	'sha256',
	'requires',
	'requires_php',
	'tested',
	'details_url',
	'description',
	'changelog',
);

/** @var array<string,array<string,mixed>> */
$GLOBALS['wpagent_update_channels'] = $GLOBALS['wpagent_update_channels'] ?? array();

/**
 * Produce the exact UTF-8 byte sequence signed by the offline release tool.
 *
 * Length prefixes make field boundaries unambiguous even when prose contains
 * colons or newlines. PHP strings and Node Buffers both count UTF-8 bytes.
 *
 * @param array<string,mixed> $manifest Manifest candidate.
 * @return string|null
 */
function wpagent_companion_update_signature_payload( array $manifest ): ?string {
	$payload = "wpagently-update-manifest-signature-v1\n";
	foreach ( WPAGENT_UPDATE_SIGNED_FIELDS as $field ) {
		if ( ! array_key_exists( $field, $manifest ) || ! is_string( $manifest[ $field ] ) ) {
			return null;
		}
		$value    = $manifest[ $field ];
		$payload .= $field . ':' . strlen( $value ) . ':' . $value . "\n";
	}
	return $payload;
}

/**
 * Authenticate a manifest against a key embedded in the installed plugin.
 *
 * @param array<string,mixed> $manifest Manifest candidate.
 * @param array<string,mixed> $channel  Registered update channel.
 */
function wpagent_companion_verify_update_manifest_signature( array $manifest, array $channel ): bool {
	if (
		! function_exists( 'sodium_crypto_sign_verify_detached' )
		|| ! defined( 'SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES' )
		|| ! defined( 'SODIUM_CRYPTO_SIGN_BYTES' )
		|| WPAGENT_UPDATE_SIGNATURE_VERSION !== ( $manifest['signature_version'] ?? null )
		|| WPAGENT_UPDATE_SIGNATURE_ALGORITHM !== ( $manifest['signature_algorithm'] ?? null )
		|| ! is_string( $manifest['key_id'] ?? null )
		|| ! preg_match( '/^[A-Za-z0-9._-]{3,80}$/', $manifest['key_id'] )
		|| ! is_string( $manifest['signature'] ?? null )
	) {
		return false;
	}
	$public_keys = $channel['public_keys'] ?? null;
	$encoded_key = is_array( $public_keys ) ? ( $public_keys[ $manifest['key_id'] ] ?? null ) : null;
	if ( ! is_string( $encoded_key ) ) {
		return false;
	}
	$public_key = base64_decode( $encoded_key, true );
	$signature  = base64_decode( $manifest['signature'], true );
	$payload    = wpagent_companion_update_signature_payload( $manifest );
	if (
		false === $public_key
		|| false === $signature
		|| SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen( $public_key )
		|| SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature )
		|| base64_encode( $public_key ) !== $encoded_key
		|| base64_encode( $signature ) !== $manifest['signature']
		|| null === $payload
	) {
		return false;
	}
	try {
		return sodium_crypto_sign_verify_detached( $signature, $payload, $public_key );
	} catch ( Throwable ) {
		return false;
	}
}

/**
 * Validate and normalize one static update manifest.
 *
 * @param mixed $manifest Decoded JSON value.
 * @return array<string,mixed>|null
 */
function wpagent_companion_validate_update_manifest( $manifest ): ?array {
	if ( ! is_array( $manifest ) ) {
		return null;
	}
	$required = array_merge( WPAGENT_UPDATE_SIGNED_FIELDS, array( 'signature' ) );
	foreach ( $required as $field ) {
		if ( empty( $manifest[ $field ] ) || ! is_string( $manifest[ $field ] ) ) {
			return null;
		}
	}
	if ( ! preg_match( '/^[a-z0-9-]{3,80}$/', $manifest['slug'] ) ) {
		return null;
	}
	if ( ! preg_match( '/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/', $manifest['version'] ) ) {
		return null;
	}

	$package = wp_parse_url( $manifest['package'] );
	$host    = strtolower( (string) ( $package['host'] ?? '' ) );
	$path    = (string) ( $package['path'] ?? '' );
	$expected_path = '/downloads/' . $manifest['slug'] . '-' . $manifest['version'] . '.zip';
	if (
		'https' !== ( $package['scheme'] ?? '' )
		|| ! in_array( $host, array( 'wpagently.com', 'www.wpagently.com' ), true )
		|| $expected_path !== $path
		|| isset( $package['user'] )
		|| isset( $package['pass'] )
		|| isset( $package['port'] )
		|| isset( $package['query'] )
		|| isset( $package['fragment'] )
	) {
		return null;
	}

	$sha256 = strtolower( $manifest['sha256'] );
	if ( 64 !== strlen( $sha256 ) || ! ctype_xdigit( $sha256 ) ) {
		return null;
	}
	$optional_limits = array(
		'name'                => 200,
		'requires'            => 32,
		'requires_php'        => 32,
		'tested'              => 32,
		'details_url'         => 2048,
		'description'         => 32768,
		'changelog'           => 98304,
		'signature_version'   => 8,
		'signature_algorithm' => 32,
		'key_id'               => 80,
		'signature'            => 128,
	);
	foreach ( $optional_limits as $field => $maximum ) {
		if ( array_key_exists( $field, $manifest ) && ( ! is_string( $manifest[ $field ] ) || strlen( $manifest[ $field ] ) > $maximum ) ) {
			return null;
		}
	}
	foreach ( array( 'name', 'requires', 'requires_php', 'tested' ) as $plain_field ) {
		if ( isset( $manifest[ $plain_field ] ) && preg_match( '/[\x00-\x1F\x7F<>]/', $manifest[ $plain_field ] ) ) {
			return null;
		}
	}
	if ( isset( $manifest['details_url'] ) && 'https://www.wpagently.com/' !== $manifest['details_url'] ) {
		return null;
	}
	$manifest['sha256'] = $sha256;
	return $manifest;
}

/**
 * Fetch a manifest through WordPress's SSRF-safe transport and cache it.
 *
 * @param array<string,mixed> $channel Registered channel.
 * @return array<string,mixed>|null
 */
function wpagent_companion_update_manifest( array $channel ): ?array {
	$cache_key = 'wpagent_update_' . md5( $channel['manifest_url'] );
	$cached    = get_site_transient( $cache_key );
	if ( is_array( $cached ) ) {
		$cached = wpagent_companion_validate_update_manifest( $cached );
		if (
			null !== $cached
			&& $cached['slug'] === $channel['slug']
			&& wpagent_companion_verify_update_manifest_signature( $cached, $channel )
		) {
			return $cached;
		}
		delete_site_transient( $cache_key );
	}

	$response = wp_safe_remote_get(
		$channel['manifest_url'],
		array(
			'timeout'             => 8,
			'redirection'         => 0,
			'limit_response_size' => WPAGENT_UPDATE_MAX_MANIFEST_BYTES,
			'headers'             => array(
				'Accept' => 'application/json',
			),
		)
	);
	if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
		return null;
	}
	$content_type = strtolower( trim( strtok( (string) wp_remote_retrieve_header( $response, 'content-type' ), ';' ) ?: '' ) );
	$body         = wp_remote_retrieve_body( $response );
	if ( 'application/json' !== $content_type || ! is_string( $body ) || strlen( $body ) > WPAGENT_UPDATE_MAX_MANIFEST_BYTES ) {
		return null;
	}
	$manifest = wpagent_companion_validate_update_manifest(
		json_decode( $body, true )
	);
	if (
		null === $manifest
		|| $manifest['slug'] !== $channel['slug']
		|| ! wpagent_companion_verify_update_manifest_signature( $manifest, $channel )
	) {
		return null;
	}
	set_site_transient( $cache_key, $manifest, WPAGENT_UPDATE_CACHE_SECONDS );
	return $manifest;
}

/**
 * Register one plugin for the common wpagently update channel.
 */
function wpagent_companion_register_update_channel(
	string $plugin_file,
	string $slug,
	string $version,
	string $manifest_url,
	array $public_keys = array()
): void {
	$GLOBALS['wpagent_update_channels'][ plugin_basename( $plugin_file ) ] = array(
		'plugin'       => plugin_basename( $plugin_file ),
		'slug'         => $slug,
		'version'      => $version,
		'manifest_url' => $manifest_url,
		'public_keys'  => $public_keys,
	);
}

/**
 * Keep Companion updates available in Free and require cached licence proof for Power.
 *
 * The daily licence job refreshes the paid state separately. WordPress update
 * checks therefore never cause an extra Polar request, including in Free.
 */
function wpagent_companion_update_entitled( string $slug = '' ): bool {
	$entitled = 'wp-agent-companion' === $slug
		|| ( function_exists( 'wpagent_companion_license_is_valid' ) && wpagent_companion_license_is_valid() );
	return (bool) apply_filters( 'wpagent_companion_update_entitled', $entitled, $slug );
}

/** Add available releases to WordPress's native Updates screen. */
function wpagent_companion_inject_updates( $transient ) {
	if ( ! is_object( $transient ) ) {
		$transient = new stdClass();
	}
	$transient->response    = is_array( $transient->response ?? null ) ? $transient->response : array();
	$transient->no_update   = is_array( $transient->no_update ?? null ) ? $transient->no_update : array();
	foreach ( $GLOBALS['wpagent_update_channels'] as $channel ) {
		if ( ! wpagent_companion_update_entitled( $channel['slug'] ) ) {
			unset( $transient->response[ $channel['plugin'] ], $transient->no_update[ $channel['plugin'] ] );
			continue;
		}
		$manifest = wpagent_companion_update_manifest( $channel );
		if ( null === $manifest ) {
			continue;
		}
		$item = (object) array(
			'id'           => 'wpagently/' . $channel['slug'],
			'slug'         => $channel['slug'],
			'plugin'       => $channel['plugin'],
			'new_version'  => $manifest['version'],
			'url'          => $manifest['details_url'] ?? 'https://www.wpagently.com/',
			'package'      => $manifest['package'],
			'requires'     => $manifest['requires'] ?? '6.9',
			'requires_php' => $manifest['requires_php'] ?? '8.1',
			'tested'       => $manifest['tested'] ?? '',
		);
		if ( version_compare( $manifest['version'], $channel['version'], '>' ) ) {
			$transient->response[ $channel['plugin'] ] = $item;
			unset( $transient->no_update[ $channel['plugin'] ] );
		} else {
			$transient->no_update[ $channel['plugin'] ] = $item;
			unset( $transient->response[ $channel['plugin'] ] );
		}
	}
	return $transient;
}
add_filter( 'pre_set_site_transient_update_plugins', 'wpagent_companion_inject_updates' );

/** Supply the plugin-information modal for wpagently releases. */
function wpagent_companion_plugin_information( $result, string $action, $args ) {
	if ( 'plugin_information' !== $action || ! is_object( $args ) ) {
		return $result;
	}
	foreach ( $GLOBALS['wpagent_update_channels'] as $channel ) {
		if ( $channel['slug'] !== ( $args->slug ?? '' ) ) {
			continue;
		}
		if ( ! wpagent_companion_update_entitled( $channel['slug'] ) ) {
			return $result;
		}
		$manifest = wpagent_companion_update_manifest( $channel );
		if ( null === $manifest ) {
			return $result;
		}
		return (object) array(
			'name'          => $manifest['name'] ?? $channel['slug'],
			'slug'          => $channel['slug'],
			'version'       => $manifest['version'],
			'author'        => '<a href="https://www.wpagently.com/">wpagently</a>',
			'homepage'      => $manifest['details_url'] ?? 'https://www.wpagently.com/',
			'requires'      => $manifest['requires'] ?? '6.9',
			'requires_php'  => $manifest['requires_php'] ?? '8.1',
			'tested'        => $manifest['tested'] ?? '',
			'download_link' => $manifest['package'],
			'sections'      => array(
				'description' => wp_kses_post( $manifest['description'] ?? '' ),
				'changelog'   => wp_kses_post( $manifest['changelog'] ?? '' ),
			),
		);
	}
	return $result;
}
add_filter( 'plugins_api', 'wpagent_companion_plugin_information', 20, 3 );

/**
 * Download wpagently updates ourselves so the ZIP can be verified before unzip.
 */
function wpagent_companion_verify_update_download( $reply, string $package, $upgrader, array $hook_extra ) {
	// A previous filter may veto the update. Its error must remain authoritative,
	// and must not trigger a manifest request or a second download.
	if ( is_wp_error( $reply ) ) {
		return $reply;
	}
	$plugin = (string) ( $hook_extra['plugin'] ?? '' );
	if ( ! isset( $GLOBALS['wpagent_update_channels'][ $plugin ] ) ) {
		return $reply;
	}
	// WordPress permits earlier filters to handle the download completely. Keep
	// non-file veto values intact. A supplied file path is verified below.
	if ( false !== $reply && ! is_string( $reply ) ) {
		return $reply;
	}
	$channel = $GLOBALS['wpagent_update_channels'][ $plugin ];
	if ( ! wpagent_companion_update_entitled( $channel['slug'] ) ) {
		return new WP_Error( 'wpagent_update_license_required', __( 'Für offizielle Power-Updates ist eine gültige bezahlte Lizenz erforderlich.', 'wp-agent-companion' ) );
	}
	$manifest = wpagent_companion_update_manifest( $channel );
	if ( null === $manifest || ! hash_equals( $manifest['package'], $package ) ) {
		return new WP_Error( 'wpagent_update_unverified_package', __( 'Das wpagently-Update konnte keinem gültigen Release-Manifest zugeordnet werden.', 'wp-agent-companion' ) );
	}
	$owns_file = false;
	if ( is_string( $reply ) ) {
		if ( '' === $reply || str_contains( $reply, "\0" ) || ! is_file( $reply ) || ! is_readable( $reply ) ) {
			return new WP_Error( 'wpagent_update_invalid_local_file', __( 'Die von einem anderen Update-Filter bereitgestellte Datei ist nicht lesbar.', 'wp-agent-companion' ) );
		}
		$file = $reply;
	} else {
		if ( ! function_exists( 'download_url' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}
		$file = download_url( $package, 120 );
		if ( is_wp_error( $file ) ) {
			return $file;
		}
		$owns_file = true;
	}
	$actual = hash_file( 'sha256', $file );
	if ( ! is_string( $actual ) || ! hash_equals( $manifest['sha256'], strtolower( $actual ) ) ) {
		if ( $owns_file ) {
			wp_delete_file( $file );
		}
		return new WP_Error( 'wpagent_update_checksum_mismatch', __( 'Die Prüfsumme des wpagently-Updates stimmt nicht. Die Installation wurde abgebrochen.', 'wp-agent-companion' ) );
	}
	return $file;
}
add_filter( 'upgrader_pre_download', 'wpagent_companion_verify_update_download', 10, 4 );

wpagent_companion_register_update_channel(
	WPAGENT_COMPANION_DIR . 'wp-agent-companion.php',
	'wp-agent-companion',
	WPAGENT_COMPANION_VERSION,
	'https://www.wpagently.com/updates/wp-agent-companion.json',
	WPAGENT_COMPANION_UPDATE_PUBLIC_KEYS
);
