<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\FileIntegrity;

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

/**
 * Bounded image type verifier used by deep scans.
 */
class ImageTypeVerifier {

	private const HEADER_BYTES = 16384;
	private const SVG_BYTES    = 65536;

	private const SUPPORTED_EXTENSIONS = [ 'jpg', 'jpeg', 'png', 'gif', 'ico', 'svg', 'webp' ];

	/**
	 * @param string $extension Lowercase file extension without dot.
	 * @return bool
	 */
	public static function is_supported_extension( string $extension ): bool {
		return in_array( self::normalize_extension( $extension ), self::SUPPORTED_EXTENSIONS, true );
	}

	/**
	 * Decide whether a failed image payload check is security-relevant enough
	 * to surface as a fake-image finding.
	 *
	 * @param array<string,mixed> $result Verifier result.
	 * @return bool
	 */
	public static function is_security_finding( array $result ): bool {
		if ( ! empty( $result['valid'] ) ) {
			return false;
		}

		$reason = strtolower( trim( (string) ( $result['reason'] ?? '' ) ) );
		if ( in_array( $reason, [ 'non_image_payload', 'non_image_mime', 'embedded_code_payload', 'svg_active_content' ], true ) ) {
			return true;
		}

		if ( $reason === 'missing_svg_root' ) {
			$detected_type = strtolower( trim( (string) ( $result['detected_type'] ?? '' ) ) );
			return in_array( $detected_type, [ 'php', 'html' ], true );
		}

		return false;
	}

	/**
	 * Verify that a file with an image extension is actually an image payload.
	 *
	 * @param string $file_path Absolute path.
	 * @param string $extension Extension without dot.
	 * @return array<string,mixed>
	 */
	public static function verify( string $file_path, string $extension ): array {
		$extension = self::normalize_extension( $extension );
		$result    = self::base_result( $extension );

		if ( ! in_array( $extension, self::SUPPORTED_EXTENSIONS, true ) ) {
			$result['reason'] = 'unsupported_image_extension';
			return $result;
		}

		if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) {
			$result['reason'] = 'unreadable_image';
			return $result;
		}

		$size = @filesize( $file_path );
		if ( $size === false || (int) $size <= 0 ) {
			$result['reason'] = 'empty_image';
			return $result;
		}
		$result['file_size'] = (int) $size;

		if ( 'svg' === $extension ) {
			return self::verify_svg( $file_path, $result );
		}

		$header = @file_get_contents( $file_path, false, null, 0, min( self::HEADER_BYTES, (int) $size ) );
		if ( ! is_string( $header ) || '' === $header ) {
			$result['reason'] = 'header_read_failed';
			return $result;
		}
		$result['bytes_checked'] = strlen( $header );

		$mime = self::detect_mime( $file_path );
		if ( '' !== $mime ) {
			$result['detected_mime'] = $mime;
		}

		$magic_type = self::detect_magic_type( $header );
		$result['detected_type'] = $magic_type;
		if ( ! self::is_supported_image_type( $magic_type ) ) {
			$result['reason'] = '' === $magic_type ? 'missing_image_magic' : 'non_image_payload';
			return $result;
		}

		$image_size = self::read_image_size( $file_path );
		if ( null !== $image_size ) {
			$parser_type = self::image_type_from_getimagesize( $image_size );
			if ( '' !== $parser_type ) {
				$result['parser_type'] = $parser_type;
			}
			if ( '' !== $parser_type && $parser_type !== $magic_type ) {
				$result['reason'] = 'image_parser_mismatch';
				return $result;
			}
		} elseif ( in_array( $magic_type, [ 'jpg', 'png', 'gif' ], true ) ) {
			$result['reason'] = 'image_parser_failed';
			return $result;
		}

		if ( '' !== $mime ) {
			if ( self::is_dangerous_non_image_mime( $mime ) ) {
				$result['reason'] = 'non_image_mime';
				return $result;
			}
		}

		$result['valid']  = true;
		$result['reason'] = 'valid_image';
		return $result;
	}

	/**
	 * @param string $extension
	 * @return string
	 */
	private static function normalize_extension( string $extension ): string {
		$extension = strtolower( ltrim( trim( $extension ), '.' ) );
		return 'jpeg' === $extension ? 'jpg' : $extension;
	}

	/**
	 * @param string $extension
	 * @return array<string,mixed>
	 */
	private static function base_result( string $extension ): array {
		return [
			'valid'              => false,
			'reason'             => 'unknown',
			'expected_extension' => $extension,
			'expected_mime'      => self::expected_mimes( $extension ),
			'detected_type'      => '',
			'parser_type'        => '',
			'detected_mime'      => '',
			'bytes_checked'      => 0,
			'file_size'          => 0,
		];
	}

	/**
	 * @param string $file_path
	 * @param array<string,mixed> $result
	 * @return array<string,mixed>
	 */
	private static function verify_svg( string $file_path, array $result ): array {
		$size    = (int) ( $result['file_size'] ?? 0 );
		$content = @file_get_contents( $file_path, false, null, 0, min( self::SVG_BYTES, $size ) );
		if ( ! is_string( $content ) || '' === $content ) {
			$result['reason'] = 'header_read_failed';
			return $result;
		}

		$result['bytes_checked'] = strlen( $content );

		$binary_type = self::detect_magic_type( $content );
		if ( self::is_supported_image_type( $binary_type ) ) {
			$result['detected_type'] = $binary_type;
			$result['valid']         = true;
			$result['reason']        = 'valid_image';
			return $result;
		}

		$trimmed = self::strip_utf8_bom( ltrim( $content ) );
		if ( stripos( $trimmed, '<svg' ) === false ) {
			$result['reason']        = 'missing_svg_root';
			$result['detected_type'] = self::detect_text_payload_type( $trimmed );
			return $result;
		}
		$result['detected_type'] = 'svg';

		if ( self::svg_has_active_content( $trimmed ) ) {
			$result['reason'] = 'svg_active_content';
			return $result;
		}

		$mime = self::detect_mime( $file_path );
		if ( '' !== $mime ) {
			$result['detected_mime'] = $mime;
			if ( ! in_array( $mime, self::expected_mimes( 'svg' ), true ) && self::is_dangerous_non_image_mime( $mime ) ) {
				$result['reason'] = 'non_image_mime';
				return $result;
			}
		}

		$result['valid']  = true;
		$result['reason'] = 'valid_image';
		return $result;
	}

	/**
	 * @param string $header
	 * @return string
	 */
	private static function detect_magic_type( string $header ): string {
		if ( strncmp( $header, "\xFF\xD8\xFF", 3 ) === 0 ) {
			return 'jpg';
		}
		if ( strncmp( $header, "\x89PNG\x0D\x0A\x1A\x0A", 8 ) === 0 ) {
			return 'png';
		}
		if ( strncmp( $header, 'GIF87a', 6 ) === 0 || strncmp( $header, 'GIF89a', 6 ) === 0 ) {
			return 'gif';
		}
		if ( strlen( $header ) >= 12 && strncmp( $header, 'RIFF', 4 ) === 0 && substr( $header, 8, 4 ) === 'WEBP' ) {
			return 'webp';
		}
		if ( strncmp( $header, "\x00\x00\x01\x00", 4 ) === 0 ) {
			return 'ico';
		}

		return self::detect_text_payload_type( $header );
	}

	/**
	 * @param string $content
	 * @return string
	 */
	private static function detect_text_payload_type( string $content ): string {
		$sample = strtolower( substr( ltrim( self::strip_utf8_bom( $content ) ), 0, 256 ) );
		if ( strpos( $sample, '<?php' ) === 0 || strpos( $sample, '<?=' ) === 0 ) {
			return 'php';
		}
		if ( strpos( $sample, '<!doctype html' ) === 0 || strpos( $sample, '<html' ) === 0 ) {
			return 'html';
		}
		if ( strpos( $sample, '<svg' ) !== false ) {
			return 'svg';
		}
		if ( preg_match( '/^[\x09\x0A\x0D\x20-\x7E]+$/', $sample ) ) {
			return 'text';
		}
		return '';
	}

	/**
	 * @param string $type
	 * @return bool
	 */
	private static function is_supported_image_type( string $type ): bool {
		return in_array( $type, [ 'jpg', 'png', 'gif', 'ico', 'webp' ], true );
	}

	/**
	 * @param string $file_path
	 * @return array<int,mixed>|null
	 */
	private static function read_image_size( string $file_path ) {
		$image_size = @getimagesize( $file_path );

		return is_array( $image_size ) ? $image_size : null;
	}

	/**
	 * @param array<int,mixed> $image_size
	 * @return string
	 */
	private static function image_type_from_getimagesize( array $image_size ): string {
		$type = isset( $image_size[2] ) ? (int) $image_size[2] : 0;
		switch ( $type ) {
			case IMAGETYPE_JPEG:
				return 'jpg';
			case IMAGETYPE_PNG:
				return 'png';
			case IMAGETYPE_GIF:
				return 'gif';
			case IMAGETYPE_ICO:
				return 'ico';
			default:
				if ( defined( 'IMAGETYPE_WEBP' ) && $type === IMAGETYPE_WEBP ) {
					return 'webp';
				}
				return '';
		}
	}

	/**
	 * @param string $file_path
	 * @return string
	 */
	private static function detect_mime( string $file_path ): string {
		if ( ! function_exists( 'finfo_open' ) ) {
			return '';
		}

		$finfo = @finfo_open( FILEINFO_MIME_TYPE );
		if ( ! $finfo ) {
			return '';
		}
		$mime = @finfo_file( $finfo, $file_path );
		@finfo_close( $finfo );

		return is_string( $mime ) ? strtolower( trim( $mime ) ) : '';
	}

	/**
	 * @param string $extension
	 * @return array<int,string>
	 */
	private static function expected_mimes( string $extension ): array {
		switch ( $extension ) {
			case 'jpg':
				return [ 'image/jpeg' ];
			case 'png':
				return [ 'image/png' ];
			case 'gif':
				return [ 'image/gif' ];
			case 'webp':
				return [ 'image/webp' ];
			case 'ico':
				return [ 'image/x-icon', 'image/vnd.microsoft.icon' ];
			case 'svg':
				return [ 'image/svg+xml', 'text/xml', 'application/xml', 'text/plain' ];
			default:
				return [];
		}
	}

	/**
	 * @param string $mime
	 * @return bool
	 */
	private static function is_dangerous_non_image_mime( string $mime ): bool {
		return in_array(
			$mime,
			[
				'application/x-httpd-php',
				'application/x-httpd-php-source',
				'application/x-php',
				'application/javascript',
				'text/html',
				'text/javascript',
				'text/x-php',
				'text/x-script.php',
				'text/plain',
			],
			true
		);
	}

	/**
	 * @param string $content
	 * @return bool
	 */
	private static function svg_has_active_content( string $content ): bool {
		return (bool) preg_match(
			'/<\s*script\b|javascript\s*:|data\s*:\s*text\/html|<\s*foreignObject\b|\son[a-z]+\s*=/i',
			$content
		);
	}

	/**
	 * @param string $content
	 * @return string
	 */
	private static function strip_utf8_bom( string $content ): string {
		return strncmp( $content, "\xEF\xBB\xBF", 3 ) === 0 ? substr( $content, 3 ) : $content;
	}
}
