<?php

/**
 * @link https://www.humhub.org/
 * @copyright Copyright (c) 2017 HumHub GmbH & Co. KG
 * @license https://www.humhub.com/licences
 */

namespace humhub\modules\content\models;

use humhub\components\ActiveRecord;
use humhub\modules\content\components\ContentContainerActiveRecord;
use humhub\modules\content\components\ContentTagActiveQuery;
use Yii;
use yii\db\ActiveQuery;
use yii\db\Expression;
use yii\db\IntegrityException;

/**
 * ContentTags are a concept to categorize content on module and ContentContainer level.
 * By means of ContentTags a module is able to create own category concepts as for example event types (calendar) or
 * task related tags, in which the module itself is responsible for managing and the usage of it's tags.
 *
 * Module implementing own an own category concept should extend the ContentTag class as follows:
 *
 * ```php
 * class MyModuleCategory extends ContentTag
 * {
 *   public $moduleId = 'myModule';
 *
 *   public static getLabel()
 *  {
 *     return 'MyCategory'
 *  }
 * ```
 *
 * All MyModuleCategory will be assigned with the given `$moduleId` as `$module_id` and the class name as `$type`.
 * Calls to `MyModuleCategory::find()` will always include `$module_id` filters, the same goes for other query methods as `findByCotnainer` etc.
 *
 * If a subclass sets `$includeTypeQuery` to true queries will also include a $type filter. This is required if a module provides more than one
 * tag type.
 *
 * If you require to select untyped tags use `ContentTag::find()` instead.
 *
 * If the contentcontainer_id of a given ContentTag is null, the ContentTag is meant to be module global and therefore not bound to a ContentContainer instance.
 *
 * In some cases a ContentTag has to be related with some further settigns, this can be achieved by extending the `ContentTagAddition` class and
 * setting the ContentTags `$additioNClass`.
 *
 * An instance of this class will automatically be created by calling `$tag->addition`
 *
 * @property int $id
 * @property string name
 * @property string module_id
 * @property int contentcontainer_id
 * @property string type
 * @property int parent_id
 * @property string color
 * @property int sort_order
 *
 * @property ContentContainerActiveRecord $container
 * @property ContentContainer $contentContainer
 *
 * @package humhub\modules\content\models
 * @since 1.2.2
 * @author buddha
 */
class ContentTag extends ActiveRecord
{
    public const EVENT_GLOBAL_CONVERSION_SUGGESTION = 'contentTagGlobalConversionSuggestion';

    /**
     * @var string id of the module related to this content tag concept
     */
    public $moduleId;

    /**
     * @var string class of ContentTagAddition (optional), if set a call to $tag->addition will create an empty addition instance,
     * the addition will also be validated and saved in the tag save process.
     */
    public $additionClass;

    /**
     * @var bool if set to true the default search queries will include a type condition beside the module condition,
     * this is only required if one module provides multiple content tag concepts.
     */
    public $includeTypeQuery = false;

    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return 'content_tag';
    }

    /**
     * ContentTag constructor.
     *
     * Can be called either with a yii contfig array or with contentcontainer and name.
     *
     * @param array|ContentContainerActiveRecord $contentContainer
     * @param null $name
     * @param array $config
     */
    public function __construct($contentContainer = [], $name = null, $config = [])
    {
        if (is_array($contentContainer)) {
            parent::__construct($contentContainer);
        } elseif ($contentContainer instanceof ContentContainerActiveRecord) {
            $this->contentcontainer_id = $contentContainer->contentcontainer_id;
            parent::__construct($config);
        } else {
            parent::__construct([]);
        }

        if (!empty($name)) {
            $this->name = $name;
        }
    }

    public static function getLabel()
    {
        return Yii::t('ContentModule.base', 'Tag');
    }

    /**
     * Sets the module_id as and type.
     * Subclasses overwriting this method have to make sure to call parent::init() at the end.
     */
    public function init()
    {
        $this->module_id = $this->moduleId;
        if (!$this->type) {
            $this->type = static::class;
        }
        parent::init();
    }

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            [['name', 'module_id'], 'required'],
            [['name'], 'trim'],
            [['name', 'module_id', 'type'], 'string', 'max' => '100'],
            ['color', 'string', 'max' => '7'],
            [['parent_id', 'sort_order'], 'integer'],
            [['name'], 'validateUnique'],
        ];
    }

    /**
     * Validates
     * @param $attribute
     * @param $params
     * @param $validator
     */
    public function validateUnique($attribute, $params, $validator)
    {
        if (empty($this->contentcontainer_id)) {
            $query = static::find();
        } else {
            $query = static::findByContainer($this->contentcontainer_id, true);
        }

        $query->andWhere(['name' => $this->name]);

        if (!$this->isNewRecord) {
            $query->andWhere(['<>', 'id', $this->id]);
        }

        /** @var static $found */
        $found = $query->one();

        if ($found) {
            if ($this->contentcontainer_id && !$found->contentcontainer_id) {
                $this->addError('name', Yii::t('ContentModule.base', 'Topic already exists globally.'));
            } elseif (!$this->contentcontainer_id && $found->contentcontainer_id) {
                $this->trigger(self::EVENT_GLOBAL_CONVERSION_SUGGESTION);
                $this->addError('name', Yii::t('ContentModule.base', 'Topic already in use in Spaces or on Profiles.'));
            } else {
                $this->addError('name', Yii::t('ContentModule.base', 'The given name is already in use.'));
            }
        }
    }

    /**
     * @inheritdoc
     */
    public function __get($name)
    {
        /**
         * Ensure there is always a corresponding Content record
         * @see ContentTagAddition
         */
        if ($name == 'addition' && $this->additionClass) {
            $addition = parent::__get('addition');
            if (!$this->isRelationPopulated('addition') || $addition === null) {
                $addition = Yii::createObject($this->additionClass);
                $this->setAddition($addition);
            }
            return $addition;
        }
        return parent::__get($name);
    }

    /**
     * @inheritdoc
     */
    public function validate($attributeNames = null, $clearErrors = true)
    {
        if ($attributeNames === null || in_array('addition', $attributeNames)) {
            // the addition will only be validated if $tag->addition has been called
            if ($this->hasAddition() && !$this->addition->validate()) {
                return false;
            }
        }

        return parent::validate($attributeNames, $clearErrors);
    }

    public function load($data, $formName = null)
    {
        if ($this->hasAddition()) {
            $this->addition->load($data);
        }
        return parent::load($data, $formName);
    }

    /**
     * Returns the ContentContainer relation as ActiveQuery to this tag or null if this is a global tag.
     *
     * Note: In order to retrieve the actual Container (Space/User) use `getContainer()`.
     * @return ActiveQuery
     */
    public function getContentContainer()
    {
        return $this->hasOne(ContentContainer::class, ['id' => 'contentcontainer_id']);
    }

    /**
     * Returns the actual Container (Space/User) related to this tag or null if this is a global tag.
     * This function will cache the container instance once loaded.
     *
     * @return null|ContentContainerActiveRecord
     * @throws IntegrityException
     */
    public function getContainer()
    {
        if ($this->contentcontainer_id === null) {
            return null;
        }

        return $this->contentContainer->getPolymorphicRelation();
    }

    /**
     * @return string related link
     */
    public function getUrl()
    {
        return null;
    }


    /**
     * Returns the parent tag relation.
     *
     * @return ActiveQuery
     */
    public function getParent()
    {
        if ($this->parent_id === null) {
            return null;
        }

        return $this->hasOne(ContentTag::class, ['id' => 'parent_id']);
    }

    /**
     * @return ActiveRecord|ActiveQuery
     */
    public function getAddition()
    {
        if (!$this->additionClass) {
            return null;
        }

        return $this->hasOne($this->additionClass, ['tag_id' => 'id']);
    }

    /**
     * @return bool check if this content tag has an related addition
     */
    public function hasAddition()
    {
        return $this->additionClass != null && $this->isRelationPopulated('addition');
    }

    /**
     * Sets a ContentTagAddition for this instance, note you'll have to call `$tag->save()` manually
     * @param ContentTagAddition $addition
     */
    public function setAddition(ContentTagAddition $addition)
    {
        $this->populateRelation('addition', $addition);
        $addition->setTag($this);
    }

    public function afterSave($insert, $changedAttributes)
    {
        if ($this->hasAddition()) {
            $this->addition->setTag($this);
            $this->addition->save();
        }

        parent::afterSave($insert, $changedAttributes);
    }

    /**
     * Checks if this content tag is of the given $type.
     *
     * @param ContentTag $tag
     * @return bool
     */
    public function is($type)
    {
        return $this->type === $type;
    }

    /**
     * Finds instances and filters by module_id if a static module_id is given.
     *
     * @return ContentTagActiveQuery
     */
    public static function find()
    {
        $query = Yii::createObject(ContentTagActiveQuery::class, [get_called_class()])
            ->select([
                'content_tag.*',
                'is_global' => new Expression('content_tag.contentcontainer_id IS NULL'),
            ])
            ->orderBy([
                'is_global' => SORT_DESC,
                'sort_order' => SORT_ASC,
                'name' => SORT_ASC,
            ]);

        return static::addQueryCondition($query);
    }

    /**
     * Adds content tag related fitler conditions to a given query.
     *
     * @param ActiveQuery $query
     * @return ActiveQuery
     * @since 1.3
     */
    public static function addQueryCondition(ActiveQuery $query)
    {
        static::moduleQuery($query);
        return static::typeQuery($query);
    }

    /**
     * Find a single instances either by providing an id or other condition.
     *
     * @return static
     */
    public static function findOne($condition)
    {
        if (is_numeric($condition)) {
            return static::find()->andWhere(['id' => $condition])->one();
        } else {
            return static::find()->andWhere($condition)->one();
        }
    }

    /**
     * Adds an type filter query to the given $query instance in case $includeTypeQuery of the subclass is set to true and the
     * calling class is not ContentTag class itself.
     *
     * @param $query ActiveQuery
     * @param bool $force
     * @return mixed
     */
    public static function typeQuery($query)
    {
        $instance = new static();
        if ($instance->includeTypeQuery && static::class != ContentTag::class) {
            $query->andWhere(['content_tag.type' => static::class]);
        }

        return $query;
    }

    /**
     * Adds an module id filter query to the given $query instance in case a subclass has set the $moduleId.
     *
     * @param $query ActiveQuery
     * @return mixed
     */
    public static function moduleQuery($query)
    {
        $instance = new static();
        if (!empty($instance->moduleId)) {
            $query->andWhere(['content_tag.module_id' => $instance->moduleId]);
        }
        return $query;
    }

    /**
     * Finds ContentTag for the given $moduleId
     *
     * @param $moduleId
     * @return ActiveQuery
     */
    public static function findByModule($moduleId)
    {
        return parent::find()->where(['content_tag.module_id' => $moduleId]);
    }

    /**
     * Finds instances by given $type.
     *
     * @param string $type
     * @return ActiveQuery
     */
    public static function findByType($type)
    {
        $query = parent::find();
        static::moduleQuery($query)->andWhere(['content_tag.type' => $type]);
        return $query;
    }

    /**
     * Finds instances by given name and optionally by contentContainer.
     * @param $name
     * @param ContentContainerActiveRecord|int|null $contentContainer
     * @return ContentTagActiveQuery
     */
    public static function findByName($name, $contentContainer = null)
    {
        if ($contentContainer) {
            $query = static::findByContainer($contentContainer);
        } else {
            $query = static::find();
        }

        $query->andWhere(['content_tag.name' => $name]);
        return $query;
    }

    /**
     * Finds all global content tags of this type.
     * @return ContentTagActiveQuery
     */
    public static function findGlobal()
    {
        $query = static::find();
        $query->andWhere('content_tag.contentcontainer_id IS NULL');
        return $query;
    }

    /**
     * Returns Content related tags.
     *
     * @param Content $content
     * @param null $type
     * @return ActiveQuery
     */
    public static function findByContent(Content $content)
    {
        $query = $content->getTags(static::class);
        static::moduleQuery($query);
        $instance = new static();

        if (!empty($instance->moduleId)) {
            $query->andWhere(['content_tag.module_id' => $instance->moduleId]);
        }

        return static::typeQuery($query);
    }

    /**
     * Returns all tag relations of the given type for the given $content.
     *
     * @param Content $content
     * @return ContentTagRelation[]
     */
    public static function getTagContentRelations(Content $content, $includeGlobal = true)
    {
        $query = $content->getTagRelations()->innerJoin('content_tag', 'content_tag_relation.tag_id = content_tag.id');

        $instance = new static();
        if (!empty($instance->moduleId)) {
            $query->andWhere(['content_tag.module_id' => $instance->moduleId]);
        }

        if (!$includeGlobal) {
            $query->andWhere(['IS NOT', 'content_tag.contentcontainer_id', new Expression('NULL')]);
        }

        if (static::class != ContentTag::class) {
            $query->andWhere(['content_tag.type' => static::class]);
        }

        return $query->all();
    }

    /**
     * Deletes all tag relations of the given type for the given $content.
     *
     * @param Content $content
     */
    public static function deleteContentRelations(Content $content, $includeGlobal = true)
    {
        $relations = static::getTagContentRelations($content, $includeGlobal);
        foreach ($relations as $relation) {
            $relation->delete();
        }

        $content->refresh();
    }

    /**
     * Deletes all tags by module id
     * @param ContentContainerActiveRecord|int $contentContainer
     * @return int the number of rows deleted
     */
    public static function deleteByModule($contentContainer = null)
    {
        $instance = new static();

        if ($contentContainer) {
            $container_id = $contentContainer instanceof ContentContainerActiveRecord ? $contentContainer->contentcontainer_id : $contentContainer;
            return static::deleteAll(['module_id' => $instance->module_id, 'contentcontainer_id' => $container_id]);
        } else {
            return static::deleteAll(['module_id' => $instance->module_id]);
        }
    }

    /**
     * Deletes all tags by type
     * @param ContentContainerActiveRecord|int $contentContainer
     * @return int the number of rows deleted
     */
    public static function deleteByType($contentContainer = null)
    {
        $instance = new static();

        if ($contentContainer) {
            $container_id = $contentContainer instanceof ContentContainerActiveRecord ? $contentContainer->contentcontainer_id : $contentContainer;
            return static::deleteAll(['type' => $instance->type, 'contentcontainer_id' => $container_id]);
        } else {
            return static::deleteAll(['type' => $instance->type]);
        }
    }

    /**
     * In case this function is not called on the base ContentTag function, it will ensure to that at least either a
     * 'module_id' or 'type' condition is given to prevent deleting all content tags.
     *
     * If no 'module_id' or 'type' condition is given this function will automatically add a 'type' condition.
     *
     * @since 1.3.2
     */
    public static function deleteAll($condition = null, $params = [])
    {
        if (static::class === ContentTag::class) {
            return parent::deleteAll($condition, $params);
        }

        $instance = new static();
        if (empty($condition)) {
            $condition = ['type' => $instance->type];
        } elseif (!empty($condition) && !isset($condition['module_id']) && !isset($condition['type'])) {
            $condition['type'] = $instance->type;
        }

        return parent::deleteAll($condition, $params);
    }

    /**
     * Searches for all content tags of this type.
     *
     * @param $condition
     * @return ActiveRecord[]
     * @since 1.3.2
     */
    public static function findAll($condition)
    {
        if (static::class === ContentTag::class) {
            return parent::findAll($condition);
        }

        $instance = new static();
        if (empty($condition)) {
            $condition = ['type' => $instance->type];
        } else {
            $condition['type'] = $instance->type;
        }

        return parent::findAll($condition);
    }

    /**
     * Finds instances by ContentContainerActiveRecord and optional type.
     *
     * If $includeGlobal is set to true the query will also include global content tags of this type.
     *
     * @param $container ContentContainerActiveRecord|int
     * @param bool $includeGlobal if true the query will include global tags as well @since 1.2.3
     * @return ContentTagActiveQuery
     * @internal param ContentContainerActiveRecord|int $record Container instance or contentcontainer_id
     * @internal param null $type
     */
    public static function findByContainer($container, $includeGlobal = false)
    {
        $container_id = $container instanceof ContentContainerActiveRecord ? $container->contentcontainer_id : $container;

        if (!$includeGlobal) {
            return static::find()->andWhere(['content_tag.contentcontainer_id' => $container_id]);
        } else {
            return static::find()->andWhere(['or',
                ['content_tag.contentcontainer_id' => $container_id],
                'content_tag.contentcontainer_id IS NULL',
            ]);
        }
    }
}
