<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

namespace MediaWiki\Extension\Gadgets;

use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\Authority;
use MediaWiki\ResourceLoader\ResourceLoader;
use MediaWiki\Skin\Skin;
use MediaWiki\User\UserIdentity;

/**
 * Represents one gadget definition.
 *
 * @copyright 2007 Daniel Kinzler
 */
class Gadget {
	/**
	 * Increment this when changing class structure
	 */
	public const GADGET_CLASS_VERSION = 21;

	public const CACHE_TTL = 86400;

	/** @var string[] */
	private $dependencies;
	/** @var string[] */
	private array $pages;
	/** @var string[] */
	private $peers;
	/** @var string[] */
	private $messages;
	/** @var string|null */
	private $name;
	/** @var string|null */
	private $definition;
	private bool $resourceLoaded;
	private bool $requiresES6;
	/** @var string[] */
	private array $requiredRights;
	/** @var string[] */
	private array $requiredActions;
	/** @var string[] */
	private array $requiredSkins;
	/** @var int[]|string[] */
	private array $requiredNamespaces;
	/** @var string[] */
	private array $requiredCategories;
	/** @var string[] */
	private array $requiredContentModels;
	/** @var bool */
	private $onByDefault;
	/** @var bool */
	private $hidden;
	/** @var bool */
	private $package;
	/** @var string */
	private $type;
	private string $section;
	/** @var bool */
	private $supportsUrlLoad;
	/** @var string[] */
	private array $codexIcons;

	public function __construct( array $options ) {
		$this->section = $options['section'] ?? '';
		$this->codexIcons = $options['codexIcons'] ?? [];
		$this->definition = $options['definition'] ?? null;
		$this->dependencies = $options['dependencies'] ?? [];
		$this->hidden = $options['hidden'] ?? false;
		$this->messages = $options['messages'] ?? [];
		$this->name = $options['name'] ?? null;
		$this->onByDefault = $options['onByDefault'] ?? false;
		$this->package = $options['package'] ?? false;
		$this->pages = $options['pages'] ?? [];
		$this->peers = $options['peers'] ?? [];
		$this->requiredActions = $options['requiredActions'] ?? [];
		$this->requiredCategories = $options['requiredCategories'] ?? [];
		$this->requiredContentModels = $options['requiredContentModels'] ?? [];
		$this->requiredNamespaces = $options['requiredNamespaces'] ?? [];
		$this->requiredRights = $options['requiredRights'] ?? [];
		$this->requiredSkins = $options['requiredSkins'] ?? [];
		$this->requiresES6 = $options['requiresES6'] ?? false;
		$this->resourceLoaded = $options['resourceLoaded'] ?? false;
		$this->supportsUrlLoad = $options['supportsUrlLoad'] ?? false;
		$this->type = $options['type'] ?? '';
	}

	/**
	 * Create a serialized array based on the metadata in a GadgetDefinitionContent object,
	 * from which a Gadget object can be constructed.
	 *
	 * @param string $id
	 * @param array $data
	 * @return array
	 */
	public static function serializeDefinition( string $id, array $data ): array {
		$prefixGadgetNs = static function ( $page ) {
			return GadgetRepo::RESOURCE_TITLE_PREFIX . $page;
		};
		return [
			'section' => $data['settings']['section'],
			'codexIcons' => $data['module']['codexIcons'],
			'dependencies' => $data['module']['dependencies'],
			'hidden' => $data['settings']['hidden'],
			'messages' => $data['module']['messages'],
			'name' => $id,
			'onByDefault' => $data['settings']['default'],
			'package' => $data['settings']['package'],
			'pages' => array_map( $prefixGadgetNs, $data['module']['pages'] ),
			'peers' => $data['module']['peers'],
			'requiredActions' => $data['settings']['actions'],
			'requiredCategories' => $data['settings']['categories'],
			'requiredContentModels' => $data['settings']['contentModels'],
			'requiredNamespaces' => $data['settings']['namespaces'],
			'requiredRights' => $data['settings']['rights'],
			'requiredSkins' => $data['settings']['skins'],
			'requiresES6' => $data['settings']['requiresES6'],
			'resourceLoaded' => true,
			'supportsUrlLoad' => $data['settings']['supportsUrlLoad'],
			'type' => $data['module']['type'],
		];
	}

	/**
	 * Serialize to an array
	 */
	public function toArray(): array {
		return [
			'section' => $this->section,
			'codexIcons' => $this->codexIcons,
			'dependencies' => $this->dependencies,
			'hidden' => $this->hidden,
			'messages' => $this->messages,
			'name' => $this->name,
			'onByDefault' => $this->onByDefault,
			'package' => $this->package,
			'pages' => $this->pages,
			'peers' => $this->peers,
			'requiredActions' => $this->requiredActions,
			'requiredCategories' => $this->requiredCategories,
			'requiredContentModels' => $this->requiredContentModels,
			'requiredNamespaces' => $this->requiredNamespaces,
			'requiredRights' => $this->requiredRights,
			'requiredSkins' => $this->requiredSkins,
			'requiresES6' => $this->requiresES6,
			'resourceLoaded' => $this->resourceLoaded,
			'supportsUrlLoad' => $this->supportsUrlLoad,
			'type' => $this->type,
			// Legacy  (specific to MediaWikiGadgetsDefinitionRepo)
			'definition' => $this->definition,
		];
	}

	/**
	 * Get a placeholder object to use if a gadget doesn't exist
	 *
	 * @param string $id name
	 * @return self
	 */
	public static function newEmptyGadget( $id ) {
		return new self( [ 'name' => $id ] );
	}

	/**
	 * Whether the provided gadget id is valid
	 *
	 * @param string $id
	 * @return bool
	 */
	public static function isValidGadgetID( $id ) {
		return $id !== '' && ResourceLoader::isValidModuleName( self::getModuleName( $id ) );
	}

	/**
	 * @return string Gadget name
	 */
	public function getName() {
		return $this->name;
	}

	/**
	 * @return string Message key
	 */
	public function getDescriptionMessageKey() {
		return 'gadget-' . $this->name;
	}

	/**
	 * @return string Gadget description parsed into HTML
	 */
	public function getDescription() {
		return wfMessage( $this->getDescriptionMessageKey() )->parse();
	}

	/**
	 * @return string Wikitext of gadget description
	 */
	public function getRawDescription() {
		return wfMessage( $this->getDescriptionMessageKey() )->plain();
	}

	/**
	 * @return string Name of section our gadget belongs to. Empty string if none.
	 */
	public function getSection(): string {
		return $this->section;
	}

	/**
	 * @param string $id Name of gadget
	 * @return string Name of ResourceLoader module for the gadget
	 */
	public static function getModuleName( $id ) {
		return "ext.gadget.{$id}";
	}

	/**
	 * Checks whether this gadget is enabled for given user
	 *
	 * @param UserIdentity $user user to check against
	 * @return bool
	 */
	public function isEnabled( UserIdentity $user ) {
		$userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
		return (bool)$userOptionsLookup->getOption( $user, "gadget-{$this->name}", $this->onByDefault );
	}

	/**
	 * Checks whether a given user may enable this gadget
	 *
	 * @param Authority $user The user to check against
	 * @return bool
	 */
	public function isAllowed( Authority $user ) {
		return !$this->requiredRights || $user->isAllowedAll( ...$this->requiredRights );
	}

	/**
	 * @return bool Whether this gadget is on by default for everyone
	 *  (but can be disabled in preferences)
	 */
	public function isOnByDefault() {
		return $this->onByDefault;
	}

	/**
	 * @return bool
	 */
	public function isHidden() {
		return $this->hidden;
	}

	public function isPackaged(): bool {
		// A packaged gadget needs to have a main script, so there must be at least one script
		return $this->package && $this->supportsResourceLoader() && $this->getScripts();
	}

	/**
	 * Whether to load the gadget on a given page action.
	 *
	 * @param string $action Action name
	 * @return bool
	 */
	public function isActionSupported( string $action ): bool {
		if ( !$this->requiredActions ) {
			return true;
		}
		// Don't require specifying 'submit' action in addition to 'edit'
		if ( $action === 'submit' ) {
			$action = 'edit';
		}
		return in_array( $action, $this->requiredActions, true );
	}

	/**
	 * Whether to load the gadget on pages in a given namespace ID.
	 *
	 * @param int $namespace Namespace ID
	 * @return bool
	 */
	public function isNamespaceSupported( int $namespace ) {
		// This is intentionally a non-strict in_array() because
		// MediaWikiGadgetsDefinitionRepo sets numerical strings.
		return !$this->requiredNamespaces || in_array( $namespace, $this->requiredNamespaces );
	}

	/**
	 * Whether to load the gadget on pages in any of the given categories
	 *
	 * @param array $categories Associative array with keys as category names (category title text,
	 * no namespace prefix, no dbkey-underscores) and with value as 1 for all keys.
	 * @return bool
	 */
	public function isCategorySupported( array $categories ) {
		if ( !$this->requiredCategories ) {
			return true;
		}
		foreach ( $this->requiredCategories as $category ) {
			if ( isset( $categories[$category] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Check if this gadget is compatible with a skin
	 *
	 * @param Skin $skin
	 * @return bool
	 */
	public function isSkinSupported( Skin $skin ) {
		return !$this->requiredSkins || in_array( $skin->getSkinName(), $this->requiredSkins, true );
	}

	/**
	 * Check if this gadget is compatible with the given content model
	 *
	 * @param string $contentModel The content model ID
	 * @return bool
	 */
	public function isContentModelSupported( string $contentModel ) {
		return !$this->requiredContentModels || in_array( $contentModel, $this->requiredContentModels );
	}

	/**
	 * @return bool Whether the gadget can be loaded with `?withgadget` query parameter.
	 */
	public function supportsUrlLoad() {
		return $this->supportsUrlLoad;
	}

	/**
	 * @return bool Whether all of this gadget's JS components support ResourceLoader
	 */
	public function supportsResourceLoader() {
		return $this->resourceLoaded;
	}

	/**
	 * @return bool Whether this gadget requires ES6
	 */
	public function requiresES6(): bool {
		return $this->requiresES6 && !$this->onByDefault;
	}

	/**
	 * @return bool Whether this gadget has resources that can be loaded via ResourceLoader
	 */
	public function hasModule() {
		return $this->getStyles() || ( $this->supportsResourceLoader() && $this->getScripts() );
	}

	/**
	 * @return string|null Definition for this gadget from MediaWiki:Gadgets-definition,
	 *  or null if MediaWikiGadgetsJsonRepo is used.
	 */
	public function getDefinition() {
		return $this->definition;
	}

	/**
	 * @return string[] JS page names (including namespace)
	 */
	public function getScripts() {
		return array_values( array_filter( $this->pages, static function ( $page ) {
			return str_ends_with( $page, '.js' );
		} ) );
	}

	/**
	 * @return string[] CSS page names (including namespace)
	 */
	public function getStyles() {
		return array_values( array_filter( $this->pages, static function ( $page ) {
			return str_ends_with( $page, '.css' );
		} ) );
	}

	/**
	 * @return string[] JSON page names (including namespace)
	 */
	public function getJSONs(): array {
		return array_values( array_filter( $this->pages, static function ( $page ) {
			return str_ends_with( $page, '.json' );
		} ) );
	}

	/**
	 * @return string[] Vue page names (including namespace)
	 */
	public function getVues(): array {
		return array_values( array_filter( $this->pages, static function ( $page ) {
			return str_ends_with( $page, '.vue' );
		} ) );
	}

	/**
	 * @return string[] Codex icons to include in the gadget module
	 */
	public function getCodexIcons(): array {
		return $this->codexIcons;
	}

	/**
	 * @return string[] All page names for this gadget's resources
	 */
	public function getScriptsAndStyles() {
		return array_merge( $this->getScripts(), $this->getStyles(), $this->getJSONs(), $this->getVues() );
	}

	/**
	 * Returns list of scripts that don't support ResourceLoader
	 * @return string[]
	 */
	public function getLegacyScripts() {
		return $this->supportsResourceLoader() ? [] : $this->getScripts();
	}

	/**
	 * Returns names of resources this gadget depends on
	 * @return string[]
	 */
	public function getDependencies() {
		return $this->dependencies;
	}

	/**
	 * Get list of extra modules that should be loaded when this gadget is enabled
	 *
	 * Primary use case is to allow a Gadget that includes JavaScript to also load
	 * a (usually, hidden) styles-type module to be applied to the page. Dependencies
	 * don't work for this use case as those would not be part of page rendering.
	 *
	 * @return string[]
	 */
	public function getPeers() {
		return $this->peers;
	}

	/**
	 * @return string[]
	 */
	public function getMessages() {
		return $this->messages;
	}

	/**
	 * Get user rights required to enable this gadget
	 * @return string[]
	 */
	public function getRequiredRights() {
		return $this->requiredRights;
	}

	/**
	 * Get page actions on which the gadget loads
	 * @return string[]
	 */
	public function getRequiredActions() {
		return $this->requiredActions;
	}

	/**
	 * Get page namespaces in which this gadget loads
	 *
	 * Use isNamespaceSupported() instead for basic checks, as
	 * namespace IDs may be returned as numerical strings.
	 *
	 * Unknown namespaces and non-numerical values result in warnings
	 * on Special:Gadgets, via GadgetRepo::checkInvalidLoadConditions.
	 *
	 * @return int[]|string[]
	 */
	public function getRequiredNamespaces() {
		return $this->requiredNamespaces;
	}

	/**
	 * Returns categories in which this gadget loads
	 * @return string[]
	 */
	public function getRequiredCategories() {
		return $this->requiredCategories;
	}

	/**
	 * Get skins in which this gadget loads
	 * @return string[]
	 */
	public function getRequiredSkins() {
		return $this->requiredSkins;
	}

	/**
	 * Get page content models for which this gadget loads
	 * @return string[]
	 */
	public function getRequiredContentModels() {
		return $this->requiredContentModels;
	}

	/**
	 * Returns the load type of this Gadget's ResourceLoader module
	 * @return string 'styles' or 'general'
	 */
	public function getType() {
		if ( $this->type === 'styles' || $this->type === 'general' ) {
			return $this->type;
		}
		// Similar to ResourceLoaderWikiModule default
		if ( $this->getStyles() && !$this->getScripts() && !$this->dependencies ) {
			return 'styles';
		}

		return 'general';
	}

	/**
	 * Get validation warnings
	 * @return string[]
	 */
	public function getValidationWarnings(): array {
		$warnings = [];

		// Default gadget requiring ES6
		if ( $this->onByDefault && $this->requiresES6 ) {
			$warnings[] = "gadgets-validate-es6default";
		}

		// Gadget containing files with uncrecognised suffixes
		if ( array_diff( $this->pages, $this->getScriptsAndStyles() ) ) {
			$warnings[] = "gadgets-validate-unknownpages";
		}

		// Non-package gadget containing JSON files
		if ( !$this->package && $this->getJSONs() ) {
			$warnings[] = "gadgets-validate-json";
		}

		// Non-package gadget containing Codex icons
		if ( !$this->package && count( $this->getCodexIcons() ) > 0 ) {
			$warnings[] = "gadgets-validate-icons";
		}

		// Package gadget without a script file in it (to serve as entry point)
		if ( $this->package && !$this->getScripts() ) {
			$warnings[] = "gadgets-validate-noentrypoint";
		}

		// Gadget with type=styles having non-CSS files
		if ( $this->type === 'styles' && $this->getScripts() ) {
			$warnings[] = "gadgets-validate-scriptsnotallowed";
		}

		// Style-only gadgets having peers
		if ( $this->getType() === 'styles' && $this->peers ) {
			$warnings[] = "gadgets-validate-stylepeers";
		}

		return $warnings;
	}
}
