<?php

namespace MediaWiki\Extension\TemplateStyles;

/**
 * @file
 * @license GPL-2.0-or-later
 */

use CSSJanus;
use MediaWiki\Category\TrackingCategories;
use MediaWiki\Config\Config;
use MediaWiki\Config\ConfigFactory;
use MediaWiki\Content\CodeContentHandler;
use MediaWiki\Content\Content;
use MediaWiki\Content\Renderer\ContentParseParams;
use MediaWiki\Content\ValidationParams;
use MediaWiki\Message\Message;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use StatusValue;
use Wikimedia\CSS\Objects\Stylesheet as CSSStylesheet;
use Wikimedia\CSS\Parser\Parser as CSSParser;
use Wikimedia\CSS\Util as CSSUtil;

/**
 * Content handler for sanitized CSS
 */
class TemplateStylesContentHandler extends CodeContentHandler {

	private Config $config;

	public function __construct(
		string $modelId,
		private readonly TrackingCategories $trackingCategories,
		private readonly ConfigFactory $configFactory,
	) {
		parent::__construct( $modelId, [ CONTENT_FORMAT_CSS ] );
	}

	private function getConfig(): Config {
		$this->config ??= $this->configFactory->makeConfig( 'templatestyles' );
		return $this->config;
	}

	/**
	 * @inheritDoc
	 */
	public function validateSave(
		Content $content,
		ValidationParams $validationParams
	) {
		'@phan-var TemplateStylesContent $content';
		return $this->sanitize( $content, [ 'novalue' => true, 'severity' => 'fatal' ] );
	}

	/** @inheritDoc */
	protected function getContentClass() {
		return TemplateStylesContent::class;
	}

	/**
	 * @inheritDoc
	 */
	protected function fillParserOutput(
		Content $content,
		ContentParseParams $cpoParams,
		ParserOutput &$output
	) {
		'@phan-var TemplateStylesContent $content';
		$page = $cpoParams->getPage();
		$parserOptions = $cpoParams->getParserOptions();

		// Inject our warnings into the resulting ParserOutput
		parent::fillParserOutput( $content, $cpoParams, $output );

		if ( $cpoParams->getGenerateHtml() ) {
			$html = "";
			$html .= "<pre class=\"mw-code mw-css\" dir=\"ltr\">\n";
			$html .= htmlspecialchars( $content->getText(), ENT_NOQUOTES );
			$html .= "\n</pre>\n";
		} else {
			$html = '';
		}

		$output->clearWrapperDivClass();
		$output->setContentHolderText( $html );

		$status = $this->sanitize( $content, [
			'novalue' => true,
			'class' => $parserOptions->getWrapOutputClass(),
			'parserOutput' => $output
		] );
		if ( $status->getMessages() ) {
			foreach ( $status->getMessages() as $msg ) {
				$output->addWarningMsgVal( $msg );
			}
			$this->trackingCategories->addTrackingCategory(
				$output,
				'templatestyles-stylesheet-error-category',
				$page
			);
		}
	}

	/**
	 * Handle errors from the CSS parser and/or sanitizer
	 * @param StatusValue $status Object to add errors to
	 * @param array[] $errors Error array
	 * @param string $severity Whether to consider errors as 'warning' or 'fatal'
	 */
	protected static function processErrors( StatusValue $status, array $errors, $severity ) {
		if ( $severity !== 'warning' && $severity !== 'fatal' ) {
			// @codeCoverageIgnoreStart
			throw new \InvalidArgumentException( 'Invalid $severity' );
			// @codeCoverageIgnoreEnd
		}
		foreach ( $errors as $error ) {
			$error[0] = 'templatestyles-error-' . $error[0];
			$status->$severity( ...$error );
		}
	}

	/**
	 * Sanitize the content
	 * @param TemplateStylesContent $content
	 * @param array $options Options are:
	 *  - class: (string) Class to prefix selectors with
	 *  - extraWrapper: (string) Extra simple selector to prefix selectors with
	 *  - flip: (bool) Have CSSJanus flip the stylesheet.
	 *  - minify: (bool) Whether to minify. Default true.
	 *  - novalue: (bool) Don't bother returning the actual stylesheet, just
	 *    fill the Status with warnings.
	 *  - severity: (string) Whether to consider errors as 'warning' or 'fatal'
	 *  - parserOutput: (ParserOutput) Register image links to this parserOutput
	 * @return Status<string>
	 */
	public function sanitize( TemplateStylesContent $content, array $options = [] ) {
		$options += [
			'class' => false,
			'extraWrapper' => null,
			'flip' => false,
			'minify' => true,
			'novalue' => false,
			'severity' => 'warning',
			'parserOutput' => null
		];

		$status = Status::newGood();

		$style = $content->getText();
		$maxSize = $this->getConfig()->get( 'TemplateStylesMaxStylesheetSize' );
		if ( $maxSize !== null && strlen( $style ) > $maxSize ) {
			$status->fatal(
				// Status::getWikiText() chokes on the Message::sizeParam if we
				// don't wrap it in a Message ourself.
				wfMessage( 'templatestyles-size-exceeded', $maxSize, Message::sizeParam( $maxSize ) )
			);
			return $status;
		}

		if ( $options['flip'] ) {
			$style = CSSJanus::transform( $style, true, false );
		}

		$matcherFactory = Hooks::getMatcherFactory();
		$matcherFactory->clearFileNames();

		// Parse it, and collect any errors
		$cssParser = CSSParser::newFromString( $style );
		$stylesheet = $cssParser->parseStylesheet();
		self::processErrors( $status, $cssParser->getParseErrors(), $options['severity'] );

		// Sanitize it, and collect any errors
		$sanitizer = Hooks::getSanitizer(
			$options['class'] ?: 'mw-parser-output', $options['extraWrapper']
		);
		// Just in case
		$sanitizer->clearSanitizationErrors();
		$stylesheet = $sanitizer->sanitize( $stylesheet );
		'@phan-var ?CSSStylesheet $stylesheet';
		self::processErrors( $status, $sanitizer->getSanitizationErrors(), $options['severity'] );
		$sanitizer->clearSanitizationErrors();

		// Stringify it while minifying
		$value = $stylesheet === null ? '' :
			CSSUtil::stringify( $stylesheet, [ 'minify' => $options['minify'] ] );

		// Sanity check, don't allow "</style" if one somehow sneaks through the sanitizer.
		// Also, don't allow "<ABC" if one somehow sneaks through the sanitizer
		// This matters if the style tag is in a foreign namespace (e.g. <svg>) which
		// has different rules from normal HTML.
		if ( preg_match( '!</?[a-z\!]!i', $value ) ) {
			$value = '';
			$status->fatal( 'templatestyles-tag-injection' );
		}

		if ( !$options['novalue'] ) {
			$status->value = $value;

			// Sanity check, don't allow raw U+007F if one somehow sneaks through the sanitizer
			$status->value = strtr( $status->value, [ "\x7f" => '�' ] );
		}

		if ( $options['parserOutput'] ) {
			$fileNames = array_unique( $matcherFactory->getFileNames() );
			foreach ( $fileNames as $file ) {
				$fileTitle = Title::makeTitleSafe( NS_FILE, $file );
				if ( $fileTitle ) {
					$options['parserOutput']->addImage( $fileTitle );
				}
			}
		}

		return $status;
	}
}
