<?php
/**
 * Asset server — streams onnxruntime-web's WASM (+ companion .mjs) from our
 * dist/ folder through a REST endpoint that forces the correct
 * `Content-Type: application/wasm`.
 *
 * Why: many shared / LocalWP / older nginx setups serve `.wasm` files as
 * `application/octet-stream`. Chromium then refuses streaming
 * WebAssembly.compile and falls back to ArrayBuffer instantiation —
 * functional, but it logs "wasm streaming compile failed" in the console
 * on every page load. Buyer-facing console errors are unacceptable on a
 * paid plugin.
 *
 * By routing the WASM (and its .mjs loader) through a REST route we
 * control, we guarantee the right Content-Type regardless of how the host
 * serves static files.
 *
 * Route: GET /wp-json/findastra/v1/asset/<filename>
 *   filename ∈ /^ort-[a-z0-9.-]+\.(wasm|mjs)$/ — strictly scoped to
 *   ORT assets we shipped. Anything else returns 404.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Asset_Server {

	public const ROUTE = '/asset/(?P<file>[A-Za-z0-9._-]+)';

	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	public function register_routes(): void {
		register_rest_route(
			FindAstra_REST_Controller::NAMESPACE,
			self::ROUTE,
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'serve' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'file' => array(
						'type'              => 'string',
						'required'          => true,
						// `sanitize_file_name` collapses consecutive periods,
						// which mangles names like "ort-wasm-…jsep.wasm" into
						// "…jsep_.wasm". The regex below + the validate_callback
						// guard are stricter and don't damage real filenames.
						'sanitize_callback' => static function ( $v ): string {
							$v = is_string( $v ) ? $v : '';
							return (string) preg_replace( '/[^A-Za-z0-9._-]/', '', $v );
						},
						'validate_callback' => array( $this, 'validate_file' ),
					),
				),
			)
		);
	}

	public function validate_file( $value ): bool {
		return is_string( $value ) && (bool) preg_match( '/^ort-[A-Za-z0-9._-]+\.(wasm|mjs)$/', $value );
	}

	public function serve( WP_REST_Request $request ) {
		$file = (string) $request['file'];
		$dist = FINDASTRA_DIR . 'dist/';
		$candidate = $dist . $file;

		// Path traversal guard. We normalize both paths to forward slashes
		// (Windows / Linux), then require the candidate to live inside dist.
		$norm = static fn( string $p ): string => str_replace( '\\', '/', $p );
		$candidate_real = $norm( (string) realpath( $candidate ) );
		$dist_real      = $norm( (string) realpath( $dist ) );

		if ( '' === $candidate_real || '' === $dist_real || 0 !== strpos( $candidate_real, $dist_real ) ) {
			return new WP_Error( 'findastra_asset_not_found', __( 'Asset not found.', 'findastra' ), array( 'status' => 404 ) );
		}
		if ( ! is_readable( $candidate_real ) ) {
			return new WP_Error( 'findastra_asset_not_readable', __( 'Asset not readable.', 'findastra' ), array( 'status' => 404 ) );
		}

		$mime = str_ends_with( $file, '.wasm' ) ? 'application/wasm' : 'text/javascript';

		// Bypass WP's REST response machinery — we need to stream binary bytes
		// with our own headers, not JSON-encoded output.
		header_remove( 'Cache-Control' );
		header( 'Content-Type: ' . $mime );
		header( 'X-Content-Type-Options: nosniff' );
		header( 'Cache-Control: public, max-age=31536000, immutable' );
		header( 'Content-Length: ' . filesize( $candidate_real ) );

		// Streams a large binary asset (the ONNX-runtime WASM / model file) directly to the
		// client; WP_Filesystem would buffer the whole multi-MB file in memory, while readfile()
		// streams it. The path is validated and realpath-checked above.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
		readfile( $candidate_real );
		exit;
	}
}
