<?php

/**
 * @package     Joomla.Administrator
 * @subpackage  com_categories
 *
 * @copyright   (C) 2008 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\Component\Categories\Administrator\Model;

use Joomla\CMS\Access\Rules;
use Joomla\CMS\Association\AssociationServiceInterface;
use Joomla\CMS\Categories\CategoryServiceInterface;
use Joomla\CMS\Component\ComponentHelper;
use Joomla\CMS\Event\Model\AfterCategoryChangeStateEvent;
use Joomla\CMS\Factory;
use Joomla\CMS\Form\Form;
use Joomla\CMS\Helper\TagsHelper;
use Joomla\CMS\Language\Associations;
use Joomla\CMS\Language\LanguageHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
use Joomla\CMS\MVC\Model\AdminModel;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\CMS\Table\Category;
use Joomla\CMS\UCM\UCMType;
use Joomla\CMS\Versioning\VersionableModelInterface;
use Joomla\CMS\Versioning\VersionableModelTrait;
use Joomla\Component\Categories\Administrator\Helper\CategoriesHelper;
use Joomla\Component\Categories\Administrator\Table\CategoryTable;
use Joomla\Database\ParameterType;
use Joomla\Filesystem\Path;
use Joomla\Registry\Registry;
use Joomla\String\StringHelper;
use Joomla\Utilities\ArrayHelper;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Categories Component Category Model
 *
 * @since  1.6
 */
class CategoryModel extends AdminModel implements VersionableModelInterface
{
    use VersionableModelTrait;

    /**
     * The prefix to use with controller messages.
     *
     * @var    string
     * @since  1.6
     */
    protected $text_prefix = 'COM_CATEGORIES';

    /**
     * The type alias for this content type. Used for content version history.
     *
     * @var      string
     * @since    3.2
     */
    public $typeAlias = null;

    /**
     * The context used for the associations table
     *
     * @var      string
     * @since    3.4.4
     */
    protected $associationsContext = 'com_categories.item';

    /**
     * Does an association exist? Caches the result of getAssoc().
     *
     * @var   boolean|null
     * @since 3.10.4
     */
    private $hasAssociation;

    /**
     * Override parent constructor.
     *
     * @param   array                 $config   An optional associative array of configuration settings.
     * @param   ?MVCFactoryInterface  $factory  The factory.
     *
     * @see     \Joomla\CMS\MVC\Model\BaseDatabaseModel
     * @since   3.2
     */
    public function __construct($config = [], ?MVCFactoryInterface $factory = null)
    {
        $extension       = Factory::getApplication()->getInput()->get('extension', 'com_content');
        $this->typeAlias = $extension . '.category';

        // Add a new batch command
        $this->batch_commands['flip_ordering'] = 'batchFlipordering';

        parent::__construct($config, $factory);
    }

    /**
     * Method to test whether a record can be deleted.
     *
     * @param   object  $record  A record object.
     *
     * @return  boolean  True if allowed to delete the record. Defaults to the permission set in the component.
     *
     * @since   1.6
     */
    protected function canDelete($record)
    {
        if (empty($record->id) || $record->published != -2) {
            return false;
        }

        return $this->getCurrentUser()->authorise('core.delete', $record->extension . '.category.' . (int) $record->id);
    }

    /**
     * Method to delete one or more records.
     *
     * @param   array  &$pks  An array of record primary keys.
     *
     * @return  boolean  True if successful, false if an error occurs.
     *
      * @since   6.2.0
     */
    public function delete(&$pks)
    {
        $menuDependencies = $this->countMenuDependenciesForCategories((array) $pks);

        if ($menuDependencies > 0 && Factory::getApplication()->isClient('administrator')) {
            Factory::getApplication()->enqueueMessage(Text::plural('COM_CATEGORIES_DELETE_WARNING_MENU_DEPENDENCIES', $menuDependencies), 'warning');
        }

        return parent::delete($pks);
    }

    /**
     * Count menu links that reference selected com_content categories.
     *
     * @param   array  $categoryIds  Category IDs to check.
     *
     * @return  integer
     *
     * @since   6.2.0
     */
    private function countMenuDependenciesForCategories(array $categoryIds): int
    {
        $categoryIds = array_filter(ArrayHelper::toInteger($categoryIds));

        if ($categoryIds === []) {
            return 0;
        }

        $db    = $this->getDatabase();
        $query = $db->createQuery()
            ->select($db->quoteName(['id', 'extension']))
            ->from($db->quoteName('#__categories'))
            ->whereIn($db->quoteName('id'), $categoryIds);

        $db->setQuery($query);

        $contentCategoryIds = [];

        foreach ($db->loadAssocList() as $category) {
            if (strpos((string) ($category['extension'] ?? ''), 'com_content') === 0) {
                $contentCategoryIds[] = (int) $category['id'];
            }
        }

        if ($contentCategoryIds === []) {
            return 0;
        }

        $query = $db->createQuery()
            ->select($db->quoteName('link'))
            ->from($db->quoteName('#__menu'))
            ->where($db->quoteName('published') . ' != -2')
            ->where($db->quoteName('link') . ' LIKE :option')
            ->where($db->quoteName('link') . ' LIKE :view')
            ->bind(':option', '%option=com_content%')
            ->bind(':view', '%view=category%');

        $db->setQuery($query);

        $count = 0;

        foreach ($db->loadColumn() as $link) {
            $menuCategoryId = $this->extractContentCategoryIdFromMenuLink((string) $link);

            if ($menuCategoryId > 0 && \in_array($menuCategoryId, $contentCategoryIds, true)) {
                $count++;
            }
        }

        return $count;
    }

    /**
     * Extract category id for a com_content category menu link.
     *
     * @param   string  $link  Menu item link.
     *
     * @return  integer
     *
      * @since   6.2.0
     */
    private function extractContentCategoryIdFromMenuLink(string $link): int
    {
        $decodedLink = html_entity_decode($link, ENT_QUOTES, 'UTF-8');
        $queryString = parse_url($decodedLink, PHP_URL_QUERY);

        if (!$queryString) {
            $parts       = explode('?', $decodedLink, 2);
            $queryString = $parts[1] ?? '';
        }

        if ($queryString === '') {
            return 0;
        }

        parse_str($queryString, $params);

        if (($params['option'] ?? '') !== 'com_content' || ($params['view'] ?? '') !== 'category') {
            return 0;
        }

        return (int) ($params['id'] ?? 0);
    }

    /**
     * Method to test whether a record can have its state changed.
     *
     * @param   object  $record  A record object.
     *
     * @return  boolean  True if allowed to change the state of the record. Defaults to the permission set in the component.
     *
     * @since   1.6
     */
    protected function canEditState($record)
    {
        $user = $this->getCurrentUser();

        // Check for existing category.
        if (!empty($record->id)) {
            return $user->authorise('core.edit.state', $record->extension . '.category.' . (int) $record->id);
        }

        // New category, so check against the parent.
        if (!empty($record->parent_id)) {
            return $user->authorise('core.edit.state', $record->extension . '.category.' . (int) $record->parent_id);
        }

        // Default to component settings if neither category nor parent known.
        return $user->authorise('core.edit.state', $record->extension);
    }

    /**
     * Method to get a table object, load it if necessary.
     *
     * @param   string  $type    The table name. Optional.
     * @param   string  $prefix  The class prefix. Optional.
     * @param   array   $config  Configuration array for model. Optional.
     *
     * @return  \Joomla\CMS\Table\Table  A Table object
     *
     * @since   1.6
     */
    public function getTable($type = 'Category', $prefix = 'Administrator', $config = [])
    {
        return parent::getTable($type, $prefix, $config);
    }

    /**
     * Auto-populate the model state.
     *
     * Note. Calling getState in this method will result in recursion.
     *
     * @return  void
     *
     * @since   1.6
     */
    protected function populateState()
    {
        $app = Factory::getApplication();

        $parentId = $app->getInput()->getInt('parent_id');
        $this->setState('category.parent_id', $parentId);

        // Load the User state.
        $pk = $app->getInput()->getInt('id');
        $this->setState($this->getName() . '.id', $pk);

        $extension = $app->getInput()->get('extension', 'com_content');
        $this->setState('category.extension', $extension);
        $parts = explode('.', $extension);

        // Extract the component name
        $this->setState('category.component', $parts[0]);

        // Extract the optional section name
        $this->setState('category.section', (\count($parts) > 1) ? $parts[1] : null);

        // Load the parameters.
        $params = ComponentHelper::getParams('com_categories');
        $this->setState('params', $params);
    }

    /**
     * Method to get a category.
     *
     * @param   integer  $pk  An optional id of the object to get, otherwise the id from the model state is used.
     *
     * @return  mixed    Category data object on success, false on failure.
     *
     * @since   1.6
     */
    public function getItem($pk = null)
    {
        if ($result = parent::getItem($pk)) {
            // Prime required properties.
            if (empty($result->id)) {
                $result->parent_id = $this->getState('category.parent_id');
                $result->extension = $this->getState('category.extension');
            }

            // Convert the metadata field to an array.
            $registry         = new Registry($result->metadata);
            $result->metadata = $registry->toArray();

            if (!empty($result->id)) {
                $result->tags = new TagsHelper();
                $result->tags->getTagIds($result->id, $result->extension . '.category');
            }
        }

        $assoc = $this->getAssoc();

        if ($assoc) {
            if ($result->id != null) {
                $result->associations = ArrayHelper::toInteger(CategoriesHelper::getAssociations($result->id, $result->extension));
            } else {
                $result->associations = [];
            }
        }

        return $result;
    }

    /**
     * Method to get the row form.
     *
     * @param   array    $data      Data for the form.
     * @param   boolean  $loadData  True if the form is to load its own data (default case), false if not.
     *
     * @return  Form  A Form object
     *
     * @since   1.6
     * @throws  \Exception on failure
     */
    public function getForm($data = [], $loadData = true)
    {
        $extension = $this->getState('category.extension');
        $jinput    = Factory::getApplication()->getInput();

        // A workaround to get the extension into the model for save requests.
        if (empty($extension) && isset($data['extension'])) {
            $extension = $data['extension'];
            $parts     = explode('.', $extension);

            $this->setState('category.extension', $extension);
            $this->setState('category.component', $parts[0]);
            $this->setState('category.section', @$parts[1]);
        }

        // Get the form.
        $form = $this->loadForm('com_categories.category' . $extension, 'category', ['control' => 'jform', 'load_data' => $loadData]);

        // Modify the form based on Edit State access controls.
        if (empty($data['extension'])) {
            $data['extension'] = $extension;
        }

        $categoryId = $jinput->get('id');
        $parts      = explode('.', $extension);
        $assetKey   = $categoryId ? $extension . '.category.' . $categoryId : $parts[0];

        if (!$this->getCurrentUser()->authorise('core.edit.state', $assetKey)) {
            // Disable fields for display.
            $form->setFieldAttribute('ordering', 'disabled', 'true');
            $form->setFieldAttribute('published', 'disabled', 'true');

            // Disable fields while saving.
            // The controller has already verified this is a record you can edit.
            $form->setFieldAttribute('ordering', 'filter', 'unset');
            $form->setFieldAttribute('published', 'filter', 'unset');
        }

        // Don't allow to change the created_user_id user if not allowed to access com_users.
        if (!$this->getCurrentUser()->authorise('core.manage', 'com_users')) {
            $form->setFieldAttribute('created_user_id', 'filter', 'unset');
        }

        return $form;
    }

    /**
     * A protected method to get the where clause for the reorder
     * This ensures that the row will be moved relative to a row with the same extension
     *
     * @param   Category  $table  Current table instance
     *
     * @return  array  An array of conditions to add to ordering queries.
     *
     * @since   1.6
     */
    protected function getReorderConditions($table)
    {
        $db = $this->getDatabase();

        return [
            $db->quoteName('extension') . ' = ' . $db->quote($table->extension),
        ];
    }

    /**
     * Method to get the data that should be injected in the form.
     *
     * @return  mixed  The data for the form.
     *
     * @since   1.6
     */
    protected function loadFormData()
    {
        // Check the session for previously entered form data.
        $app  = Factory::getApplication();
        $data = $app->getUserState('com_categories.edit.' . $this->getName() . '.data', []);

        if (empty($data)) {
            $data = $this->getItem();

            // Pre-select some filters (Status, Language, Access) in edit form if those have been selected in Category Manager
            if (!$data->id) {
                // Check for which extension the Category Manager is used and get selected fields
                $extension = substr($app->getUserState('com_categories.categories.filter.extension', ''), 4);
                $filters   = (array) $app->getUserState('com_categories.categories.' . $extension . '.filter');

                $data->published = $app->getInput()->getInt(
                    'published',
                    ((isset($filters['published']) && $filters['published'] !== '') ? $filters['published'] : null)
                );
                $data->language = $app->getInput()->getString('language', (!empty($filters['language']) ? $filters['language'] : null));
                $data->access   = $app->getInput()->getInt('access', (!empty($filters['access']) ? $filters['access'] : $app->get('access')));
            }
        }

        $this->preprocessData('com_categories.category', $data);

        return $data;
    }

    /**
     * Method to validate the form data.
     *
     * @param   Form    $form   The form to validate against.
     * @param   array   $data   The data to validate.
     * @param   string  $group  The name of the field group to validate.
     *
     * @return  array|boolean  Array of filtered data if valid, false otherwise.
     *
     * @see     \Joomla\CMS\Form\FormRule
     * @see     \Joomla\CMS\Filter\InputFilter
     * @since   3.9.23
     */
    public function validate($form, $data, $group = null)
    {
        if (!$this->getCurrentUser()->authorise('core.admin', $data['extension'])) {
            if (isset($data['rules'])) {
                unset($data['rules']);
            }
        }

        return parent::validate($form, $data, $group);
    }

    /**
     * Method to preprocess the form.
     *
     * @param   Form    $form  A Form object.
     * @param   mixed   $data  The data expected for the form.
     * @param   string  $group The name of the plugin group to import.
     *
     * @return  mixed
     *
     * @since   1.6
     *
     * @throws  \Exception if there is an error in the form event.
     *
     * @see     \Joomla\CMS\Form\FormField
     */
    protected function preprocessForm(Form $form, $data, $group = 'content')
    {
        $lang      = Factory::getLanguage();
        $component = $this->getState('category.component');
        $section   = $this->getState('category.section');
        $extension = Factory::getApplication()->getInput()->get('extension', null);

        // Get the component form if it exists
        $name = 'category' . ($section ? ('.' . $section) : '');

        // Looking first in the component forms folder
        $path = Path::clean(JPATH_ADMINISTRATOR . "/components/$component/forms/$name.xml");

        // Looking in the component models/forms folder (J! 3)
        if (!file_exists($path)) {
            $path = Path::clean(JPATH_ADMINISTRATOR . "/components/$component/models/forms/$name.xml");
        }

        // Old way: looking in the component folder
        if (!file_exists($path)) {
            $path = Path::clean(JPATH_ADMINISTRATOR . "/components/$component/$name.xml");
        }

        if (file_exists($path)) {
            $lang->load($component, JPATH_BASE);
            $lang->load($component, JPATH_BASE . '/components/' . $component);

            if (!$form->loadFile($path, false)) {
                throw new \Exception(Text::_('JERROR_LOADFILE_FAILED'));
            }
        }

        $componentInterface = Factory::getApplication()->bootComponent($component);

        if ($componentInterface instanceof CategoryServiceInterface) {
            $componentInterface->prepareForm($form, $data);
        } else {
            // Try to find the component helper.
            $eName = str_replace('com_', '', $component);
            $path  = Path::clean(JPATH_ADMINISTRATOR . "/components/$component/helpers/category.php");

            if (file_exists($path)) {
                $cName = ucfirst($eName) . ucfirst($section) . 'HelperCategory';

                \JLoader::register($cName, $path);

                if (class_exists($cName) && \is_callable([$cName, 'onPrepareForm'])) {
                    $lang->load($component, JPATH_BASE, null, false, false)
                        || $lang->load($component, JPATH_BASE . '/components/' . $component, null, false, false)
                        || $lang->load($component, JPATH_BASE, $lang->getDefault(), false, false)
                        || $lang->load($component, JPATH_BASE . '/components/' . $component, $lang->getDefault(), false, false);
                    \call_user_func_array([$cName, 'onPrepareForm'], [&$form]);

                    // Check for an error.
                    if ($form instanceof \Exception) {
                        $this->setError($form->getMessage());

                        return false;
                    }
                }
            }
        }

        // Set the access control rules field component value.
        $form->setFieldAttribute('rules', 'component', $component);
        $form->setFieldAttribute('rules', 'section', $name);

        // Association category items
        if ($this->getAssoc()) {
            $languages = LanguageHelper::getContentLanguages(false, false, null, 'ordering', 'asc');

            if (\count($languages) > 1) {
                $addform = new \SimpleXMLElement('<form />');
                $fields  = $addform->addChild('fields');
                $fields->addAttribute('name', 'associations');
                $fieldset = $fields->addChild('fieldset');
                $fieldset->addAttribute('name', 'item_associations');

                foreach ($languages as $language) {
                    $field = $fieldset->addChild('field');
                    $field->addAttribute('name', $language->lang_code);
                    $field->addAttribute('type', 'modal_category');
                    $field->addAttribute('language', $language->lang_code);
                    $field->addAttribute('label', $language->title);
                    $field->addAttribute('translate_label', 'false');
                    $field->addAttribute('extension', $extension);
                    $field->addAttribute('select', 'true');
                    $field->addAttribute('new', 'true');
                    $field->addAttribute('edit', 'true');
                    $field->addAttribute('clear', 'true');
                    $field->addAttribute('propagate', 'true');
                }

                $form->load($addform, false);
            }
        }

        // Trigger the default form events.
        parent::preprocessForm($form, $data, $group);
    }

    /**
     * Method to save the form data.
     *
     * @param   array  $data  The form data.
     *
     * @return  boolean  True on success.
     *
     * @since   1.6
     */
    public function save($data)
    {
        $assoc = $this->getAssoc();

        if (!$assoc) {
            $this->associationsContext = null;
        }

        $table      = $this->getTable();
        $input      = Factory::getApplication()->getInput();
        $pk         = (!empty($data['id'])) ? $data['id'] : (int) $this->getState($this->getName() . '.id');

        // Load the row if saving an existing category.
        if ($pk > 0) {
            $table->load($pk);
        }

        // Alter the title for save as copy
        if ($input->get('task') == 'save2copy') {
            $origTable = $this->getTable();
            $origTable->load($input->getInt('id'));

            if ($data['title'] == $origTable->title) {
                [$title, $alias] = $this->generateNewTitle($data['parent_id'], $data['alias'], $data['title']);
                $data['title']   = $title;
                $data['alias']   = $alias;
            } else {
                if ($data['alias'] == $origTable->alias) {
                    $data['alias'] = '';
                }
            }

            $data['published'] = 0;
        }

        $result = parent::save($data);

        if ($result) {
            /**
             * @var CategoryTable
             */
            $table = $this->getTable();

            $categoryId = $this->getState($this->getName() . '.id');

            $table->load($categoryId);

            // Rebuild the path for the category:
            if (!$table->rebuildPath($table->id)) {
                $this->setError($table->getError());

                return false;
            }

            // Rebuild the paths of the category's children:
            if (!$table->rebuild($table->id, $table->lft, $table->level, $table->path)) {
                $this->setError($table->getError());

                return false;
            }
        }

        return $result;
    }

    /**
     * Prepare and sanitise the table data prior to saving.
     *
     * @param   Table  $table  A reference to a Table object.
     *
     * @return  void
     *
     * @since   6.1.0
     */
    protected function prepareTable($table)
    {
        $oldTable = clone $table;

        $oldTable->load();

        // Set the new parent id if parent id not matched OR while New/Save as Copy .
        if (empty($table->id) || (int) $table->parent_id !== (int) $oldTable->parent_id) {
            $table->setLocation($table->parent_id, 'last-child');
        }
    }

    /**
     * Method to change the published state of one or more records.
     *
     * @param   array    $pks    A list of the primary keys to change.
     * @param   integer  $value  The value of the published state.
     *
     * @return  boolean  True on success.
     *
     * @since   2.5
     */
    public function publish(&$pks, $value = 1)
    {
        if (parent::publish($pks, $value)) {
            $extension = Factory::getApplication()->getInput()->get('extension');

            // Include the content plugins for the change of category state event.
            PluginHelper::importPlugin('content', null, true, $this->getDispatcher());

            // Trigger the onCategoryChangeState event.
            $this->getDispatcher()->dispatch('onCategoryChangeState', new AfterCategoryChangeStateEvent('onCategoryChangeState', [
                'context' => $extension,
                'subject' => $pks,
                'value'   => $value,
            ]));

            return true;
        }

        return false;
    }

    /**
     * Method rebuild the entire nested set tree.
     *
     * @return  boolean  False on failure or error, true otherwise.
     *
     * @since   1.6
     */
    public function rebuild()
    {
        // Get an instance of the table object.
        $table = $this->getTable();

        if (!$table->rebuild()) {
            $this->setError($table->getError());

            return false;
        }

        // Clear the cache
        $this->cleanCache();

        return true;
    }

    /**
     * Method to save the reordered nested set tree.
     * First we save the new order values in the lft values of the changed ids.
     * Then we invoke the table rebuild to implement the new ordering.
     *
     * @param   array    $idArray   An array of primary key ids.
     * @param   integer  $lftArray  The lft value
     *
     * @return  boolean  False on failure or error, True otherwise
     *
     * @since   1.6
     */
    public function saveorder($idArray = null, $lftArray = null)
    {
        // Get an instance of the table object.
        $table = $this->getTable();

        if (!$table->saveorder($idArray, $lftArray)) {
            $this->setError($table->getError());

            return false;
        }

        // Clear the cache
        $this->cleanCache();

        return true;
    }

    /**
     * Batch flip category ordering.
     *
     * @param   integer  $value     The new category.
     * @param   array    $pks       An array of row IDs.
     * @param   array    $contexts  An array of item contexts.
     *
     * @return  mixed    An array of new IDs on success, boolean false on failure.
     *
     * @since   3.6.3
     */
    protected function batchFlipordering($value, $pks, $contexts)
    {
        $successful = [];

        $db    = $this->getDatabase();
        $query = $db->createQuery();

        /**
         * For each category get the max ordering value
         * Re-order with max - ordering
         */
        foreach ($pks as $id) {
            $query->clear()
                ->select('MAX(' . $db->quoteName('ordering') . ')')
                ->from($db->quoteName('#__content'))
                ->where($db->quoteName('catid') . ' = :catid')
                ->bind(':catid', $id, ParameterType::INTEGER);

            $db->setQuery($query);

            $max = (int) $db->loadResult();
            $max++;

            $query->clear()
                ->update($db->quoteName('#__content'))
                ->set($db->quoteName('ordering') . ' = :max - ' . $db->quoteName('ordering'))
                ->where($db->quoteName('catid') . ' = :catid')
                ->bind(':max', $max, ParameterType::INTEGER)
                ->bind(':catid', $id, ParameterType::INTEGER);

            $db->setQuery($query);

            if ($db->execute()) {
                $successful[] = $id;
            }
        }

        return empty($successful) ? false : $successful;
    }

    /**
     * Batch copy categories to a new category.
     *
     * @param   integer  $value     The new category.
     * @param   array    $pks       An array of row IDs.
     * @param   array    $contexts  An array of item contexts.
     *
     * @return  mixed    An array of new IDs on success, boolean false on failure.
     *
     * @since   1.6
     */
    protected function batchCopy($value, $pks, $contexts)
    {
        $type       = new UCMType();
        $this->type = $type->getTypeByAlias($this->typeAlias);

        // $value comes as {parent_id}.{extension}
        $parts    = explode('.', $value);
        $parentId = (int) ArrayHelper::getValue($parts, 0, 1);

        $db        = $this->getDatabase();
        $extension = Factory::getApplication()->getInput()->get('extension', '', 'word');
        $newIds    = [];

        // Check that the parent exists
        if ($parentId) {
            if (!$this->table->load($parentId)) {
                if ($error = $this->table->getError()) {
                    // Fatal error
                    $this->setError($error);

                    return false;
                }

                // Non-fatal error
                $this->setError(Text::_('JGLOBAL_BATCH_MOVE_PARENT_NOT_FOUND'));
                $parentId = 0;
            }

            // Check that user has create permission for parent category
            if ($parentId == $this->table->getRootId()) {
                $canCreate = $this->user->authorise('core.create', $extension);
            } else {
                $canCreate = $this->user->authorise('core.create', $extension . '.category.' . $parentId);
            }

            if (!$canCreate) {
                // Error since user cannot create in parent category
                $this->setError(Text::_('COM_CATEGORIES_BATCH_CANNOT_CREATE'));

                return false;
            }
        }

        // If the parent is 0, set it to the ID of the root item in the tree
        if (empty($parentId)) {
            if (!$parentId = $this->table->getRootId()) {
                $this->setError($this->table->getError());

                return false;
            }

            if (!$this->user->authorise('core.create', $extension)) {
                // Make sure we can create in root
                $this->setError(Text::_('COM_CATEGORIES_BATCH_CANNOT_CREATE'));

                return false;
            }
        }

        // We need to log the parent ID
        $parents = [];

        // Calculate the emergency stop count as a precaution against a runaway loop bug
        $query = $db->createQuery()
            ->select('COUNT(' . $db->quoteName('id') . ')')
            ->from($db->quoteName('#__categories'));
        $db->setQuery($query);

        try {
            $count = $db->loadResult();
        } catch (\RuntimeException $e) {
            $this->setError($e->getMessage());

            return false;
        }

        // Parent exists so let's proceed
        while (!empty($pks) && $count > 0) {
            // Pop the first id off the stack
            $pk = array_shift($pks);

            $this->table->reset();

            // Check that the row actually exists
            if (!$this->table->load($pk)) {
                if ($error = $this->table->getError()) {
                    // Fatal error
                    $this->setError($error);

                    return false;
                }

                // Not fatal error
                $this->setError(Text::sprintf('JGLOBAL_BATCH_MOVE_ROW_NOT_FOUND', $pk));
                continue;
            }

            // Copy is a bit tricky, because we also need to copy the children
            $lft = (int) $this->table->lft;
            $rgt = (int) $this->table->rgt;
            $query->clear()
                ->select($db->quoteName('id'))
                ->from($db->quoteName('#__categories'))
                ->where($db->quoteName('lft') . ' > :lft')
                ->where($db->quoteName('rgt') . ' < :rgt')
                ->bind(':lft', $lft, ParameterType::INTEGER)
                ->bind(':rgt', $rgt, ParameterType::INTEGER);
            $db->setQuery($query);
            $childIds = $db->loadColumn();

            // Add child ID's to the array only if they aren't already there.
            foreach ($childIds as $childId) {
                if (!\in_array($childId, $pks)) {
                    $pks[] = $childId;
                }
            }

            // Make a copy of the old ID, Parent ID and Asset ID
            $oldId       = $this->table->id;
            $oldParentId = $this->table->parent_id;
            $oldAssetId  = $this->table->asset_id;

            // Reset the id because we are making a copy.
            $this->table->id = 0;

            // If we a copying children, the Old ID will turn up in the parents list
            // otherwise it's a new top level item
            $this->table->parent_id = $parents[$oldParentId] ?? $parentId;

            // Set the new location in the tree for the node.
            $this->table->setLocation($this->table->parent_id, 'last-child');

            // @TODO: Deal with ordering?
            // $this->table->ordering = 1;
            $this->table->level    = null;
            $this->table->asset_id = null;
            $this->table->lft      = null;
            $this->table->rgt      = null;

            // Alter the title & alias
            [$title, $alias]     = $this->generateNewTitle($this->table->parent_id, $this->table->alias, $this->table->title);
            $this->table->title  = $title;
            $this->table->alias  = $alias;

            // Unpublish because we are making a copy
            $this->table->published = 0;

            // Store the row.
            if (!$this->table->store()) {
                $this->setError($this->table->getError());

                return false;
            }

            // Get the new item ID
            $newId = $this->table->id;

            // Add the new ID to the array
            $newIds[$pk] = $newId;

            // Copy rules
            $query->clear()
                ->update($db->quoteName('#__assets', 't'))
                ->join(
                    'INNER',
                    $db->quoteName('#__assets', 's'),
                    $db->quoteName('s.id') . ' = :oldid'
                )
                ->bind(':oldid', $oldAssetId, ParameterType::INTEGER)
                ->set($db->quoteName('t.rules') . ' = ' . $db->quoteName('s.rules'))
                ->where($db->quoteName('t.id') . ' = :assetid')
                ->bind(':assetid', $this->table->asset_id, ParameterType::INTEGER);
            $db->setQuery($query)->execute();

            // Now we log the old 'parent' to the new 'parent'
            $parents[$oldId] = $this->table->id;
            $count--;
        }

        // Rebuild the hierarchy.
        if (!$this->table->rebuild()) {
            $this->setError($this->table->getError());

            return false;
        }

        // Rebuild the tree path.
        if (!$this->table->rebuildPath($this->table->id)) {
            $this->setError($this->table->getError());

            return false;
        }

        return $newIds;
    }

    /**
     * Batch move categories to a new category.
     *
     * @param   integer  $value     The new category ID.
     * @param   array    $pks       An array of row IDs.
     * @param   array    $contexts  An array of item contexts.
     *
     * @return  boolean  True on success.
     *
     * @since   1.6
     */
    protected function batchMove($value, $pks, $contexts)
    {
        $parentId   = (int) $value;
        $type       = new UCMType();
        $this->type = $type->getTypeByAlias($this->typeAlias);

        $db        = $this->getDatabase();
        $query     = $db->createQuery();
        $extension = Factory::getApplication()->getInput()->get('extension', '', 'word');

        // Check that the parent exists.
        if ($parentId) {
            if (!$this->table->load($parentId)) {
                if ($error = $this->table->getError()) {
                    // Fatal error.
                    $this->setError($error);

                    return false;
                }

                // Non-fatal error.
                $this->setError(Text::_('JGLOBAL_BATCH_MOVE_PARENT_NOT_FOUND'));
                $parentId = 0;
            }

            // Check that user has create permission for parent category.
            if ($parentId == $this->table->getRootId()) {
                $canCreate = $this->user->authorise('core.create', $extension);
            } else {
                $canCreate = $this->user->authorise('core.create', $extension . '.category.' . $parentId);
            }

            if (!$canCreate) {
                // Error since user cannot create in parent category
                $this->setError(Text::_('COM_CATEGORIES_BATCH_CANNOT_CREATE'));

                return false;
            }

            // Check that user has edit permission for every category being moved
            // Note that the entire batch operation fails if any category lacks edit permission
            foreach ($pks as $pk) {
                if (!$this->user->authorise('core.edit', $extension . '.category.' . $pk)) {
                    // Error since user cannot edit this category
                    $this->setError(Text::_('COM_CATEGORIES_BATCH_CANNOT_EDIT'));

                    return false;
                }
            }
        }

        // We are going to store all the children and just move the category
        $children = [];

        $table = $this->getTable();

        // Parent exists so let's proceed
        foreach ($pks as $pk) {
            // Check that the row actually exists
            if (!$this->table->load($pk)) {
                if ($error = $this->table->getError()) {
                    // Fatal error
                    $this->setError($error);

                    return false;
                }

                // Not fatal error
                $this->setError(Text::sprintf('JGLOBAL_BATCH_MOVE_ROW_NOT_FOUND', $pk));
                continue;
            }

            // Set the new location in the tree for the node.
            $this->table->setLocation($parentId, 'last-child');

            // Check if we are moving to a different parent
            if ($parentId != $this->table->parent_id) {
                $lft = (int) $this->table->lft;
                $rgt = (int) $this->table->rgt;

                // Add the child node ids to the children array.
                $query->clear()
                    ->select($db->quoteName('id'))
                    ->from($db->quoteName('#__categories'))
                    ->where($db->quoteName('lft') . ' BETWEEN :lft AND :rgt')
                    ->bind(':lft', $lft, ParameterType::INTEGER)
                    ->bind(':rgt', $rgt, ParameterType::INTEGER);
                $db->setQuery($query);

                try {
                    $children = array_merge($children, (array) $db->loadColumn());
                } catch (\RuntimeException $e) {
                    $this->setError($e->getMessage());

                    return false;
                }

                // Verify that the alias is unique before move
                $conditions = [
                    'alias'     => $this->table->alias,
                    'parent_id' => $parentId,
                    'extension' => $extension,
                ];

                if ($table->load($conditions)) {
                    $this->setError(Text::_('JLIB_DATABASE_ERROR_CATEGORY_UNIQUE_ALIAS'));

                    return false;
                }
            }

            // Store the row.
            if (!$this->table->store()) {
                $this->setError($this->table->getError());

                return false;
            }

            // Rebuild the tree path.
            if (!$this->table->rebuildPath()) {
                $this->setError($this->table->getError());

                return false;
            }
        }

        // Process the child rows
        if (!empty($children)) {
            // Remove any duplicates and sanitize ids.
            $children = array_unique($children);
            $children = ArrayHelper::toInteger($children);
        }

        return true;
    }

    /**
     * Custom clean the cache of com_content and content modules
     *
     * @param  string  $group  Cache group name.
     *
     * @return  void
     *
     * @since   1.6
     */
    protected function cleanCache($group = null)
    {
        $extension = Factory::getApplication()->getInput()->get('extension');

        switch ($extension) {
            case 'com_content':
                parent::cleanCache('com_content');
                parent::cleanCache('mod_articles');
                parent::cleanCache('mod_articles_archive');
                parent::cleanCache('mod_articles_categories');
                parent::cleanCache('mod_articles_category');
                parent::cleanCache('mod_articles_latest');
                parent::cleanCache('mod_articles_news');
                parent::cleanCache('mod_articles_popular');
                break;
            default:
                parent::cleanCache($extension);
                break;
        }
    }

    /**
     * Method to change the title & alias.
     *
     * @param   integer  $parentId  The id of the parent.
     * @param   string   $alias     The alias.
     * @param   string   $title     The title.
     *
     * @return  array    Contains the modified title and alias.
     *
     * @since   1.7
     */
    protected function generateNewTitle($parentId, $alias, $title)
    {
        // Alter the title & alias
        $table = $this->getTable();

        while ($table->load(['alias' => $alias, 'parent_id' => $parentId])) {
            $title = StringHelper::increment($title);
            $alias = StringHelper::increment($alias, 'dash');
        }

        return [$title, $alias];
    }

    /**
     * Method to determine if a category association is available.
     *
     * @return  boolean True if a category association is available; false otherwise.
     */
    public function getAssoc()
    {
        if (!\is_null($this->hasAssociation)) {
            return $this->hasAssociation;
        }

        $extension = $this->getState('category.extension', '');

        $this->hasAssociation = Associations::isEnabled();
        $extension            = explode('.', $extension);
        $component            = array_shift($extension);
        $cname                = str_replace('com_', '', $component);

        if (!$this->hasAssociation || !$component || !$cname) {
            $this->hasAssociation = false;

            return $this->hasAssociation;
        }

        $componentObject = $this->bootComponent($component);

        if ($componentObject instanceof AssociationServiceInterface && $componentObject instanceof CategoryServiceInterface) {
            $this->hasAssociation = true;

            return $this->hasAssociation;
        }

        $hname = $cname . 'HelperAssociation';
        \JLoader::register($hname, JPATH_SITE . '/components/' . $component . '/helpers/association.php');

        $this->hasAssociation = class_exists($hname) && !empty($hname::$category_association);

        return $this->hasAssociation;
    }
}
