<?php

namespace YahnisElsts\AdminMenuEditor\Customizable\Controls;

use YahnisElsts\AdminMenuEditor\Customizable\Rendering\Renderer;
use YahnisElsts\AdminMenuEditor\Customizable\Settings;
use YahnisElsts\AdminMenuEditor\Customizable\Settings\AbstractSetting;
use YahnisElsts\AdminMenuEditor\Customizable\Settings\Setting;
use YahnisElsts\AdminMenuEditor\WireDSL\EvaluationContext;
use YahnisElsts\AdminMenuEditor\WireDSL\Expression;
use YahnisElsts\AdminMenuEditor\WireDSL\Resolvers\Resolution;

abstract class Control extends UiElement implements ControlContainer {
	use Toggleable {
		isEnabled as private traitIsEnabled;
	}

	/**
	 * @var string Type of the control
	 */
	protected $type = 'abstract';

	/**
	 * @var AbstractSetting[]
	 */
	protected $settings = [];

	/**
	 * @var null|Binding
	 */
	protected $mainBinding = null;

	/**
	 * @var string|null Name of the Knockout component that renders this control.
	 */
	protected $koComponentName = null;

	protected static array $revisedComponentMap = [
		'ame-actor-feature-checkbox' => 'ame-rev-actor-feature',
	];

	/**
	 * @var string|null|Binding
	 */
	protected $label = null;

	/**
	 * @var bool Whether this control has a specific, well-defined form element
	 * that is the main input and needs an associated label.
	 */
	protected $hasPrimaryInput = false;

	/**
	 * @var array List of CSS classes to apply to the primary input element.
	 */
	protected $inputClasses = [];

	/**
	 * @var array List of HTML attributes to add to the input element.
	 */
	protected $inputAttributes = [];

	protected static $totalInstances = 0;
	protected $instanceNumber = 0;

	public function __construct($settings = [], $params = [], $children = []) {
		$this->instanceNumber = ++static::$totalInstances;

		parent::__construct($params, $children);

		$this->settings = $settings;
		if ( !empty($this->settings) ) {
			if ( isset($params['mainSetting']) ) {
				$this->mainBinding = $this->settings[$params['mainSetting']];
			} else {
				$this->mainBinding = reset($this->settings);
			}
		}

		//Label
		if ( isset($params['label']) ) {
			$this->label = $params['label'];
		}
		//Description
		if ( isset($params['description']) ) {
			$this->description = $params['description'];
		}
		//Enabled/disabled
		$this->parseEnabledParam($params);

		//ID. It can be assigned automatically if the control has a plain main setting (not a reference).
		if ( $this->id === '' ) {
			if ( $this->mainBinding instanceof AbstractSetting ) {
				$this->id = preg_replace('/[^a-z0-9\-_]+/i', '_', $this->mainBinding->getId());
				//The ID should not start with a number because it *could*
				//be used as an HTML element ID.
				if ( preg_match('/^[0-9]/', $this->id) ) {
					$this->id = 'c' . $this->id;
				}
			}
		}

		//Input classes
		if ( isset($params['inputClasses']) ) {
			$this->inputClasses = (array)$params['inputClasses'];
		}

		//Input attributes
		if ( isset($params['inputAttributes']) ) {
			$this->inputAttributes = (array)$params['inputAttributes'];
		}
	}

	abstract public function renderContent(Renderer $renderer, EvaluationContext $context);

	public function getInputClasses(?EvaluationContext $context = null): array {
		return $this->inputClasses;
	}

	public function getHtmlIdBase(?EvaluationContext $context = null) {
		//Note that this is not necessarily the same as the ID attribute
		//of the HTML element generated by this control.
		if ( $this->id === '' ) {
			$generatedId = '';
			if ( $this->mainBinding && $context ) {
				$path = $context->resolve($this->mainBinding)->map(
					fn(Resolution $r) => $r->getFullSettingPath()
				)->getOrElse([]);
				if ( !empty($path) ) {
					$generatedId = implode('.', $path);
				}
			} else if ( $this->mainBinding instanceof AbstractSetting ) {
				$generatedId = $this->mainBinding->getId();
			}

			if ( !empty($generatedId) ) {
				$generatedId = preg_replace('/[^a-z0-9\-_]+/i', '_', $generatedId);
				//The ID should not start with a number because it *could*
				//be used as an HTML element ID.
				if ( preg_match('/^[0-9]/', $generatedId) ) {
					$generatedId = 'c' . $generatedId;
				}
				return $generatedId;
			}

			return 'ame_' . $this->type . '_' . $this->instanceNumber;
		}

		return parent::getHtmlIdBase($context);
	}

	public function getLabel(?EvaluationContext $context = null): string {
		if ( isset($this->label) ) {
			if ( $this->label instanceof Expression ) {
				return (string)$this->label->evaluate($context);
			} else {
				return $this->label;
			}
		}

		if ( !empty($this->mainBinding) ) {
			if ( $this->mainBinding instanceof AbstractSetting ) {
				return $this->mainBinding->getLabel($context);
			} else if ( $context ) {
				$resolved = $context->resolve($this->mainBinding);
				if ( $resolved->isDefined() ) {
					$resolvedRef = $resolved->get();
					return $resolvedRef->getLabel($context);
				}
			}
		}
		return '';
	}

	public function getLabelTargetId(?EvaluationContext $context = null) {
		return $this->getPrimaryInputId($context);
	}

	/**
	 * Does this control have an input or other form-related element that
	 * could be meaningfully associated with the control's label?
	 *
	 * @return bool
	 */
	public function supportsLabelAssociation() {
		return $this->hasPrimaryInput;
	}

	/**
	 * Whether this control renders its own label as part of its content.
	 *
	 * For example, a checkbox control will usually wrap a label element around
	 * its input element, so it would return true here.
	 *
	 * @return boolean
	 */
	public function includesOwnLabel(): bool {
		return false;
	}

	public function parentLabelEnabled() {
		return $this->supportsLabelAssociation();
	}

	/**
	 * @return AbstractSetting[]
	 */
	public function getSettings() {
		return $this->settings;
	}

	/**
	 * @return string
	 */
	public function getType() {
		return $this->type;
	}

	/**
	 * @param EvaluationContext|null $context
	 * @return bool
	 */
	public function isEnabled(?EvaluationContext $context = null) {
		$enabled = $this->traitIsEnabled($context);
		if ( !empty($this->mainBinding) ) {
			if ( $this->mainBinding instanceof AbstractSetting ) {
				$enabled = $enabled && $this->mainBinding->isEditableByUser($context);
			} else if ( $context ) {
				$enabled = $enabled && (
					$context
						->resolve($this->mainBinding)
						->map(function (Resolution $resolved) use ($context) {
							return $resolved->isEditableByUser($context);
						})
						->getOrElse(true)
					);
			}
		}
		return $enabled;
	}

	/**
	 * @param EvaluationContext|null $context
	 * @return string
	 */
	public function getPrimaryInputId(?EvaluationContext $context = null) {
		if ( !$this->hasPrimaryInput ) {
			return '';
		}
		return '_ame-cntrl-' . $this->getHtmlIdBase($context);
	}

	/**
	 * Generate an HTML tag based on the input settings of this control.
	 *
	 * This is mainly intended for creating input tags, but it also works for textarea tags.
	 *
	 * @param EvaluationContext $context
	 * @param array<string,mixed> $attributes
	 * @param string $tagName
	 * @param string|null $content
	 * @return string
	 */
	protected function buildInputElement(
		?EvaluationContext $context = null,
		                   $attributes = [],
		                   $tagName = 'input',
		                   $content = null
	) {
		//Merge input classes and any classes specified in the $attributes array.
		$classes = $this->getInputClasses($context);
		if ( isset($attributes['class']) ) {
			$classes = array_merge($classes, (array)$attributes['class']);
			unset($attributes['class']);
		}

		$attributes = array_merge(
			[
				'id'                 => $this->hasPrimaryInput ? $this->getPrimaryInputId($context) : null,
				'class'              => $classes,
				'name'               => $this->getFieldName($context),
				'disabled'           => !$this->isEnabled($context),
				//Add the setting ID for the admin customizer module.
				'data-ac-setting-id' => $this->getAutoAcSettingId(),
			],
			$attributes,
			$this->inputAttributes
		);

		return $this->buildTag($tagName, $attributes, $content);
	}

	/**
	 * Generate an HTML tag that will serve as the outer container for this control.
	 *
	 * This is intended to be used for fieldset or div tags that wrap the entire control, and it
	 * will include classes and styles from the control's properties.
	 *
	 * @param array $prependClasses
	 * @param array $attributes
	 * @param string $tagName
	 * @return string
	 */
	protected function buildContainerElement(
		array  $prependClasses = [],
		array  $attributes = [],
		string $tagName = 'div'
	): string {
		$mergedAttributes = array_merge(
			[
				'class' => array_merge($prependClasses, $this->classes),
				'style' => $this->styles,
			],
			$attributes
		);

		return $this->buildTag($tagName, $mergedAttributes);
	}

	/**
	 * As buildContainerElement(), but specifically for fieldset tags. Adds the enabled/disabled state
	 * if applicable.
	 *
	 * @param EvaluationContext $context
	 * @param array $prependClasses
	 * @param array $attributes
	 * @return string
	 */
	protected function buildFieldsetContainer(EvaluationContext $context, array $prependClasses = [], array $attributes = []): string {
		$attributes['disabled'] = !$this->isEnabled($context);
		$attributes['data-bind'] = $this->makeKoDataBind($this->getKoEnableBinding());
		return $this->buildContainerElement($prependClasses, $attributes, 'fieldset');
	}

	protected function getMainSettingValue($default = null, ?EvaluationContext $context = null) {
		if ( $this->mainBinding instanceof Setting ) {
			return $this->mainBinding->getValue($default);
		} else if ( $this->mainBinding instanceof Settings\WithSchema\SingularSetting ) {
			return $this->mainBinding->getValue($default);
		} else if ( $context && $this->mainBinding ) {
			return $context->resolveValue($this->mainBinding, $default);
		}
		return $default;
	}

	/**
	 * Generate the "name" attribute value for a form element associated with
	 * this control.
	 *
	 * @param EvaluationContext|null $context
	 * @param string|null $compositeChildKey Only for composite settings: the key of the child setting.
	 * @param Binding|null $setting The setting shown in the field. Defaults to the main setting.
	 * @return string
	 */
	protected function getFieldName(?EvaluationContext $context = null, $compositeChildKey = null, ?Binding $setting = null) {
		if ( $setting === null ) {
			$setting = $this->mainBinding;
		}
		if ( !($setting instanceof Binding) ) {
			return $this->getHtmlIdBase($context);
		}

		if ( $context ) {
			$path = $context->resolve($setting)->map(fn(Resolution $r) => $r->getFullSettingPath())->getOrElse([]);
			if ( empty($path) ) {
				return $this->getHtmlIdBase($context);
			}
			if ( !empty($compositeChildKey) ) {
				$path[] = $compositeChildKey;
			}
		} else if ( $setting instanceof AbstractSetting ) {
			if ( ($compositeChildKey === null) || ($compositeChildKey === '') ) {
				$id = $setting->getId();
			} else {
				$id = $setting->getId() . '.' . $compositeChildKey;
				if ( $setting instanceof Settings\CompositeSetting ) {
					$child = $setting->getChild($compositeChildKey);
					if ( $child ) {
						$id = $child->getId();
					}
				}
			}

			//Convert dot notation to array notation.
			$path = explode('.', $id);
		} else {
			return $this->getHtmlIdBase($context);
		}

		$root = array_shift($path);
		if ( empty($path) ) {
			return $root;
		}
		return $root . '[' . implode('][', $path) . ']';
	}

	public function getAutoGroupTitle(?EvaluationContext $context = null) {
		if ( $this->mainBinding instanceof AbstractSetting ) {
			$customTitle = $this->mainBinding->getCustomGroupTitle();
			if ( !empty($customTitle) ) {
				return $customTitle;
			}
		}
		return $this->getLabel($context);
	}

	/**
	 * Get a version of this control that is compatible with the Admin Customizer module.
	 *
	 * May return this object if it is already compatible or if there is
	 * no better alternative.
	 *
	 * @return Control
	 */
	public function getAdminCustomizerControl() {
		return $this;
	}

	protected function getAutoAcSettingId() {
		if ( ($this->mainBinding instanceof AbstractSetting) && $this->hasPrimaryInput ) {
			return $this->mainBinding->getId();
		}
		return null;
	}

	protected function getKoObservableExpression($defaultValue, ?AbstractSetting $setting = null) {
		if ( $setting === null ) {
			$setting = $this->mainBinding;
		}
		if ( !($setting instanceof AbstractSetting) ) {
			return wp_json_encode($defaultValue);
		}

		$settingId = $setting->getId();
		return sprintf(
			'$root.getSettingObservable(%s, %s)',
			wp_json_encode($settingId),
			wp_json_encode($defaultValue)
		);
	}

	protected function makeKoDataBind($bindingValues) {
		if ( empty($bindingValues) ) {
			return '';
		}

		$bindings = [];
		foreach ($bindingValues as $binding => $value) {
			$bindings[] = sprintf('%s: %s', $binding, $value);
		}
		return implode(', ', $bindings);
	}

	protected function getJsUiElementType() {
		return 'control';
	}

	protected function getRevisedJsUiElementType(): string {
		return $this->getJsUiElementType();
	}

	public function serializeForJs(EvaluationContext $context): array {
		$result = parent::serializeForJs($context);

		$label = $this->getLabel($context);
		if ( !empty($label) ) {
			$result['label'] = $label;
		}

		if ( $this->koComponentName === null ) {
			return $result;
		}
		$result['component'] = $this->koComponentName;

		//Map setting names to IDs.
		$settingIds = [];
		$settings = $this->settings;
		//The main setting is always available as "value". This means that a setting
		//could be duplicated in the "settings" array, but that should be fine.
		if ( $this->mainBinding instanceof Binding ) {
			$settings['value'] = $this->mainBinding;
		}
		foreach ($settings as $name => $setting) {
			//Skip non-string keys. Controls that care about accessing multiple
			//distinct settings should have custom keys.
			if ( !is_string($name) ) {
				continue;
			}

			//Recurse into structs and add all child settings separately as "name.child".
			//However, don't split up composite settings.
			if (
				($setting instanceof Settings\AbstractStructSetting)
				&& !($setting instanceof Settings\CompositeSetting)
			) {
				foreach (
					AbstractSetting::recursivelyIterateSettings($setting, false, $name)
					as $key => $childSetting
				) {
					$settingIds[$key] = self::serializeMinimalBindingForJs($childSetting, $context);
				}
			} else if ( $setting instanceof Binding ) {
				$settingIds[$name] = self::serializeMinimalBindingForJs($setting, $context);
			}
		}
		$result['settings'] = $settingIds;

		//Enabled state or condition.
		$result['enabled'] = $this->serializeConditionForJs();

		//Label association settings for components that display this control as a child.
		$includesOwnLabel = $this->includesOwnLabel();
		if ( $includesOwnLabel ) {
			$result['includesOwnLabel'] = true;
		}
		$labelTargetId = $this->getLabelTargetId();
		if ( $this->supportsLabelAssociation() && !empty($labelTargetId) ) {
			$result['labelTargetId'] = $labelTargetId;
		}

		return $result;
	}

	public function serializeForRevisedJs(EvaluationContext $context): array {
		$result = parent::serializeForRevisedJs($context);

		//Serialize bindings for the control.
		$serializedBindings = [];
		$bindings = $this->settings;
		//The main binding is always available as "value". This means that a binding
		//could be duplicated in the "bindings" array, but that should be fine.
		if ( $this->mainBinding instanceof Binding ) {
			$bindings['value'] = $this->mainBinding;
		}
		foreach ($bindings as $name => $binding) {
			//Skip non-string keys. Controls that care about accessing multiple
			//distinct bindings should have custom keys.
			if ( !is_string($name) ) {
				continue;
			}
			$serializedBindings[$name] = self::serializeMinimalBindingForJs($binding, $context);
		}
		if ( !empty($serializedBindings) ) {
			$result['bindings'] = $serializedBindings;
		}

		if ( $this->koComponentName !== null ) {
			$component = $this->koComponentName;
			if ( isset(self::$revisedComponentMap[$component]) ) {
				$component = self::$revisedComponentMap[$component];
			} else if (
				!\ameUtils::stringStartsWith($component, 'ame-rev-')
				&& \ameUtils::stringStartsWith($component, 'ame-')
			) {
				$component = 'ame-rev-' . substr($component, 4);
			}
			$result['component'] = $component;
		}

		$serializedLabel = $this->serializeLabelForJs($context);
		if ( !empty($serializedLabel) ) {
			$result['label'] = $serializedLabel;
		}

		//Enabled state or condition.
		$serializedEnabled = $this->serializeConditionForJs();
		if ( $serializedEnabled !== true ) {
			$result['enabled'] = $serializedEnabled;
		}

		//Label association settings for components that display this control as a child.
		$includesOwnLabel = $this->includesOwnLabel();
		if ( $includesOwnLabel ) {
			$result['includesOwnLabel'] = true;
		}
		if ( $this->supportsLabelAssociation() ) {
			$result['supportLabelAssociation'] = true;
		}
		//Unfortunately, the input ID can't be reliably determined when using dynamic bindings,
		//so we don't include it in the revised JS serialization. The components should use
		//another way to determine the label target ID.
		unset($result['primaryInputId']);

		return $result;
	}

	protected function getKoComponentParams(EvaluationContext $context): array {
		$params = parent::getKoComponentParams($context);

		//Primary input ID.
		if ( $this->hasPrimaryInput ) {
			$params['primaryInputId'] = $this->getPrimaryInputId($context);
		}

		//Input classes.
		$inputClasses = $this->getInputClasses();
		if ( !empty($inputClasses) ) {
			$params['inputClasses'] = $inputClasses;
		}

		//Input attributes.
		if ( !empty($this->inputAttributes) ) {
			$params['inputAttributes'] = $this->inputAttributes;
		}

		return $params;
	}

	public function serializeLabelForJs(?EvaluationContext $context) {
		if ( isset($this->label) ) {
			return self::serializeValueForJs($this->label, $context);
		}

		if ( $this->mainBinding ) {
			if ( $this->mainBinding instanceof AbstractSetting ) {
				return $this->mainBinding->getLabel($context);
			} else if ( $context ) {
				return $context->partialResolve($this->mainBinding)
					->map(fn(Resolution $r) => strval($r->getLabel($context)))
					->getOrElse('[Unresolved reference "' . $this->mainBinding->getInternalStringId() . '"]');
			}
		}

		return '';
	}

	public function getChildren(): array {
		return $this->children;
	}

	public function getAllDescendants() {
		foreach ($this->children as $child) {
			yield $child;
			if ( $child instanceof ControlContainer ) {
				yield from $child->getAllDescendants();
			}
		}
	}

	public function getAllReferencedSettings(EvaluationContext $context) {
		foreach ($this->getSettings() as $setting) {
			yield from self::getSettingsReferencedByBinding($setting, $context);
		}

		foreach ($this->getChildren() as $child) {
			yield from $child->getAllReferencedSettings($context);
		}
	}
}