<?php
/**
 * Validates uploaded supplier price lists before they're stored.
 *
 * @package EmberPrice
 */

namespace EmberPrice\Ingestion;

use EmberPrice\Core\Settings;

/**
 * Checks a file's extension, real (sniffed) MIME type and size. Pure PHP,
 * no WordPress dependency, so it can run in the Unit test suite.
 */
final class FileValidator {

	public const DEFAULT_MAX_BYTES                   = 20 * 1024 * 1024;
	public const DEFAULT_MAX_XLSX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024;
	public const DEFAULT_MAX_XLSX_ENTRIES            = 10000;

	private const MAX_SIZE_SETTING = 'max_upload_size_mb';

	/**
	 * Allowed extensions mapped to the MIME types their real content may
	 * legitimately sniff as.
	 *
	 * @var array<string, string[]>
	 */
	private const ALLOWED_MIME_TYPES = array(
		'xlsx' => array(
			'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
			'application/zip',
		),
		'xls'  => array(
			'application/vnd.ms-excel',
			'application/x-ole-storage',
			'application/x-cfb',
		),
		'csv'  => array( 'text/csv', 'text/plain' ),
		'pdf'  => array( 'application/pdf' ),
	);

	/**
	 * Maximum allowed file size, in bytes.
	 *
	 * @var int
	 */
	private int $max_bytes;

	/**
	 * Maximum combined uncompressed size of an XLSX archive.
	 *
	 * @var int
	 */
	private int $max_xlsx_uncompressed_bytes;

	/**
	 * Constructor.
	 *
	 * @param int $max_bytes                   Maximum allowed file size, in bytes.
	 * @param int $max_xlsx_uncompressed_bytes Maximum combined uncompressed XLSX size.
	 */
	public function __construct(
		int $max_bytes = self::DEFAULT_MAX_BYTES,
		int $max_xlsx_uncompressed_bytes = self::DEFAULT_MAX_XLSX_UNCOMPRESSED_BYTES
	) {
		$this->max_bytes                   = $max_bytes;
		$this->max_xlsx_uncompressed_bytes = $max_xlsx_uncompressed_bytes;
	}

	/**
	 * Builds an instance using the site's configured max upload size
	 * (`embp_max_upload_size_mb`, default 20 MB).
	 *
	 * @param Settings $settings Source of the configured max upload size.
	 */
	public static function with_max_size_from_settings( Settings $settings ): self {
		$megabytes = $settings->get_int( self::MAX_SIZE_SETTING, 20 );

		return new self( $megabytes * 1024 * 1024 );
	}

	/**
	 * Validates a file already on disk (e.g. a PHP upload tmp_name).
	 *
	 * @param string $tmp_path          Path to the file to validate.
	 * @param string $original_filename The filename the user uploaded, used for its extension.
	 *
	 * @return string[] Human-readable error messages; empty means valid.
	 */
	public function validate( string $tmp_path, string $original_filename ): array {
		if ( ! is_file( $tmp_path ) ) {
			return array( __( 'Uploaded file not found.', 'emberprice' ) );
		}

		$errors    = array();
		$extension = strtolower( pathinfo( $original_filename, PATHINFO_EXTENSION ) );

		if ( ! isset( self::ALLOWED_MIME_TYPES[ $extension ] ) ) {
			$errors[] = sprintf(
				/* translators: 1: file extension, 2: comma-separated list of allowed extensions. */
				__( 'File extension ".%1$s" is not allowed (allowed: %2$s).', 'emberprice' ),
				$extension,
				implode( ', ', array_keys( self::ALLOWED_MIME_TYPES ) )
			);
		}

		$size = filesize( $tmp_path );

		if ( false !== $size && $size > $this->max_bytes ) {
			$errors[] = sprintf(
				/* translators: 1: uploaded file size in bytes, 2: maximum allowed size in bytes. */
				__( 'File size (%1$d bytes) exceeds the maximum allowed (%2$d bytes).', 'emberprice' ),
				$size,
				$this->max_bytes
			);
		}

		if ( isset( self::ALLOWED_MIME_TYPES[ $extension ] ) ) {
			$real_mime = $this->detect_mime_type( $tmp_path );

			if ( ! in_array( $real_mime, self::ALLOWED_MIME_TYPES[ $extension ], true ) ) {
				$errors[] = sprintf(
					/* translators: 1: file extension, 2: detected MIME type. */
					__( 'File content does not match its ".%1$s" extension (detected: %2$s).', 'emberprice' ),
					$extension,
					'' === $real_mime ? 'unknown' : $real_mime
				);
			}
		}

		if ( 'xlsx' === $extension && empty( $errors ) ) {
			array_push( $errors, ...$this->validate_xlsx_archive( $tmp_path ) );
		}

		return $errors;
	}

	/**
	 * Verifies that an XLSX is a bounded, structurally valid OOXML archive.
	 * This rejects arbitrary ZIP files renamed to .xlsx and prevents a
	 * compressed upload from expanding far beyond the HTTP size limit.
	 *
	 * @param string $path XLSX path.
	 *
	 * @return string[] Validation errors.
	 */
	private function validate_xlsx_archive( string $path ): array {
		if ( ! class_exists( \ZipArchive::class ) ) {
			return array( __( 'XLSX validation is unavailable on this server.', 'emberprice' ) );
		}

		$zip = new \ZipArchive();

		if ( true !== $zip->open( $path ) ) {
			return array( __( 'The XLSX archive is damaged or invalid.', 'emberprice' ) );
		}

		$errors              = array();
		$total_uncompressed  = 0;
		$required_entry_seen = array(
			'[Content_Types].xml' => false,
			'xl/workbook.xml'     => false,
		);

		$entry_count = $zip->numFiles; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- ZipArchive exposes this native property name.

		if ( $entry_count > self::DEFAULT_MAX_XLSX_ENTRIES ) {
			$errors[] = __( 'The XLSX archive contains too many files.', 'emberprice' );
		}

		for ( $index = 0; $index < $entry_count && empty( $errors ); $index++ ) {
			$stat = $zip->statIndex( $index );

			if ( false === $stat ) {
				$errors[] = __( 'The XLSX archive could not be inspected safely.', 'emberprice' );
				break;
			}

			$name = (string) ( $stat['name'] ?? '' );

			if ( str_starts_with( $name, '/' ) || str_contains( $name, '../' ) || str_contains( $name, '..\\' ) ) {
				$errors[] = __( 'The XLSX archive contains an unsafe path.', 'emberprice' );
				break;
			}

			$total_uncompressed += (int) ( $stat['size'] ?? 0 );

			if ( $total_uncompressed > $this->max_xlsx_uncompressed_bytes ) {
				$errors[] = __( 'The XLSX archive expands beyond the safe size limit.', 'emberprice' );
				break;
			}

			if ( array_key_exists( $name, $required_entry_seen ) ) {
				$required_entry_seen[ $name ] = true;
			}
		}

		$zip->close();

		if ( empty( $errors ) && in_array( false, $required_entry_seen, true ) ) {
			$errors[] = __( 'The uploaded ZIP is not a valid XLSX workbook.', 'emberprice' );
		}

		return $errors;
	}

	/**
	 * Sniffs a file's real MIME type via fileinfo (not the client-supplied one).
	 *
	 * @param string $path Path to the file to sniff.
	 */
	private function detect_mime_type( string $path ): string {
		$finfo = finfo_open( FILEINFO_MIME_TYPE );

		if ( false === $finfo ) {
			return '';
		}

		// No finfo_close(): finfo is object-based since PHP 8.1 and closes
		// itself; the function is deprecated as of PHP 8.5.
		$mime = finfo_file( $finfo, $path );

		return false !== $mime ? $mime : '';
	}
}
