<?php

namespace YahnisElsts\AdminMenuEditor\StyleGenerator;

use YahnisElsts\AdminMenuEditor\Customizable\Settings\AbstractSetting;
use YahnisElsts\AdminMenuEditor\ProCustomizable\CssValueGenerator;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\DslFunctions;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\FunctionCall;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\Expression;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\JsFunctionCall;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\SettingReference;
use YahnisElsts\AdminMenuEditor\StyleGenerator\Dsl\VariableReference;

class StyleGenerator {
	/**
	 * @var CssStatement[]
	 */
	private array $statements = [];

	/**
	 * @var array<string, Expression[]>
	 */
	private array $variables = [];

	private array $variablesBeingResolved = [];

	/**
	 * @var string[] List of CSS selectors that match stylesheets (`<link>` elements)
	 * that should be disabled while previewing styles generated by this generator.
	 */
	private array $stylesheetsToDisable = [];

	/**
	 * @param string[] $selectors
	 * @param array $declarations
	 * @return $this
	 */
	public function addRuleSet(array $selectors, array $declarations): self {
		if ( empty($selectors) || empty($declarations) ) {
			return $this; //This ruleset is empty, no need to add it.
		}

		//In some cases, ruleset order matters because later styles override earlier
		//ones. Let's preserve the order by putting all rulesets in the same array,
		//even if they don't have any (real) conditions.
		$ruleset = new CssRuleSet($selectors, $declarations);
		$this->addCondition(Expression::true(), $ruleset);
		return $this;
	}

	/**
	 * @param AbstractSetting $setting
	 * @param string $op
	 * @param mixed $matchValue
	 * @param CssStatement[] $statements
	 * @return $this
	 */
	public function addSimpleCondition(AbstractSetting $setting, string $op, $matchValue, ...$statements): self {
		if ( empty($statements) ) {
			return $this; //This condition has no effect.
		}

		return $this->addCondition(
			$this->compare($setting, $op, $matchValue),
			...$statements
		);
	}

	public function addCondition(Expression $expression, ...$statements): self {
		if ( empty($statements) ) {
			return $this;
		}

		$this->statements[] = new IfStatement($expression, $statements);
		return $this;
	}

	/**
	 * @param Expression $conditionExpr
	 * @param string $mediaCondition
	 * @param CssStatement ...$statements
	 * @return $this
	 */
	public function addMediaQuery(Expression $conditionExpr, string $mediaCondition, ...$statements): self {
		if ( empty($statements) ) {
			return $this;
		}

		$mediaQuery = new CssMediaQuery($mediaCondition, $statements);
		$this->addCondition($conditionExpr, $mediaQuery);
		return $this;
	}

	//region DSL

	/**
	 * @param string $name
	 * @param mixed $value
	 * @return $this
	 */
	public function setVariable($name, ...$value): self {
		$this->variables[$name] = Expression::boxValues($value);
		return $this;
	}

	/**
	 * @param array<string,array> $variables
	 * @return $this
	 */
	public function setVariables(array $variables): self {
		foreach ($variables as $name => $possibleValues) {
			if ( !is_array($possibleValues) ) {
				$possibleValues = [$possibleValues];
			}
			$this->setVariable($name, ...$possibleValues);
		}
		return $this;
	}

	public function variable($name): VariableReference {
		return new VariableReference($name, $this);
	}

	/**
	 * @param string $name
	 * @param mixed|null $defaultValue
	 * @return numeric|string|null
	 */
	public function resolveVariable($name, $defaultValue = null) {
		if ( !isset($this->variables[$name]) ) {
			return $defaultValue;
		}

		//Check for circular references.
		if ( isset($this->variablesBeingResolved[$name]) ) {
			throw new \LogicException(sprintf(
				"Circular reference detected while resolving variable '%s'.",
				$name
			));
		}
		$this->variablesBeingResolved[$name] = true;

		$cssValue = $this->dslFirstNonEmpty($this->variables[$name], $defaultValue);

		unset($this->variablesBeingResolved[$name]);
		return $cssValue;
	}

	/**
	 * @param string $variableName
	 * @return array{0: bool, 1: bool} [usesSettings, hasNonEmptySettings]
	 */
	public function checkSettingsUsedByVariable(string $variableName) {
		if ( !isset($this->variables[$variableName]) ) {
			return [false, false]; //This variable does not exist.
		}

		$usesSettings = false;

		foreach ($this->variables[$variableName] as $value) {
			if ( !($value instanceof Expression) ) {
				continue;
			}
			list($valueUsesSettings, $valueHasNonEmptySettings) = $value->checkUsedSettingStatus();
			if ( $valueHasNonEmptySettings ) {
				return [true, true];
			}
			$usesSettings = $usesSettings || $valueUsesSettings;
		}
		return [$usesSettings, false];
	}

	/**
	 * @param Expression[] $inputs
	 * @return mixed|null
	 */
	public function dslFirstNonEmpty(array $inputs, $defaultValue = null) {
		//Use the first non-empty value.
		foreach ($inputs as $value) {
			if ( $value instanceof Expression ) {
				$cssValue = $value->getValue();
			} else {
				throw new \InvalidArgumentException(sprintf(
					"Unboxed value found: %s",
					gettype($value)
				));
			}
			if ( !self::isEmptyCssValue($cssValue) ) {
				return $cssValue;
			}
		}
		return $defaultValue;
	}

	public function firstNonEmpty($values): FunctionCall {
		return new FunctionCall(
			'firstNonEmpty',
			$values,
			[DslFunctions::class, 'runFirstNonEmpty']
		);
	}

	/**
	 * Set the hue, saturation, or lightness of a color.
	 *
	 * @param Expression $inputColor
	 * @param numeric|null $hue
	 * @param numeric|null $saturation
	 * @param numeric|null $lightness
	 * @return FunctionCall
	 */
	public function editHexAsHsl(Expression $inputColor, $hue, $saturation, $lightness): FunctionCall {
		return new FunctionCall(
			'editHexAsHsl',
			[
				'color'      => $inputColor,
				'hue'        => $hue,
				'saturation' => $saturation,
				'lightness'  => $lightness,
			],
			[DslFunctions::class, 'runEditHexAsHsl']
		);
	}

	/**
	 * Increase or decrease the hue, saturation, or lightness of a color by a specified amount.
	 *
	 * @param Expression $inputColor
	 * @param numeric|null $hue
	 * @param numeric|null $saturation
	 * @param numeric|null $lightness
	 * @return FunctionCall
	 */
	public function adjustHexAsHsl(Expression $inputColor, $hue, $saturation, $lightness): FunctionCall {
		return new FunctionCall(
			'adjustHexAsHsl',
			[
				'color'      => $inputColor,
				'hue'        => $hue,
				'saturation' => $saturation,
				'lightness'  => $lightness,
			],
			[DslFunctions::class, 'runAdjustHexAsHsl']
		);
	}

	/**
	 * @param Expression $color1 First HEX color.
	 * @param Expression $color2 Second HEX color.
	 * @param numeric $weight A number between 0 and 100.
	 * @return FunctionCall
	 */
	public function mixColors($color1, $color2, $weight) {
		return new FunctionCall(
			'mixColors',
			[
				'color1' => $color1,
				'color2' => $color2,
				'weight' => $weight,
			],
			[DslFunctions::class, 'runMixColors']
		);
	}

	public function darken($color, $amount) {
		return new FunctionCall(
			'changeLightness',
			[
				'color'  => $color,
				'amount' => -$amount,
			],
			[DslFunctions::class, 'runChangeLightness']
		);
	}

	public function lighten($color, $amount) {
		return new FunctionCall(
			'changeLightness',
			[
				'color'  => $color,
				'amount' => $amount,
			],
			[DslFunctions::class, 'runChangeLightness']
		);
	}

	/** @noinspection PhpUndefinedClassInspection
	 * @noinspection PhpLanguageLevelInspection
	 */
	#[\Pure]
	public function condition(Expression $condition, ...$children): CssStatement {
		return new IfStatement($condition, self::flattenChildList($children));
	}

	public function ifLooselyEqual($value1, $value2, $thenResult = true, $elseResult = null) {
		return $this->compare($value1, '==', $value2, $thenResult, $elseResult);
	}

	public function ifTruthy($value, $thenResult = true, $elseResult = null): FunctionCall {
		return new FunctionCall(
			'ifTruthy',
			[
				'value'      => $value,
				'thenResult' => $thenResult,
				'elseResult' => $elseResult,
			],
			[DslFunctions::class, 'runIfTruthy']
		);
	}

	public function ifFalsy($value, $thenResult = true, $elseResult = null): FunctionCall {
		return $this->ifTruthy($value, $elseResult, $thenResult);
	}

	/**
	 * @param array $values
	 * @param mixed $thenResult
	 * @param mixed $elseResult
	 * @return FunctionCall
	 */
	public function ifSome($values, $thenResult = true, $elseResult = null) {
		return new FunctionCall(
			'ifSome',
			[
				'values'     => $values,
				'thenResult' => $thenResult,
				'elseResult' => $elseResult,
			],
			[DslFunctions::class, 'runIfSome']
		);
	}

	/**
	 * @param array $values
	 * @param $thenResult
	 * @param $elseResult
	 * @return FunctionCall
	 */
	public function ifAll($values, $thenResult = true, $elseResult = null) {
		return new FunctionCall(
			'ifAll',
			[
				'values'     => $values,
				'thenResult' => $thenResult,
				'elseResult' => $elseResult,
			],
			[DslFunctions::class, 'runIfAll']
		);
	}

	public function compare($value1, $op, $value2, $thenResult = true, $elseResult = null) {
		return new FunctionCall(
			'compare',
			[
				'value1'     => $value1,
				'op'         => $op,
				'value2'     => $value2,
				'thenResult' => $thenResult,
				'elseResult' => $elseResult,
			],
			[DslFunctions::class, 'runCompare']
		);
	}

	public function ifImageSettingContainsImage($imageValue, $thenResult = true, $elseResult = null) {
		return new FunctionCall(
			'ifImageSettingContainsImage',
			[
				'value'      => $imageValue,
				'thenResult' => $thenResult,
				'elseResult' => $elseResult,
			],
			[DslFunctions::class, 'runIfImageSettingContainsImage']
		);
	}

	public function div($numerator, $denominator): Expression {
		return new FunctionCall(
			'div',
			[
				'numerator'   => $numerator,
				'denominator' => $denominator,
			],
			[DslFunctions::class, 'runDiv']
		);
	}

	public function ceil($value): Expression {
		return new FunctionCall(
			'ceil',
			['value' => $value],
			[DslFunctions::class, 'runCeil']
		);
	}

	/**
	 * Generate an expression that returns the value of a setting formatted for CSS.
	 *
	 * For most settings this is equivalent to the raw setting value, and you can
	 * just use the setting directly instead of calling this method. However, some
	 * settings like CSS length values need special formatting, such as adding the unit.
	 * This method lets you explicitly indicate that you want the formatted value.
	 *
	 * @param AbstractSetting $setting
	 * @return Expression
	 */
	public function cssValue(AbstractSetting $setting) {
		if ( $setting instanceof CssValueGenerator ) {
			return $setting->getCssValueExpression();
		} else {
			return new SettingReference($setting);
		}
	}

	/**
	 *
	 * @param array $selectors
	 * @param CssStatement|array ...$children
	 * @return CssRuleSet
	 * @noinspection PhpUndefinedClassInspection
	 * @noinspection PhpLanguageLevelInspection
	 */
	#[\Pure]
	public function ruleset(array $selectors, ...$children): CssRuleSet {
		return new CssRuleSet($selectors, self::flattenChildList($children));
	}

	//endregion

	/**
	 * @param string[] $cssSelectors
	 * @return $this
	 */
	public function setStylesheetsToDisableOnPreview($cssSelectors) {
		$this->stylesheetsToDisable = $cssSelectors;
		return $this;
	}

	public function getJsPreviewConfiguration(): array {
		$serializedStatements = [];
		foreach ($this->statements as $statement) {
			$serializedStatements[] = $statement->serializeForJs();
		}

		$variables = [];
		foreach ($this->variables as $name => $possibleValues) {
			$variables[$name] = new JsFunctionCall('firstNonEmpty', $possibleValues);
		}

		return [
			'statements'              => $serializedStatements,
			'variables'               => $variables,
			'stylesheetsToDisable'    => $this->stylesheetsToDisable,
			'previewAllOnFirstUpdate' => !empty($this->stylesheetsToDisable),
		];
	}

	/**
	 * @param CssStatement[] $statements
	 * @return array
	 */
	private function getStatementJsConfigurations($statements) {
		$result = [];
		foreach ($statements as $ruleset) {
			$result[] = $ruleset->serializeForJs();
		}
		return $result;
	}

	private function getFlattenedGeneratorConfigs($generators) {
		$generatorConfigs = [];
		foreach ($generators as $generator) {
			$configs = $generator->getJsPreviewConfiguration();
			if ( !empty($configs) ) {
				foreach ($configs as $config) {
					$generatorConfigs[] = $config;
				}
			}
		}
		return $generatorConfigs;
	}

	/**
	 * @return string
	 */
	public function generateCss(): string {
		$blocks = $this->generateStatementCss($this->statements);
		if ( empty($blocks) ) {
			return '';
		}
		return trim(implode("\n", $blocks));
	}

	/**
	 * @param CssStatement[] $statements
	 * @return string[]
	 */
	private function generateStatementCss($statements) {
		$blocks = [];
		foreach ($statements as $statement) {
			$result = $statement->evaluate();
			foreach ($result->getCompiledStatements() as $rule) {
				$css = $rule->getCssText();
				if ( !empty($css) ) {
					$blocks[] = $css;
				}
			}
		}
		return $blocks;
	}

	/**
	 * @param array $condition
	 * @return bool
	 */
	private function checkCondition($condition) {
		$expression = $condition['expression'];
		$conditionResult = $expression->getValue();
		return (bool)$conditionResult;
	}

	public static function isEmptyCssValue($value) {
		return ($value === null) || ($value === '') || (is_array($value) && empty($value));
	}

	private static function flattenChildList(array $nestedList): array {
		if ( empty($nestedList) ) {
			return [];
		}
		if ( count($nestedList) === 1 ) {
			$firstChild = $nestedList[0];
			if ( is_array($firstChild) ) {
				return $firstChild;
			}
			return $nestedList;
		}

		$children = [];
		foreach ($nestedList as $child) {
			if ( is_array($child) ) {
				$children = array_merge($children, $child);
			} else {
				$children[] = $child;
			}
		}
		return $children;
	}
}