<?php

namespace MediaWiki\Settings\Config;

use JsonSchema\Constraints\Constraint;
use JsonSchema\Validator;
use MediaWiki\Config\Config;
use MediaWiki\Settings\DynamicDefaultValues;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\JsonSchemaTrait;
use StatusValue;
use function array_key_exists;

/**
 * Aggregates multiple config schemas.
 *
 * Some aspects of the schema are maintained separately, to optimized
 * for settings defaults, types and merge strategies in bulk, and later
 * accessing them independently of each other, for each config key.
 */
class ConfigSchemaAggregator implements ConfigSchema {
	use JsonSchemaTrait;

	/** @var array[] Maps config keys to JSON schema structures */
	private $schemas = [];

	/** @var array Map of config keys to default values, for optimized access */
	private $defaults = [];

	/** @var array Map of config keys to dynamic default declaration ararys, for optimized access */
	private $dynamicDefaults = [];

	/** @var array Map of config keys to types, for optimized access */
	private $types = [];

	/** @var array Map of config keys to merge strategies, for optimized access */
	private $mergeStrategies = [];

	/** @var MergeStrategy[]|null */
	private $mergeStrategyCache;

	/** @var Validator */
	private $validator;

	/**
	 * Add a config schema to the aggregator.
	 *
	 * @param string $key
	 * @param array $schema
	 * @param string $sourceName
	 */
	public function addSchema( string $key, array $schema, string $sourceName = 'unknown' ) {
		if ( isset( $schema['properties'] ) ) {
			// Collect the defaults of nested property declarations into the top level default.
			$schema['default'] = self::getDefaultFromJsonSchema( $schema );
		}

		$this->schemas[$key] = $schema;

		$this->setListValueInternal( $schema, $this->defaults, $key, 'default', $sourceName );
		$this->setListValueInternal( $schema, $this->types, $key, 'type', $sourceName );
		$this->setListValueInternal( $schema, $this->mergeStrategies, $key, 'mergeStrategy', $sourceName );
		$this->setListValueInternal( $schema, $this->dynamicDefaults, $key, 'dynamicDefault', $sourceName );

		if ( isset( $schema['mergeStrategy'] ) ) {
			// TODO: mark cache as incomplete rather than throwing it away
			$this->mergeStrategyCache = null;
		}
	}

	/**
	 * Update a map with a specific field.
	 *
	 * @param array $schema
	 * @param array &$target
	 * @param string $key
	 * @param string $fieldName
	 * @param string $sourceName
	 *
	 * @return void
	 * @throws SettingsBuilderException if a conflict is detected
	 */
	private function setListValueInternal( $schema, &$target, $key, $fieldName, $sourceName ) {
		if ( array_key_exists( $fieldName, $schema ) ) {
			if ( array_key_exists( $key, $target ) ) {
				throw new SettingsBuilderException(
					"Overriding $fieldName in schema for {key} from {source}",
					[
						'source' => $sourceName,
						'key' => $key,
					]
				);
			}
			$target[$key] = $schema[$fieldName];
		}
	}

	/**
	 * Add multiple schema definitions.
	 *
	 * @see addSchema()
	 *
	 * @param array[] $schemas An associative array mapping config variable
	 *        names to their respective schemas.
	 */
	public function addSchemaMulti( array $schemas ) {
		foreach ( $schemas as $key => $sch ) {
			$this->addSchema( $key, $sch );
		}
	}

	/**
	 * Update a map with the given values.
	 *
	 * @param array $values
	 * @param array &$target
	 * @param string $fieldName
	 * @param string $sourceName
	 *
	 * @throws SettingsBuilderException if a conflict is detected
	 *
	 * @return void
	 */
	private function mergeListInternal( $values, &$target, $fieldName, $sourceName ) {
		$merged = array_merge( $target, $values );
		if ( count( $merged ) < ( count( $target ) + count( $values ) ) ) {
			throw new SettingsBuilderException( 'Overriding config {field} from {source}', [
				'field' => $fieldName,
				'source' => $sourceName,
				'old_values' => implode( ', ', array_intersect_key( $target, $values ) ),
				'new_values' => implode( ', ', array_intersect_key( $values, $target ) ),
			] );
		}

		$target = $merged;
	}

	/**
	 * Declare default values
	 *
	 * @param array $defaults
	 * @param string $sourceName
	 */
	public function addDefaults( array $defaults, string $sourceName = 'unknown' ) {
		$this->mergeListInternal( $defaults, $this->defaults, 'defaults', $sourceName );
	}

	/**
	 * Declare types
	 *
	 * @param array $types
	 * @param string $sourceName
	 */
	public function addTypes( array $types, string $sourceName = 'unknown' ) {
		$this->mergeListInternal( $types, $this->types, 'types', $sourceName );
	}

	/**
	 * Declare merge strategies
	 *
	 * @param array $mergeStrategies
	 * @param string $sourceName
	 */
	public function addMergeStrategies( array $mergeStrategies, string $sourceName = 'unknown' ) {
		$this->mergeListInternal(
			$mergeStrategies,
			$this->mergeStrategies,
			'mergeStrategies',
			$sourceName
		);

		// TODO: mark cache as incomplete rather than throwing it away
		$this->mergeStrategyCache = null;
	}

	/**
	 * Declare dynamic defaults
	 *
	 * @see DynamicDefaultValues.
	 *
	 * @param array $dynamicDefaults
	 * @param string $sourceName
	 */
	public function addDynamicDefaults( array $dynamicDefaults, string $sourceName = 'unknown' ) {
		$this->mergeListInternal(
			$dynamicDefaults,
			$this->dynamicDefaults,
			'dynamicDefaults',
			$sourceName
		);
	}

	/**
	 * Get a list of all defined keys
	 *
	 * @return string[]
	 */
	public function getDefinedKeys(): array {
		return array_keys(
			array_merge(
				$this->schemas,
				$this->defaults,
				$this->types,
				$this->mergeStrategies,
				$this->dynamicDefaults
			)
		);
	}

	/**
	 * Get the schema for the given key
	 *
	 * @param string $key
	 *
	 * @return array
	 */
	public function getSchemaFor( string $key ): array {
		$schema = $this->schemas[$key] ?? [];

		if ( isset( $this->defaults[$key] ) ) {
			$schema['default'] = $this->defaults[$key];
		}

		if ( isset( $this->types[$key] ) ) {
			$schema['type'] = $this->types[$key];
		}

		if ( isset( $this->mergeStrategies[$key] ) ) {
			$schema['mergeStrategy'] = $this->mergeStrategies[$key];
		}

		if ( isset( $this->dynamicDefaults[$key] ) ) {
			$schema['dynamicDefault'] = $this->dynamicDefaults[$key];
		}

		return $schema;
	}

	/**
	 * Check whether schema for $key is defined.
	 *
	 * @param string $key
	 * @return bool
	 */
	public function hasSchemaFor( string $key ): bool {
		return isset( $this->schemas[ $key ] )
			|| array_key_exists( $key, $this->defaults )
			|| isset( $this->types[ $key ] )
			|| isset( $this->mergeStrategies[ $key ] )
			|| isset( $this->dynamicDefaults[ $key ] );
	}

	/**
	 * Get all defined default values.
	 */
	public function getDefaults(): array {
		return $this->defaults;
	}

	/**
	 * Get all known types.
	 *
	 * @return array<string|array>
	 */
	public function getTypes(): array {
		return $this->types;
	}

	/**
	 * Get the names of all known merge strategies.
	 *
	 * @return array<string>
	 */
	public function getMergeStrategyNames(): array {
		return $this->mergeStrategies;
	}

	/**
	 * Get all dynamic default declarations.
	 * @see DynamicDefaultValues.
	 *
	 * @return array<string,array>
	 */
	public function getDynamicDefaults(): array {
		return $this->dynamicDefaults;
	}

	/**
	 * Check if the $key has a default values set in the schema.
	 *
	 * @param string $key
	 * @return bool
	 */
	public function hasDefaultFor( string $key ): bool {
		return array_key_exists( $key, $this->defaults );
	}

	/**
	 * Get default value for the $key.
	 * If no default value was declared, this returns null.
	 *
	 * @param string $key
	 * @return mixed
	 */
	public function getDefaultFor( string $key ) {
		return $this->defaults[$key] ?? null;
	}

	/**
	 * Get type for the $key, or null if the type is not known.
	 *
	 * @param string $key
	 * @return mixed
	 */
	public function getTypeFor( string $key ) {
		return $this->types[$key] ?? null;
	}

	/**
	 * Get a dynamic default declaration for $key.
	 * If no dynamic default is declared, this returns null.
	 *
	 * @param string $key
	 * @return ?array An associative array of the form expected by DynamicDefaultValues.
	 */
	public function getDynamicDefaultDeclarationFor( string $key ): ?array {
		return $this->dynamicDefaults[$key] ?? null;
	}

	/**
	 * Get the merge strategy defined for the $key, or null if none defined.
	 *
	 * @param string $key
	 * @return MergeStrategy|null
	 * @throws SettingsBuilderException if merge strategy name is invalid.
	 */
	public function getMergeStrategyFor( string $key ): ?MergeStrategy {
		if ( $this->mergeStrategyCache === null ) {
			$this->initMergeStrategies();
		}
		return $this->mergeStrategyCache[$key] ?? null;
	}

	/**
	 * Get all merge strategies indexed by config key. If there is no merge
	 * strategy for a given key, the element will be absent.
	 *
	 * @return MergeStrategy[]
	 */
	public function getMergeStrategies() {
		if ( $this->mergeStrategyCache === null ) {
			$this->initMergeStrategies();
		}
		return $this->mergeStrategyCache;
	}

	/**
	 * Initialise $this->mergeStrategyCache
	 */
	private function initMergeStrategies() {
		// XXX: Keep $strategiesByName for later, in case we reset the cache?
		//      Or we could make a bulk version of MergeStrategy::newFromName(),
		//      to make use of the cache there without the overhead of a method
		//      call for each setting.

		$strategiesByName = [];
		$strategiesByKey = [];

		// Explicitly defined merge strategies
		$strategyNamesByKey = $this->mergeStrategies;

		// Loop over settings for which we know a type but not a merge strategy,
		// so we can add a merge strategy for them based on their type.
		$types = array_diff_key( $this->types, $strategyNamesByKey );
		foreach ( $types as $key => $type ) {
			$strategyNamesByKey[$key] = self::getStrategyForType( $type );
		}

		// Assign MergeStrategy objects to settings. Create only one object per strategy name.
		foreach ( $strategyNamesByKey as $key => $strategyName ) {
			if ( !array_key_exists( $strategyName, $strategiesByName ) ) {
				$strategiesByName[$strategyName] = MergeStrategy::newFromName( $strategyName );
			}
			$strategiesByKey[$key] = $strategiesByName[$strategyName];
		}

		$this->mergeStrategyCache = $strategiesByKey;
	}

	/**
	 * Returns an appropriate merge strategy for the given type.
	 *
	 * @param string|array $type
	 *
	 * @return string
	 */
	private static function getStrategyForType( $type ) {
		if ( is_array( $type ) ) {
			if ( in_array( 'array', $type ) ) {
				$type = 'array';
			} elseif ( in_array( 'object', $type ) ) {
				$type = 'object';
			}
		}

		if ( $type === 'array' ) {
			// In JSON Schema, "array" means a list.
			// Use array_merge to append.
			return 'array_merge';
		} elseif ( $type === 'object' ) {
			// In JSON Schema, "object" means a map.
			// Use array_plus to replace keys, even if they are numeric.
			return 'array_plus';
		}

		return 'replace';
	}

	/**
	 * Check if the given config conforms to the schema.
	 * Note that all keys for which a schema was defined are required to be present in $config.
	 *
	 * @param Config $config
	 *
	 * @return StatusValue
	 */
	public function validateConfig( Config $config ): StatusValue {
		$result = StatusValue::newGood();

		foreach ( $this->getDefinedKeys() as $key ) {
			// All config keys present in the schema must be set.
			if ( !$config->has( $key ) ) {
				$result->fatal( 'config-missing-key', $key );
				continue;
			}

			$value = $config->get( $key );
			$result->merge( $this->validateValue( $key, $value ) );
		}
		return $result;
	}

	/**
	 * Check if the given value conforms to the relevant schema.
	 *
	 * @param string $key
	 * @param mixed $value
	 *
	 * @return StatusValue
	 */
	public function validateValue( string $key, $value ): StatusValue {
		$status = StatusValue::newGood();
		$schema = $this->getSchemaFor( $key );

		if ( !$schema ) {
			return $status;
		}

		if ( !$this->validator ) {
			$this->validator = new Validator();
		}

		$types = isset( $schema['type'] ) ? (array)$schema['type'] : [];

		if ( in_array( 'object', $types ) && is_array( $value ) ) {
			if ( $this->hasNumericKeys( $value ) ) {
				// JSON Schema validation doesn't like numeric keys in objects,
				// but we need this quite a bit. Skip type validation in this case.
				$status->warning(
					'config-invalid-key',
					$key,
					'Skipping validation of object with integer keys'
				);
				unset( $schema['type'] );
			}
		}

		if ( in_array( 'integer', $types ) && is_float( $value ) ) {
			// The validator complains about float values when an integer is expected,
			// even when the fractional part is 0. So cast to integer to avoid spurious errors.
			$intval = intval( $value );
			if ( $intval == $value ) {
				$value = $intval;
			}
		}

		if ( in_array( 'array', $types ) && is_array( $value ) && !array_is_list( $value ) ) {
			// Lists can become associative arrays along the way as a result of some
			// operations such as unsetting an element or using array_diff. Cast it back
			// to list to avoid weird errors. We use array_merge(), instead of array_values(),
			// so as to not discard (non-numeric) string keys which may have other meaning.
			$value = array_merge( $value );
		}

		$this->validator->validate(
			$value,
			$schema,
			Constraint::CHECK_MODE_TYPE_CAST
		);

		if ( !$this->validator->isValid() ) {
			foreach ( $this->validator->getErrors() as $error ) {
				$errorMsg = $error['message'];

				// In the JSON Schema, 'array' means a list, but the native PHP type
				// is of course 'array' leading to this spurious error message.
				// We change the message here to make it more informative.
				if ( $errorMsg === 'Array value found, but an array is required' ) {
					$errorMsg = 'Associative array value found, but a list is required';
				}

				$status->fatal( 'config-invalid-key', $key, $errorMsg, var_export( $value, true ) );
			}
		}
		$this->validator->reset();
		return $status;
	}

	/**
	 * @param array $value
	 *
	 * @return bool
	 */
	private function hasNumericKeys( array $value ) {
		foreach ( $value as $key => $dummy ) {
			if ( is_int( $key ) ) {
				return true;
			}
		}

		return false;
	}

}
