<?php
/**
 * @link https://craftcms.com/
 * @copyright Copyright (c) Pixel & Tonic, Inc.
 * @license https://craftcms.github.io/license/
 */

namespace craft\controllers;

use Craft;
use craft\base\Element;
use craft\base\Field;
use craft\elements\Category;
use craft\errors\InvalidElementException;
use craft\events\ElementEvent;
use craft\helpers\Json;
use craft\helpers\UrlHelper;
use craft\models\CategoryGroup;
use craft\models\CategoryGroup_SiteSettings;
use craft\models\Site;
use craft\web\assets\editcategory\EditCategoryAsset;
use craft\web\Controller;
use yii\base\Exception;
use yii\web\BadRequestHttpException;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\ServerErrorHttpException;

/**
 * The CategoriesController class is a controller that handles various actions related to categories and category
 * groups, such as creating, editing and deleting them.
 * Note that all actions in the controller require an authenticated Craft session via [[allowAnonymous]].
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since 3.0
 */
class CategoriesController extends Controller
{
    // Constants
    // =========================================================================

    /**
     * @event ElementEvent The event that is triggered when a category’s template is rendered for Live Preview.
     */
    const EVENT_PREVIEW_CATEGORY = 'previewCategory';

    // Public Methods
    // =========================================================================

    // Category Groups
    // -------------------------------------------------------------------------

    /**
     * Category groups index.
     *
     * @return Response
     */
    public function actionGroupIndex(): Response
    {
        $this->requireAdmin();

        $groups = Craft::$app->getCategories()->getAllGroups();

        return $this->renderTemplate('settings/categories/index', [
            'categoryGroups' => $groups
        ]);
    }

    /**
     * Edit a category group.
     *
     * @param int|null $groupId The category group’s ID, if editing an existing group.
     * @param CategoryGroup|null $categoryGroup The category group being edited, if there were any validation errors.
     * @return Response
     * @throws NotFoundHttpException if the requested category group cannot be found
     */
    public function actionEditCategoryGroup(int $groupId = null, CategoryGroup $categoryGroup = null): Response
    {
        $this->requireAdmin();

        $variables = [];

        // Breadcrumbs
        $variables['crumbs'] = [
            [
                'label' => Craft::t('app', 'Settings'),
                'url' => UrlHelper::url('settings')
            ],
            [
                'label' => Craft::t('app', 'Categories'),
                'url' => UrlHelper::url('settings/categories')
            ]
        ];

        $variables['brandNewGroup'] = false;

        if ($groupId !== null) {
            if ($categoryGroup === null) {
                $categoryGroup = Craft::$app->getCategories()->getGroupById($groupId);

                if (!$categoryGroup) {
                    throw new NotFoundHttpException('Category group not found');
                }
            }

            $variables['title'] = trim($categoryGroup->name) ?: Craft::t('app', 'Edit Category Group');
        } else {
            if ($categoryGroup === null) {
                $categoryGroup = new CategoryGroup();
                $variables['brandNewGroup'] = true;
            }

            $variables['title'] = Craft::t('app', 'Create a new category group');
        }

        $variables['tabs'] = [
            'settings' => [
                'label' => Craft::t('app', 'Settings'),
                'url' => '#categorygroup-settings'
            ],
            'fieldLayout' => [
                'label' => Craft::t('app', 'Field Layout'),
                'url' => '#categorygroup-fieldlayout'
            ]
        ];

        $variables['groupId'] = $groupId;
        $variables['categoryGroup'] = $categoryGroup;

        return $this->renderTemplate('settings/categories/_edit', $variables);
    }

    /**
     * Save a category group.
     *
     * @return Response|null
     */
    public function actionSaveGroup()
    {
        $this->requirePostRequest();
        $this->requireAdmin();

        $request = Craft::$app->getRequest();
        $group = new CategoryGroup();

        // Main group settings
        $group->id = $request->getBodyParam('groupId');
        $group->name = $request->getBodyParam('name');
        $group->handle = $request->getBodyParam('handle');
        $group->maxLevels = $request->getBodyParam('maxLevels');

        // Site-specific settings
        $allSiteSettings = [];

        foreach (Craft::$app->getSites()->getAllSites() as $site) {
            $postedSettings = $request->getBodyParam('sites.' . $site->handle);

            $siteSettings = new CategoryGroup_SiteSettings();
            $siteSettings->siteId = $site->id;

            if ($siteSettings->hasUrls = !empty($postedSettings['uriFormat'])) {
                $siteSettings->uriFormat = $postedSettings['uriFormat'];
                $siteSettings->template = $postedSettings['template'];
            }

            $allSiteSettings[$site->id] = $siteSettings;
        }

        $group->setSiteSettings($allSiteSettings);

        // Group the field layout
        $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost();
        $fieldLayout->type = Category::class;
        $group->setFieldLayout($fieldLayout);

        // Save it
        if (!Craft::$app->getCategories()->saveGroup($group)) {
            Craft::$app->getSession()->setError(Craft::t('app', 'Couldn’t save the category group.'));

            // Send the category group back to the template
            Craft::$app->getUrlManager()->setRouteParams([
                'categoryGroup' => $group
            ]);

            return null;
        }

        Craft::$app->getSession()->setNotice(Craft::t('app', 'Category group saved.'));

        return $this->redirectToPostedUrl($group);
    }

    /**
     * Deletes a category group.
     *
     * @return Response
     */
    public function actionDeleteCategoryGroup(): Response
    {
        $this->requirePostRequest();
        $this->requireAcceptsJson();
        $this->requireAdmin();

        $groupId = Craft::$app->getRequest()->getRequiredBodyParam('id');

        Craft::$app->getCategories()->deleteGroupById($groupId);

        return $this->asJson(['success' => true]);
    }

    // Categories
    // -------------------------------------------------------------------------

    /**
     * Displays the category index page.
     *
     * @param string|null $groupHandle The category group’s handle.
     * @return Response
     * @throws ForbiddenHttpException if the user is not permitted to edit categories
     */
    public function actionCategoryIndex(string $groupHandle = null): Response
    {
        $groups = Craft::$app->getCategories()->getEditableGroups();

        if (empty($groups)) {
            throw new ForbiddenHttpException('User not permitted to edit categories');
        }

        $this->view->registerTranslations('app', [
            'New category',
        ]);

        return $this->renderTemplate('categories/_index', [
            'groupHandle' => $groupHandle,
            'groups' => $groups
        ]);
    }

    /**
     * Displays the category edit page.
     *
     * @param string $groupHandle The category group’s handle.
     * @param int|null $categoryId The category’s ID, if editing an existing category.
     * @param string|null $siteHandle The site handle, if specified.
     * @param Category|null $category The category being edited, if there were any validation errors.
     * @return Response
     * @throws NotFoundHttpException if the requested site handle is invalid
     */
    public function actionEditCategory(string $groupHandle, int $categoryId = null, string $siteHandle = null, Category $category = null): Response
    {
        $variables = [
            'groupHandle' => $groupHandle,
            'categoryId' => $categoryId,
            'category' => $category
        ];

        if ($siteHandle !== null) {
            $variables['site'] = Craft::$app->getSites()->getSiteByHandle($siteHandle);

            if (!$variables['site']) {
                throw new NotFoundHttpException('Invalid site handle: ' . $siteHandle);
            }
        }

        $this->_prepEditCategoryVariables($variables);

        /** @var Site $site */
        $site = $variables['site'];
        /** @var Category $category */
        $category = $variables['category'];

        $this->_enforceEditCategoryPermissions($category);

        $request = Craft::$app->getRequest();

        // Parent Category selector variables
        // ---------------------------------------------------------------------

        if ((int)$variables['group']->maxLevels !== 1) {
            $variables['elementType'] = Category::class;

            // Define the parent options criteria
            $variables['parentOptionCriteria'] = [
                'siteId' => $site->id,
                'groupId' => $variables['group']->id,
                'status' => null,
                'enabledForSite' => false,
            ];

            if ($variables['group']->maxLevels) {
                $variables['parentOptionCriteria']['level'] = '< ' . $variables['group']->maxLevels;
            }

            if ($category->id !== null) {
                // Prevent the current category, or any of its descendants, from being options
                $excludeIds = Category::find()
                    ->descendantOf($category)
                    ->anyStatus()
                    ->ids();

                $excludeIds[] = $category->id;
                $variables['parentOptionCriteria']['where'] = [
                    'not in',
                    'elements.id',
                    $excludeIds
                ];
            }

            // Get the initially selected parent
            $parentId = $request->getParam('parentId');

            if ($parentId === null && $category->id !== null) {
                $parentId = $category->getAncestors(1)
                    ->anyStatus()
                    ->ids();
            }

            if (is_array($parentId)) {
                $parentId = reset($parentId) ?: null;
            }

            if ($parentId) {
                $variables['parent'] = Craft::$app->getCategories()->getCategoryById($parentId, $site->id);
            }
        }

        // Other variables
        // ---------------------------------------------------------------------

        // Body class
        $variables['bodyClass'] = 'edit-category site--' . $site->handle;

        // Page title
        if ($category->id === null) {
            $variables['title'] = Craft::t('app', 'Create a new category');
        } else {
            $variables['docTitle'] = $variables['title'] = trim($category->title) ?: Craft::t('app', 'Edit Category');
        }

        // Breadcrumbs
        $variables['crumbs'] = [
            [
                'label' => Craft::t('app', 'Categories'),
                'url' => UrlHelper::url('categories')
            ],
            [
                'label' => Craft::t('site', $variables['group']->name),
                'url' => UrlHelper::url('categories/' . $variables['group']->handle)
            ]
        ];

        /** @var Category $ancestor */
        foreach ($category->getAncestors()->all() as $ancestor) {
            $variables['crumbs'][] = [
                'label' => $ancestor->title,
                'url' => $ancestor->getCpEditUrl()
            ];
        }

        // Enable Live Preview?
        if (!$request->isMobileBrowser(true) && Craft::$app->getCategories()->isGroupTemplateValid($variables['group'], $category->siteId)) {
            $this->getView()->registerJs('Craft.LivePreview.init(' . Json::encode([
                    'fields' => '#title-field, #fields > div > div > .field',
                    'extraFields' => '#settings',
                    'previewUrl' => $category->getUrl(),
                    'previewAction' => 'categories/preview-category',
                    'previewParams' => [
                        'groupId' => $variables['group']->id,
                        'categoryId' => $category->id,
                        'siteId' => $category->siteId,
                    ]
                ]) . ');');

            $variables['showPreviewBtn'] = true;

            // Should we show the Share button too?
            if ($category->id !== null) {
                // If the category is enabled, use its main URL as its share URL.
                if ($category->getStatus() === Element::STATUS_ENABLED) {
                    $variables['shareUrl'] = $category->getUrl();
                } else {
                    $variables['shareUrl'] = UrlHelper::actionUrl('categories/share-category',
                        [
                            'categoryId' => $category->id,
                            'siteId' => $category->siteId
                        ]);
                }
            }
        } else {
            $variables['showPreviewBtn'] = false;
        }

        // Set the base CP edit URL
        $variables['baseCpEditUrl'] = "categories/{$variables['group']->handle}/{id}-{slug}";

        // Set the "Continue Editing" URL
        $siteSegment = Craft::$app->getIsMultiSite() && Craft::$app->getSites()->getCurrentSite()->id != $site->id ? "/{$site->handle}" : '';
        $variables['continueEditingUrl'] = $variables['baseCpEditUrl'] . $siteSegment;

        // Set the "Save and add another" URL
        $variables['nextCategoryUrl'] = "categories/{$variables['group']->handle}/new{$siteSegment}";

        // Render the template!
        $this->getView()->registerAssetBundle(EditCategoryAsset::class);

        return $this->renderTemplate('categories/_edit', $variables);
    }

    /**
     * Previews a category.
     *
     * @return Response
     */
    public function actionPreviewCategory(): Response
    {
        $this->requirePostRequest();

        $category = $this->_getCategoryModel();
        $this->_enforceEditCategoryPermissions($category);
        $this->_populateCategoryModel($category);

        // Fire a 'previewCategory' event
        if ($this->hasEventHandlers(self::EVENT_PREVIEW_CATEGORY)) {
            $this->trigger(self::EVENT_PREVIEW_CATEGORY, new ElementEvent([
                'element' => $category,
            ]));
        }

        return $this->_showCategory($category);
    }

    /**
     * Saves an category.
     *
     * @return Response|null
     * @throws ServerErrorHttpException
     */
    public function actionSaveCategory()
    {
        $this->requirePostRequest();

        $category = $this->_getCategoryModel();
        $request = Craft::$app->getRequest();

        // Permission enforcement
        $this->_enforceEditCategoryPermissions($category);

        // Are we duplicating the category?
        if ($request->getBodyParam('duplicate')) {
            // Swap $category with the duplicate
            try {
                $category = Craft::$app->getElements()->duplicateElement($category);
            } catch (InvalidElementException $e) {
                /** @var Category $clone */
                $clone = $e->element;

                if ($request->getAcceptsJson()) {
                    return $this->asJson([
                        'success' => false,
                        'errors' => $clone->getErrors(),
                    ]);
                }

                Craft::$app->getSession()->setError(Craft::t('app', 'Couldn’t duplicate category.'));

                // Send the original category back to the template, with any validation errors on the clone
                $category->addErrors($clone->getErrors());
                Craft::$app->getUrlManager()->setRouteParams([
                    'category' => $category
                ]);

                return null;
            } catch (\Throwable $e) {
                throw new ServerErrorHttpException(Craft::t('app', 'An error occurred when duplicating the category.'), 0, $e);
            }
        }

        // Populate the category with post data
        $this->_populateCategoryModel($category);

        // Save the category
        if ($category->enabled && $category->enabledForSite) {
            $category->setScenario(Element::SCENARIO_LIVE);
        }

        if (!Craft::$app->getElements()->saveElement($category)) {
            if ($request->getAcceptsJson()) {
                return $this->asJson([
                    'success' => false,
                    'errors' => $category->getErrors(),
                ]);
            }

            Craft::$app->getSession()->setError(Craft::t('app', 'Couldn’t save category.'));

            // Send the category back to the template
            Craft::$app->getUrlManager()->setRouteParams([
                'category' => $category
            ]);

            return null;
        }

        if ($request->getAcceptsJson()) {
            return $this->asJson([
                'success' => true,
                'id' => $category->id,
                'title' => $category->title,
                'slug' => $category->slug,
                'status' => $category->getStatus(),
                'url' => $category->getUrl(),
                'cpEditUrl' => $category->getCpEditUrl()
            ]);
        }

        Craft::$app->getSession()->setNotice(Craft::t('app', 'Category saved.'));

        return $this->redirectToPostedUrl($category);
    }

    /**
     * Deletes a category.
     *
     * @return Response|null
     * @throws NotFoundHttpException if the requested category cannot be found
     */
    public function actionDeleteCategory()
    {
        $this->requirePostRequest();

        $request = Craft::$app->getRequest();
        $categoryId = $request->getRequiredBodyParam('categoryId');
        $category = Craft::$app->getCategories()->getCategoryById($categoryId);

        if (!$category) {
            throw new NotFoundHttpException('Category not found');
        }

        // Make sure they have permission to do this
        $this->requirePermission('editCategories:' . $category->groupId);

        // Delete it
        if (!Craft::$app->getElements()->deleteElement($category)) {
            if ($request->getAcceptsJson()) {
                return $this->asJson(['success' => false]);
            }

            Craft::$app->getSession()->setError(Craft::t('app', 'Couldn’t delete category.'));

            // Send the category back to the template
            Craft::$app->getUrlManager()->setRouteParams([
                'category' => $category
            ]);

            return null;
        }

        if ($request->getAcceptsJson()) {
            return $this->asJson(['success' => true]);
        }

        Craft::$app->getSession()->setNotice(Craft::t('app', 'Category deleted.'));

        return $this->redirectToPostedUrl($category);
    }

    /**
     * Redirects the client to a URL for viewing a disabled category on the front end.
     *
     * @param int $categoryId
     * @param int|null $siteId
     * @return Response
     * @throws Exception
     * @throws NotFoundHttpException if the requested category cannot be found
     * @throws ServerErrorHttpException if the category group is not configured properly
     */
    public function actionShareCategory(int $categoryId, int $siteId = null): Response
    {
        $category = Craft::$app->getCategories()->getCategoryById($categoryId, $siteId);

        if (!$category) {
            throw new NotFoundHttpException('Category not found');
        }

        // Make sure they have permission to be viewing this category
        $this->_enforceEditCategoryPermissions($category);

        // Make sure the category actually can be viewed
        if (!Craft::$app->getCategories()->isGroupTemplateValid($category->getGroup(), $category->siteId)) {
            throw new ServerErrorHttpException('Category group not configured properly');
        }

        // Create the token and redirect to the category URL with the token in place
        $token = Craft::$app->getTokens()->createToken([
            'categories/view-shared-category',
            [
                'categoryId' => $categoryId,
                'siteId' => $category->siteId
            ]
        ]);

        if ($token === false) {
            throw new Exception('There was a problem generating the token.');
        }

        $url = UrlHelper::urlWithToken($category->getUrl(), $token);

        return Craft::$app->getResponse()->redirect($url);
    }

    /**
     * Shows an category/draft/version based on a token.
     *
     * @param int $categoryId
     * @param int|null $siteId
     * @return Response
     * @throws NotFoundHttpException if the requested category cannot be found
     */
    public function actionViewSharedCategory(int $categoryId, int $siteId = null): Response
    {
        $this->requireToken();

        $category = Craft::$app->getCategories()->getCategoryById($categoryId, $siteId);

        if (!$category) {
            throw new NotFoundHttpException('Category not found');
        }

        return $this->_showCategory($category);
    }

    // Private Methods
    // =========================================================================

    /**
     * Preps category category variables.
     *
     * @param array &$variables
     * @throws NotFoundHttpException if the requested category group or category cannot be found
     * @throws ForbiddenHttpException if the user is not permitted to edit content in the requested site
     */
    private function _prepEditCategoryVariables(array &$variables)
    {
        // Get the category group
        // ---------------------------------------------------------------------

        if (!empty($variables['groupHandle'])) {
            $variables['group'] = Craft::$app->getCategories()->getGroupByHandle($variables['groupHandle']);
        } else if (!empty($variables['groupId'])) {
            $variables['group'] = Craft::$app->getCategories()->getGroupById($variables['groupId']);
        }

        if (empty($variables['group'])) {
            throw new NotFoundHttpException('Category group not found');
        }

        // Get the site
        // ---------------------------------------------------------------------

        if (Craft::$app->getIsMultiSite()) {
            // Only use the sites that the user has access to
            $variables['siteIds'] = Craft::$app->getSites()->getEditableSiteIds();
        } else {
            /** @noinspection PhpUnhandledExceptionInspection */
            $variables['siteIds'] = [Craft::$app->getSites()->getPrimarySite()->id];
        }

        if (!$variables['siteIds']) {
            throw new ForbiddenHttpException('User not permitted to edit content in any sites');
        }

        if (empty($variables['site'])) {
            /** @noinspection PhpUnhandledExceptionInspection */
            $variables['site'] = Craft::$app->getSites()->getCurrentSite();

            if (!in_array($variables['site']->id, $variables['siteIds'], false)) {
                $variables['site'] = Craft::$app->getSites()->getSiteById($variables['siteIds'][0]);
            }

            $site = $variables['site'];
        } else {
            // Make sure they were requesting a valid site
            /** @var Site $site */
            $site = $variables['site'];
            if (!in_array($site->id, $variables['siteIds'], false)) {
                throw new ForbiddenHttpException('User not permitted to edit content in this site');
            }
        }

        // Get the category
        // ---------------------------------------------------------------------

        if (empty($variables['category'])) {
            if (!empty($variables['categoryId'])) {
                $variables['category'] = Craft::$app->getCategories()->getCategoryById($variables['categoryId'], $site->id);

                if (!$variables['category']) {
                    throw new NotFoundHttpException('Category not found');
                }
            } else {
                $variables['category'] = new Category();
                $variables['category']->groupId = $variables['group']->id;
                $variables['category']->enabled = true;
                $variables['category']->siteId = $site->id;
            }
        }

        // Define the content tabs
        // ---------------------------------------------------------------------

        $variables['tabs'] = [];

        foreach ($variables['group']->getFieldLayout()->getTabs() as $index => $tab) {
            // Do any of the fields on this tab have errors?
            $hasErrors = false;

            if ($variables['category']->hasErrors()) {
                foreach ($tab->getFields() as $field) {
                    /** @var Field $field */
                    if ($hasErrors = $variables['category']->hasErrors($field->handle . '.*')) {
                        break;
                    }
                }
            }

            $variables['tabs'][] = [
                'label' => Craft::t('site', $tab->name),
                'url' => '#' . $tab->getHtmlId(),
                'class' => $hasErrors ? 'error' : null
            ];
        }
    }

    /**
     * Fetches or creates a Category.
     *
     * @return Category
     * @throws BadRequestHttpException if the requested category group doesn't exist
     * @throws NotFoundHttpException if the requested category cannot be found
     */
    private function _getCategoryModel(): Category
    {
        $request = Craft::$app->getRequest();
        $categoryId = $request->getBodyParam('categoryId');
        $siteId = $request->getBodyParam('siteId');

        if ($categoryId) {
            $category = Craft::$app->getCategories()->getCategoryById($categoryId, $siteId);

            if (!$category) {
                throw new NotFoundHttpException('Category not found');
            }
        } else {
            $groupId = $request->getRequiredBodyParam('groupId');
            if (($group = Craft::$app->getCategories()->getGroupById($groupId)) === null) {
                throw new BadRequestHttpException('Invalid category group ID: ' . $groupId);
            }

            $category = new Category();
            $category->groupId = $group->id;
            $category->fieldLayoutId = $group->fieldLayoutId;

            if ($siteId) {
                $category->siteId = $siteId;
            }
        }

        return $category;
    }

    /**
     * Enforces all Edit Category permissions.
     *
     * @param Category $category
     */
    private function _enforceEditCategoryPermissions(Category $category)
    {
        if (Craft::$app->getIsMultiSite()) {
            // Make sure they have access to this site
            $this->requirePermission('editSite:' . $category->siteId);
        }

        // Make sure the user is allowed to edit categories in this group
        $this->requirePermission('editCategories:' . $category->groupId);
    }

    /**
     * Populates an Category with post data.
     *
     * @param Category $category
     */
    private function _populateCategoryModel(Category $category)
    {
        // Set the category attributes, defaulting to the existing values for whatever is missing from the post data
        $request = Craft::$app->getRequest();
        $category->slug = $request->getBodyParam('slug', $category->slug);
        $category->enabled = (bool)$request->getBodyParam('enabled', $category->enabled);

        $category->title = $request->getBodyParam('title', $category->title);

        $fieldsLocation = $request->getParam('fieldsLocation', 'fields');
        $category->setFieldValuesFromRequest($fieldsLocation);

        // Parent
        if (($parentId = $request->getBodyParam('parentId')) !== null) {
            if (is_array($parentId)) {
                $parentId = reset($parentId) ?: '';
            }

            $category->newParentId = $parentId ?: '';
        }
    }

    /**
     * Displays a category.
     *
     * @param Category $category
     * @return Response
     * @throws ServerErrorHttpException if the category doesn't have a URL for the site it's configured with, or if the category's site ID is invalid
     */
    private function _showCategory(Category $category): Response
    {
        $categoryGroupSiteSettings = $category->getGroup()->getSiteSettings();

        if (!isset($categoryGroupSiteSettings[$category->siteId]) || !$categoryGroupSiteSettings[$category->siteId]->hasUrls) {
            throw new ServerErrorHttpException('The category ' . $category->id . ' doesn’t have a URL for the site ' . $category->siteId . '.');
        }

        $site = Craft::$app->getSites()->getSiteById($category->siteId);

        if (!$site) {
            throw new ServerErrorHttpException('Invalid site ID: ' . $category->siteId);
        }

        Craft::$app->language = $site->language;
        Craft::$app->set('locale', Craft::$app->getI18n()->getLocaleById($site->language));

        // Have this category override any freshly queried categories with the same ID/site
        Craft::$app->getElements()->setPlaceholderElement($category);

        $this->getView()->getTwig()->disableStrictVariables();

        return $this->renderTemplate($categoryGroupSiteSettings[$category->siteId]->template, [
            'category' => $category
        ]);
    }
}
