<?php
/**
 * Opt-in SVG upload support for taxonomy/custom icons.
 *
 * Disabled by default. When the `allow_svg_uploads` setting is on, users with
 * `manage_options` may upload SVG files to the media library; every upload is
 * checked and rejected outright if it contains active content (scripts, event
 * handlers, external references, DOCTYPE/entities). Mirrors the Elementor
 * "enable unfiltered uploads" pattern requested in tester feedback #15.
 *
 * @package ThemeREX\AutoDealer\Core
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\Core;

defined( 'ABSPATH' ) || exit;

use ThemeREX\AutoDealer\Admin\AdminSettings;

final class SvgUploads {

	/**
	 * Register upload filters. Each callback self-gates on the setting +
	 * capability, so unconditional registration is safe.
	 */
	public function register(): void {
		add_filter( 'upload_mimes', array( $this, 'addSvgMime' ) );
		add_filter( 'wp_check_filetype_and_ext', array( $this, 'fixSvgFiletype' ), 10, 4 );
		// BOTH prefilters: form uploads (media modal, async-upload.php) run
		// wp_handle_upload; REST media "simple uploads" and programmatic
		// imports run wp_handle_sideload. Hooking only the first leaves the
		// REST path unscanned (caught in the 2026-08-28 walkthrough).
		add_filter( 'wp_handle_upload_prefilter', array( $this, 'rejectUnsafeSvg' ) );
		add_filter( 'wp_handle_sideload_prefilter', array( $this, 'rejectUnsafeSvg' ) );
	}

	/**
	 * SVG uploads are allowed only when the admin opted in AND the current
	 * user is a site administrator (SVG is an XML document that can carry
	 * scripts — never open it to lower roles).
	 */
	private function isEnabled(): bool {
		$settings = AdminSettings::getSettings();

		return ! empty( $settings['allow_svg_uploads'] ) && current_user_can( 'manage_options' );
	}

	/**
	 * @param array<string, string> $mimes Allowed mime map (ext regex => mime).
	 * @return array<string, string>
	 */
	public function addSvgMime( array $mimes ): array {
		if ( $this->isEnabled() ) {
			$mimes['svg'] = 'image/svg+xml';
		}

		return $mimes;
	}

	/**
	 * WP's finfo-based sniffing does not resolve SVG on every PHP build —
	 * without this the upload dies with "Sorry, you are not allowed to upload
	 * this file type" even when the mime is whitelisted.
	 *
	 * @param array{ext: string|false, type: string|false, proper_filename: string|false} $data     Detected values.
	 * @param string                                                                      $file     Full path to the file.
	 * @param string                                                                      $filename The name of the file.
	 * @param string[]|null                                                               $mimes    Allowed mime types.
	 * @return array{ext: string|false, type: string|false, proper_filename: string|false}
	 */
	public function fixSvgFiletype( array $data, string $file, string $filename, ?array $mimes ): array {
		if ( ! $this->isEnabled() ) {
			return $data;
		}

		if ( str_ends_with( strtolower( $filename ), '.svg' ) && empty( $data['ext'] ) ) {
			$data['ext']  = 'svg';
			$data['type'] = 'image/svg+xml';
		}

		return $data;
	}

	/**
	 * Reject SVG files carrying active content before WP moves them into the
	 * uploads dir. Rejecting (instead of rewriting) keeps the check simple and
	 * fail-safe.
	 *
	 * @param array{name: string, type: string, tmp_name: string, error: int|string, size: int} $file Upload data.
	 * @return array{name: string, type: string, tmp_name: string, error: int|string, size: int}
	 */
	public function rejectUnsafeSvg( array $file ): array {
		$name = isset( $file['name'] ) ? strtolower( (string) $file['name'] ) : '';
		if ( ! str_ends_with( $name, '.svg' ) ) {
			return $file;
		}

		if ( ! $this->isEnabled() ) {
			$file['error'] = __( 'SVG uploads are disabled. Enable them in Auto Dealer settings (administrators only).', 'trx-auto-dealer' );
			return $file;
		}

		// Reading the just-uploaded tmp file — WP_Filesystem is not applicable
		// in the prefilter. phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown
		$contents = file_get_contents( (string) $file['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents

		if ( false === $contents || '' === trim( $contents ) || ! $this->isSafeSvg( $contents ) ) {
			$file['error'] = __( 'This SVG was rejected: it is malformed or contains disallowed content (scripts, event handlers, DOCTYPE declarations, or external references).', 'trx-auto-dealer' );
		}

		return $file;
	}

	/**
	 * Conservative allow-check for an SVG document.
	 */
	private function isSafeSvg( string $svg ): bool {
		// Strip a UTF-8 BOM before parsing.
		if ( str_starts_with( $svg, "\xEF\xBB\xBF" ) ) {
			$svg = substr( $svg, 3 );
		}

		// DOCTYPE / entity declarations open the door to XXE and entity
		// expansion — no legitimate icon needs them.
		if ( false !== stripos( $svg, '<!DOCTYPE' ) || false !== stripos( $svg, '<!ENTITY' ) ) {
			return false;
		}

		$previous = libxml_use_internal_errors( true );
		$dom      = new \DOMDocument();
		$loaded   = $dom->loadXML( $svg, LIBXML_NONET );
		libxml_clear_errors();
		libxml_use_internal_errors( $previous );

		if ( false === $loaded || null === $dom->documentElement ) {
			return false;
		}

		if ( 'svg' !== strtolower( $dom->documentElement->localName ?? '' ) ) {
			return false;
		}

		$disallowedTags = array( 'script', 'foreignobject', 'iframe', 'object', 'embed', 'animate', 'set', 'handler' );

		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( '//*' );
		if ( false === $nodes ) {
			return false;
		}

		foreach ( $nodes as $node ) {
			if ( ! $node instanceof \DOMElement ) {
				continue;
			}

			if ( in_array( strtolower( $node->localName ?? '' ), $disallowedTags, true ) ) {
				return false;
			}

			foreach ( $node->attributes as $attr ) {
				if ( ! $attr instanceof \DOMAttr ) {
					continue;
				}

				$attrName  = strtolower( $attr->nodeName );
				$attrValue = strtolower( trim( $attr->value ) );

				// Event handlers (onload, onclick, ...).
				if ( str_starts_with( $attrName, 'on' ) ) {
					return false;
				}

				// javascript:/data: URLs and cross-document references in any
				// href/xlink:href/src-style attribute.
				if ( str_contains( $attrValue, 'javascript:' ) || str_contains( $attrValue, 'data:text' ) ) {
					return false;
				}
				if ( ( 'href' === $attrName || 'xlink:href' === $attrName || 'src' === $attrName )
					&& '' !== $attrValue && ! str_starts_with( $attrValue, '#' ) ) {
					return false;
				}
			}
		}

		return true;
	}
}
