<?php

namespace SvgUpload;

use WP_PowerSuite\Core\AbstractModule;

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

class Module extends AbstractModule {

	private const MODULE_ID = 'svg-upload';
	private const MODULE_VERSION = '1.1.0';
	private const MAX_SVG_BYTES = 5 * 1024 * 1024;
	private const DANGEROUS_TAGS = [ 'script', 'foreignObject', 'embed', 'object', 'iframe', 'handler', 'base', 'import' ];

	private bool $mime_hooks_registered = false;
	private bool $media_ui_hooks_registered = false;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'SVG Upload', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Allow SVG and SVGZ uploads in the Media Library. Restricted to administrators only.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'rest', 'ajax' ];
	}

	protected function registerHooks(): void {
		add_filter( 'wp_handle_upload_prefilter', [ $this, 'restrictToAdmins' ] );
		add_filter( 'rest_pre_upload_file', [ $this, 'restrictRestUploadToAdmins' ], 10, 2 );
		add_filter( 'wp_check_filetype_and_ext', [ $this, 'fixFiletypeAndExt' ], 10, 4 );
		add_filter( 'intermediate_image_sizes_advanced', [ $this, 'noSubsizesForSvg' ], 10, 3 );

		add_action( 'load-upload.php', [ $this, 'registerMediaUiHooks' ] );
		add_action( 'load-media.php', [ $this, 'registerMediaUiHooks' ] );
		add_action( 'wp_enqueue_media', [ $this, 'registerMediaUiHooks' ] );
		add_action( 'rest_api_init', [ $this, 'registerMimeHooks' ] );
	}

	public function registerMimeHooks(): void {
		if ( $this->mime_hooks_registered ) {
			return;
		}

		$this->mime_hooks_registered = true;
		add_filter( 'upload_mimes', [ $this, 'allowSvgMimes' ] );
	}

	public function registerMediaUiHooks(): void {
		$this->registerMimeHooks();

		if ( $this->media_ui_hooks_registered ) {
			return;
		}

		$this->media_ui_hooks_registered = true;
		add_filter( 'wp_prepare_attachment_for_js', [ $this, 'svgPreviewSizes' ] );
	}

	public function allowSvgMimes( array $mimes ): array {
		if ( ! current_user_can( 'manage_options' ) ) {
			return $mimes;
		}

		$mimes['svg']  = 'image/svg+xml';
		$mimes['svgz'] = 'image/svg+xml';

		return $mimes;
	}

	public function fixFiletypeAndExt( $data, $file, $filename, $mimes ) {
		unset( $file, $mimes );

		if ( ! is_array( $data ) ) {
			return $data;
		}

		$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
		if ( $ext !== 'svg' && $ext !== 'svgz' ) {
			return $data;
		}

		$data['ext']             = $ext;
		$data['type']            = 'image/svg+xml';
		$data['proper_filename'] = $data['proper_filename'] ?? $filename;

		return $data;
	}

	public function restrictToAdmins( $file ): array {
		if ( ! is_array( $file ) || ! isset( $file['name'] ) ) {
			return $file;
		}

		$ext = strtolower( pathinfo( (string) $file['name'], PATHINFO_EXTENSION ) );
		if ( $ext !== 'svg' && $ext !== 'svgz' ) {
			return $file;
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			$file['error'] = __( 'SVG uploads are restricted to administrators.', 'wp-powersuite' );
			return $file;
		}

		$tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
		if ( $tmp === '' || ! $this->sanitizeSvgFile( $tmp ) ) {
			$file['error'] = __( 'File is not a valid SVG.', 'wp-powersuite' );
		}

		return $file;
	}

	public function restrictRestUploadToAdmins( $file, $request ) {
		unset( $request );

		if ( ! is_array( $file ) ) {
			return $file;
		}

		$name = isset( $file['name'] ) ? (string) $file['name'] : '';
		$ext  = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
		$type = isset( $file['type'] ) ? (string) $file['type'] : '';
		$is_svg = ( $ext === 'svg' || $ext === 'svgz' ) || $type === 'image/svg+xml';

		if ( ! $is_svg ) {
			return $file;
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			return new \WP_Error(
				'svg_upload_restricted',
				__( 'SVG uploads are restricted to administrators.', 'wp-powersuite' ),
				[ 'status' => 403 ]
			);
		}

		$tmp = isset( $file['tmp_name'] ) ? (string) $file['tmp_name'] : '';
		if ( $tmp === '' || ! $this->sanitizeSvgFile( $tmp ) ) {
			return new \WP_Error(
				'svg_upload_invalid',
				__( 'File is not a valid SVG.', 'wp-powersuite' ),
				[ 'status' => 400 ]
			);
		}

		return $file;
	}

	private function sanitizeSvgFile( string $path ): bool {
		if ( ! is_file( $path ) || ! is_readable( $path ) ) {
			return false;
		}

		$size = filesize( $path );
		if ( $size === false || $size === 0 || $size > self::MAX_SVG_BYTES ) {
			return false;
		}

		$contents = file_get_contents( $path );
		if ( $contents === false || $contents === '' ) {
			return false;
		}

		$is_gzipped = strncmp( $contents, "\x1f\x8b", 2 ) === 0;
		if ( $is_gzipped ) {
			$decoded = gzdecode( $contents );
			if ( $decoded === false || strlen( $decoded ) > self::MAX_SVG_BYTES ) {
				return false;
			}
			$contents = $decoded;
		}

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

		if ( ! $loaded ) {
			return false;
		}

		$root = $dom->documentElement;
		if ( ! $root instanceof \DOMElement || strtolower( $root->nodeName ) !== 'svg' ) {
			return false;
		}

		$this->removeSvgElements( $dom, self::DANGEROUS_TAGS );
		$this->removeExternalSvgUseElements( $dom );
		$this->removeUnsafeStyleNodes( $dom );
		$this->sanitizeSvgAttributes( $dom );

		$output = $dom->saveXML();
		if ( $output === false ) {
			return false;
		}

		if ( $is_gzipped ) {
			$output = gzencode( $output );
			if ( $output === false ) {
				return false;
			}
		}

		return file_put_contents( $path, $output ) !== false;
	}

	private function removeSvgElements( \DOMDocument $dom, array $tags ): void {
		foreach ( $tags as $tag ) {
			$nodes     = $dom->getElementsByTagName( $tag );
			$to_remove = [];
			foreach ( $nodes as $node ) {
				$to_remove[] = $node;
			}
			foreach ( $to_remove as $node ) {
				if ( $node->parentNode ) {
					$node->parentNode->removeChild( $node );
				}
			}
		}
	}

	private function removeExternalSvgUseElements( \DOMDocument $dom ): void {
		$uses      = $dom->getElementsByTagName( 'use' );
		$to_remove = [];

		foreach ( $uses as $use ) {
			if ( ! $use instanceof \DOMElement ) {
				continue;
			}

			$href = '';
			if ( $use->hasAttribute( 'href' ) ) {
				$href = trim( (string) $use->getAttribute( 'href' ) );
			} elseif ( $use->hasAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ) ) {
				$href = trim( (string) $use->getAttributeNS( 'http://www.w3.org/1999/xlink', 'href' ) );
			}

			if ( $href === '' || $href[0] === '#' ) {
				continue;
			}

			$lower = strtolower( $href );
			if (
				str_starts_with( $lower, 'javascript:' )
				|| str_starts_with( $lower, 'data:' )
				|| str_starts_with( $lower, 'http://' )
				|| str_starts_with( $lower, 'https://' )
				|| str_starts_with( $lower, '//' )
			) {
				$to_remove[] = $use;
			}
		}

		foreach ( $to_remove as $node ) {
			if ( $node->parentNode ) {
				$node->parentNode->removeChild( $node );
			}
		}
	}

	private function removeUnsafeStyleNodes( \DOMDocument $dom ): void {
		$styles     = $dom->getElementsByTagName( 'style' );
		$to_remove  = [];

		foreach ( $styles as $style ) {
			$css = strtolower( (string) $style->textContent );
			if ( str_contains( $css, '@import' ) || str_contains( $css, 'expression(' ) ) {
				$to_remove[] = $style;
			}
		}

		foreach ( $to_remove as $style ) {
			if ( $style->parentNode ) {
				$style->parentNode->removeChild( $style );
			}
		}
	}

	private function sanitizeSvgAttributes( \DOMDocument $dom ): void {
		$xpath = new \DOMXPath( $dom );
		$nodes = $xpath->query( '//*' );

		if ( ! $nodes ) {
			return;
		}

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

			$remove = [];
			foreach ( $node->attributes as $attr ) {
				$attr_name = $attr->nodeName;
				$lower     = strtolower( $attr_name );

				if ( str_starts_with( $lower, 'on' ) ) {
					$remove[] = $attr_name;
					continue;
				}

				if ( $lower === 'href' || $lower === 'xlink:href' || $lower === 'src' ) {
					$value = strtolower( trim( (string) $attr->nodeValue ) );
					if (
						str_starts_with( $value, 'javascript:' )
						|| str_starts_with( $value, 'data:' )
						|| str_starts_with( $value, 'vbscript:' )
					) {
						$remove[] = $attr_name;
					}
				}
			}

			foreach ( $remove as $attr_name ) {
				$node->removeAttribute( $attr_name );
			}
		}
	}

	public function svgPreviewSizes( $response ): array {
		if ( ! is_array( $response ) || ( $response['mime'] ?? '' ) !== 'image/svg+xml' ) {
			return $response;
		}

		$response['sizes'] = [
			'full' => [
				'url'    => $response['url'] ?? '',
				'width'  => null,
				'height' => null,
			],
		];

		return $response;
	}

	public function noSubsizesForSvg( $sizes, $metadata, $attachment_id ): array {
		unset( $metadata );

		if ( get_post_mime_type( $attachment_id ) === 'image/svg+xml' ) {
			return [];
		}

		return $sizes;
	}
}
