<?php namespace Cms\Controllers;

use Url;
use Lang;
use Flash;
use Config;
use Event;
use Request;
use Exception;
use BackendMenu;
use Cms\Widgets\AssetList;
use Cms\Widgets\TemplateList;
use Cms\Widgets\ComponentList;
use Cms\Classes\Page;
use Cms\Classes\Theme;
use Cms\Classes\Router;
use Cms\Classes\Layout;
use Cms\Classes\Partial;
use Cms\Classes\Content;
use Cms\Classes\CmsObject;
use Cms\Classes\CmsCompoundObject;
use Cms\Classes\ComponentManager;
use Cms\Classes\ComponentPartial;
use Cms\Helpers\Cms as CmsHelpers;
use Backend\Classes\Controller;
use System\Helpers\DateTime;
use Winter\Storm\Router\Router as StormRouter;
use ApplicationException;
use Cms\Classes\Asset;

/**
 * CMS index
 *
 * @package winter\wn-cms-module
 * @author Alexey Bobkov, Samuel Georges
 */
class Index extends Controller
{
    use \Backend\Traits\InspectableContainer;

    /**
     * @var Cms\Classes\Theme
     */
    protected $theme;

    /**
     * @var array Permissions required to view this page.
     */
    public $requiredPermissions = [
        'cms.manage_content',
        'cms.manage_assets',
        'cms.manage_pages',
        'cms.manage_layouts',
        'cms.manage_partials'
    ];

    /**
     * Constructor.
     */
    public function __construct()
    {
        parent::__construct();

        Event::listen('backend.form.extendFieldsBefore', function ($widget) {
            if (!$widget->getController() instanceof Index) {
                return;
            }
            if (!$widget->model instanceof CmsCompoundObject) {
                return;
            }

            if (empty($widget->secondaryTabs['fields'])) {
                return;
            }

            if (array_key_exists('code', $widget->secondaryTabs['fields']) && CmsHelpers::safeModeEnabled()) {
                $widget->secondaryTabs['fields']['safemode_notice']['hidden'] = false;
                $widget->secondaryTabs['fields']['code']['readOnly'] = true;
            };
        });

        BackendMenu::setContext('Winter.Cms', 'cms', true);

        try {
            if (!($theme = Theme::getEditTheme())) {
                throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_found'));
            }

            $this->theme = $theme;

            new TemplateList($this, 'pageList', function () use ($theme) {
                return Page::listInTheme($theme, true);
            });

            new TemplateList($this, 'partialList', function () use ($theme) {
                return Partial::listInTheme($theme, true);
            });

            new TemplateList($this, 'layoutList', function () use ($theme) {
                return Layout::listInTheme($theme, true);
            });

            new TemplateList($this, 'contentList', function () use ($theme) {
                return Content::listInTheme($theme, true);
            });

            new ComponentList($this, 'componentList');

            new AssetList($this, 'assetList');
        }
        catch (Exception $ex) {
            $this->handleError($ex);
        }
    }

    //
    // Pages
    //

    /**
     * Index page action
     * @return void
     */
    public function index()
    {
        $this->addJs('/modules/cms/assets/js/winter.cmspage.js', 'core');
        $this->addJs('/modules/cms/assets/js/winter.dragcomponents.js', 'core');
        $this->addJs('/modules/cms/assets/js/winter.tokenexpander.js', 'core');
        $this->addCss('/modules/cms/assets/css/winter.components.css', 'core');

        // Preload the code editor class as it could be needed
        // before it loads dynamically.
        $this->addJs('/modules/backend/formwidgets/codeeditor/assets/js/build-min.js', 'core');

        $this->bodyClass = 'compact-container';
        $this->pageTitle = 'cms::lang.cms.menu_label';
        $this->pageTitleTemplate = '%s '.Lang::get($this->pageTitle);

        if (Request::ajax() && Request::input('formWidgetAlias')) {
            $this->bindFormWidgetToController();
        }
    }

    /**
     * Opens an existing template from the index page
     * @return array
     */
    public function index_onOpenTemplate()
    {
        $this->validateRequestTheme();

        $type = Request::input('type');
        $template = $this->loadTemplate($type, Request::input('path'));
        $widget = $this->makeTemplateFormWidget($type, $template);

        $this->vars['templatePath'] = Request::input('path');
        $this->vars['lastModified'] = DateTime::makeCarbon($template->mtime);
        $this->vars['canCommit'] = $this->canCommitTemplate($template);
        $this->vars['canReset'] = $this->canResetTemplate($template);

        if ($type === 'page') {
            $router = new StormRouter;
            $this->vars['pageUrl'] = $router->urlFromPattern($template->url);
        }

        return [
            'tabTitle' => $this->getTabTitle($type, $template),
            'tab'      => $this->makePartial('form_page', [
                'form'          => $widget,
                'templateType'  => $type,
                'templateTheme' => $this->theme->getDirName(),
                'templateMtime' => $template->mtime
            ])
        ];
    }

    /**
     * Saves the template currently open
     * @return array
     */
    public function onSave()
    {
        $this->validateRequestTheme();
        $type = Request::input('templateType');
        $templatePath = trim(Request::input('templatePath'));
        $template = $templatePath ? $this->loadTemplate($type, $templatePath) : $this->createTemplate($type);
        $formWidget = $this->makeTemplateFormWidget($type, $template);

        $saveData = $formWidget->getSaveData();
        $postData = post();
        $templateData = [];

        $settings = array_get($saveData, 'settings', []) + Request::input('settings', []);
        $settings = $this->upgradeSettings($settings, $template->settings);

        if ($settings) {
            $templateData['settings'] = $settings;
        }

        $fields = ['markup', 'code', 'fileName', 'content'];

        foreach ($fields as $field) {
            if (array_key_exists($field, $saveData)) {
                $templateData[$field] = $saveData[$field];
            }
            elseif (array_key_exists($field, $postData)) {
                $templateData[$field] = $postData[$field];
            }
        }

        if (!empty($templateData['markup']) && Config::get('cms.convertLineEndings', false) === true) {
            $templateData['markup'] = $this->convertLineEndings($templateData['markup']);
        }

        if (!empty($templateData['code']) && Config::get('cms.convertLineEndings', false) === true) {
            $templateData['code'] = $this->convertLineEndings($templateData['code']);
        }

        if (
            !Request::input('templateForceSave') && $template->mtime
            && Request::input('templateMtime') != $template->mtime
        ) {
            throw new ApplicationException('mtime-mismatch');
        }

        $template->attributes = [];
        $template->fill($templateData);
        $template->save();

        /**
         * @event cms.template.save
         * Fires after a CMS template (page|partial|layout|content|asset) has been saved.
         *
         * Example usage:
         *
         *     Event::listen('cms.template.save', function ((\Cms\Controllers\Index) $controller, (mixed) $templateObject, (string) $type) {
         *         \Log::info("A $type has been saved");
         *     });
         *
         * Or
         *
         *     $CmsIndexController->bindEvent('template.save', function ((mixed) $templateObject, (string) $type) {
         *         \Log::info("A $type has been saved");
         *     });
         *
         */
        $this->fireSystemEvent('cms.template.save', [$template, $type]);

        Flash::success(Lang::get('cms::lang.template.saved'));

        return $this->getUpdateResponse($template, $type);
    }

    /**
     * Displays a form that suggests the template has been edited elsewhere
     * @return string
     */
    public function onOpenConcurrencyResolveForm()
    {
        return $this->makePartial('concurrency_resolve_form');
    }

    /**
     * Create a new template
     * @return array
     */
    public function onCreateTemplate()
    {
        $type = Request::input('type');
        $template = $this->createTemplate($type);

        if ($type === 'asset') {
            $template->fileName = $this->widget->assetList->getCurrentRelativePath();
        }

        $widget = $this->makeTemplateFormWidget($type, $template);

        $this->vars['templatePath'] = '';
        $this->vars['canCommit'] = $this->canCommitTemplate($template);
        $this->vars['canReset'] = $this->canResetTemplate($template);

        return [
            'tabTitle' => $this->getTabTitle($type, $template),
            'tab'      => $this->makePartial('form_page', [
                'form'          => $widget,
                'templateType'  => $type,
                'templateTheme' => $this->theme->getDirName(),
                'templateMtime' => null
            ])
        ];
    }

    /**
     * Deletes multiple templates at the same time
     * @return array
     */
    public function onDeleteTemplates()
    {
        $this->validateRequestTheme();

        $type = Request::input('type');
        $templates = Request::input('template');
        $error = null;
        $deleted = [];

        try {
            foreach ($templates as $path => $selected) {
                if ($selected) {
                    $this->loadTemplate($type, $path)->delete();
                    $deleted[] = $path;
                }
            }
        }
        catch (Exception $ex) {
            $error = $ex->getMessage();
        }

        /**
         * @event cms.template.delete
         * Fires after a CMS template (page|partial|layout|content|asset) has been deleted.
         *
         * Example usage:
         *
         *     Event::listen('cms.template.delete', function ((\Cms\Controllers\Index) $controller, (string) $type) {
         *         \Log::info("A $type has been deleted");
         *     });
         *
         * Or
         *
         *     $CmsIndexController->bindEvent('template.delete', function ((string) $type) {
         *         \Log::info("A $type has been deleted");
         *     });
         *
         */
        $this->fireSystemEvent('cms.template.delete', [$type]);

        return [
            'deleted' => $deleted,
            'error'   => $error,
            'theme'   => Request::input('theme')
        ];
    }

    /**
     * Deletes a template
     * @return void
     */
    public function onDelete()
    {
        $this->validateRequestTheme();

        $type = Request::input('templateType');

        $this->loadTemplate($type, trim(Request::input('templatePath')))->delete();

        /*
         * Extensibility - documented above
         */
        $this->fireSystemEvent('cms.template.delete', [$type]);
    }

    /**
     * Returns list of available templates
     * @return array
     */
    public function onGetTemplateList()
    {
        $this->validateRequestTheme();

        $page = Page::inTheme($this->theme);
        return [
            'layouts' => $page->getLayoutOptions()
        ];
    }

    /**
     * Remembers an open or closed state for a supplied token, for example, component folders.
     * @return array
     */
    public function onExpandMarkupToken()
    {
        if (!$alias = post('tokenName')) {
            throw new ApplicationException(Lang::get('cms::lang.component.no_records'));
        }

        // Can only expand components at this stage
        if ((!$type = post('tokenType')) && $type !== 'component') {
            return;
        }

        if (!($names = (array) post('component_names')) || !($aliases = (array) post('component_aliases'))) {
            throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
        }

        if (($index = array_get(array_flip($aliases), $alias, false)) === false) {
            throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
        }

        if (!$componentName = array_get($names, $index)) {
            throw new ApplicationException(Lang::get('cms::lang.component.not_found', ['name' => $alias]));
        }

        $manager = ComponentManager::instance();
        $componentObj = $manager->makeComponent($componentName);
        $partial = ComponentPartial::load($componentObj, 'default');

        if (!$partial) {
            throw new ApplicationException(Lang::get('cms::lang.component.no_default_partial'));
        }

        $content = $partial->getContent();
        $content = str_replace('__SELF__', $alias, $content);

        return $content;
    }

    /**
     * Commits the DB changes of a template to the filesystem
     *
     * @return array $response
     */
    public function onCommit()
    {
        $this->validateRequestTheme();
        $type = Request::input('templateType');
        $template = $this->loadTemplate($type, trim(Request::input('templatePath')));

        if ($this->canCommitTemplate($template)) {
            // Populate the filesystem with the template and then remove it from the db
            $datasource = $this->getThemeDatasource();
            $datasource->pushToSource($template, 'filesystem');
            $datasource->removeFromSource($template, 'database');

            Flash::success(Lang::get('cms::lang.editor.commit_success', ['type' => $type]));
        }

        return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]);
    }

    /**
     * Resets a template to the version on the filesystem
     *
     * @return array $response
     */
    public function onReset()
    {
        $this->validateRequestTheme();
        $type = Request::input('templateType');
        $template = $this->loadTemplate($type, trim(Request::input('templatePath')));

        if ($this->canResetTemplate($template)) {
            // Remove the template from the DB
            $datasource = $this->getThemeDatasource();
            $datasource->removeFromSource($template, 'database');

            Flash::success(Lang::get('cms::lang.editor.reset_success', ['type' => $type]));
        }

        return array_merge($this->getUpdateResponse($template, $type), ['forceReload' => true]);
    }

    //
    // Methods for internal use
    //

    /**
     * Get the response to return in an AJAX request that updates a template
     *
     * @param object $template The template that has been affected
     * @param string $type The type of template being affected
     * @return array $result;
     */
    protected function getUpdateResponse($template, string $type)
    {
        $result = [
            'templatePath'  => $template->fileName,
            'templateMtime' => $template->mtime,
            'tabTitle'      => $this->getTabTitle($type, $template)
        ];

        if ($type === 'page') {
            $result['pageUrl'] = Url::to($template->url);
            $router = new Router($this->theme);
            $router->clearCache();
            CmsCompoundObject::clearCache($this->theme);
        }

        $result['canCommit'] = $this->canCommitTemplate($template);
        $result['canReset'] = $this->canResetTemplate($template);

        return $result;
    }

    /**
     * Get the active theme's datasource
     *
     * @return \Winter\Storm\Halcyon\Datasource\DatasourceInterface
     */
    protected function getThemeDatasource()
    {
        return $this->theme->getDatasource();
    }

    /**
     * Check to see if the provided template can be committed
     * Only available in debug mode, the DB layer must be enabled, and the template must exist in the database
     *
     * @param CmsObject $template
     * @return boolean
     */
    protected function canCommitTemplate($template)
    {
        if ($template instanceof Cms\Contracts\CmsObject === false) {
            return false;
        }

        $result = false;

        if (Config::get('app.debug', false) &&
            Theme::databaseLayerEnabled() &&
            $this->getThemeDatasource()->sourceHasModel('database', $template)
        ) {
            $result = true;
        }

        return $result;
    }

    /**
     * Check to see if the provided template can be reset
     * Only available when the DB layer is enabled and the template exists in both the DB & Filesystem
     *
     * @param CmsObject $template
     * @return boolean
     */
    protected function canResetTemplate($template)
    {
        if ($template instanceof Cms\Contracts\CmsObject === false) {
            return false;
        }

        $result = false;

        if (Theme::databaseLayerEnabled()) {
            $datasource = $this->getThemeDatasource();
            $result = $datasource->sourceHasModel('database', $template) && $datasource->sourceHasModel('filesystem', $template);
        }

        return $result;
    }

    /**
     * Validate that the current request is within the active theme
     * @return void
     */
    protected function validateRequestTheme()
    {
        if ($this->theme->getDirName() != Request::input('theme')) {
            throw new ApplicationException(Lang::get('cms::lang.theme.edit.not_match'));
        }
    }

    /**
     * Resolves a template type to its class name
     * @param string $type
     * @return string
     */
    protected function resolveTypeClassName($type)
    {
        $types = [
            'page'    => Page::class,
            'partial' => Partial::class,
            'layout'  => Layout::class,
            'content' => Content::class,
            'asset'   => Asset::class
        ];

        if (!array_key_exists($type, $types)) {
            throw new ApplicationException(Lang::get('cms::lang.template.invalid_type'));
        }

        return $types[$type];
    }

    /**
     * Returns an existing template of a given type
     * @param string $type
     * @param string $path
     * @return mixed
     */
    protected function loadTemplate($type, $path)
    {
        $class = $this->resolveTypeClassName($type);

        if (!($template = call_user_func([$class, 'load'], $this->theme, $path))) {
            throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
        }

        /**
         * @event cms.template.processSettingsAfterLoad
         * Fires immediately after a CMS template (page|partial|layout|content|asset) has been loaded and provides an opportunity to interact with it.
         *
         * Example usage:
         *
         *     Event::listen('cms.template.processSettingsAfterLoad', function ((\Cms\Controllers\Index) $controller, (mixed) $templateObject) {
         *         // Make some modifications to the $template object
         *     });
         *
         * Or
         *
         *     $CmsIndexController->bindEvent('template.processSettingsAfterLoad', function ((mixed) $templateObject) {
         *         // Make some modifications to the $template object
         *     });
         *
         */
        $this->fireSystemEvent('cms.template.processSettingsAfterLoad', [$template]);

        return $template;
    }

    /**
     * Creates a new template of a given type
     * @param string $type
     * @return mixed
     */
    protected function createTemplate($type)
    {
        $class = $this->resolveTypeClassName($type);

        if (!($template = $class::inTheme($this->theme))) {
            throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
        }

        return $template;
    }

    /**
     * Returns the text for a template tab
     * @param string $type
     * @param string $template
     * @return string
     */
    protected function getTabTitle($type, $template)
    {
        if ($type === 'page') {
            $result = $template->title ?: $template->getFileName();
            if (!$result) {
                $result = Lang::get('cms::lang.page.new');
            }

            return $result;
        }

        if ($type === 'partial' || $type === 'layout' || $type === 'content' || $type === 'asset') {
            $result = in_array($type, ['asset', 'content']) ? $template->getFileName() : $template->getBaseFileName();
            if (!$result) {
                $result = Lang::get('cms::lang.'.$type.'.new');
            }

            return $result;
        }

        return $template->getFileName();
    }

    /**
     * Returns a form widget for a specified template type.
     * @param string $type
     * @param string $template
     * @param string $alias
     * @return Backend\Widgets\Form
     */
    protected function makeTemplateFormWidget($type, $template, $alias = null)
    {
        $formConfigs = [
            'page'    => '~/modules/cms/classes/page/fields.yaml',
            'partial' => '~/modules/cms/classes/partial/fields.yaml',
            'layout'  => '~/modules/cms/classes/layout/fields.yaml',
            'content' => '~/modules/cms/classes/content/fields.yaml',
            'asset'   => '~/modules/cms/classes/asset/fields.yaml'
        ];

        if (!array_key_exists($type, $formConfigs)) {
            throw new ApplicationException(Lang::get('cms::lang.template.not_found'));
        }

        $widgetConfig = $this->makeConfig($formConfigs[$type]);

        $ext = pathinfo($template->fileName, PATHINFO_EXTENSION);
        if ($type === 'content') {
            switch ($ext) {
                case 'htm':
                    $type = 'richeditor';
                    break;
                case 'md':
                    $type = 'markdown';
                    break;
                default:
                    $type = 'codeeditor';
                    break;
            }
            array_set($widgetConfig->secondaryTabs, 'fields.markup.type', $type);
        }

        $lang = 'php';
        if (array_get($widgetConfig->secondaryTabs, 'fields.markup.type') === 'codeeditor') {
            switch ($ext) {
                case 'htm':
                    $lang = 'twig';
                    break;
                case 'html':
                    $lang = 'html';
                    break;
                case 'css':
                    $lang = 'css';
                    break;
                case 'js':
                case 'json':
                    $lang = 'javascript';
                    break;
            }
        }

        $widgetConfig->model = $template;
        $widgetConfig->alias = $alias ?: 'form'.studly_case($type).md5($template->exists ? $template->getFileName() : uniqid());

        return $this->makeWidget('Backend\Widgets\Form', $widgetConfig);
    }

    /**
     * Processes the component settings so they are ready to be saved.
     * @param array $settings The new settings for this template.
     * @param array $prevSettings The previous settings for this template.
     * @return array
     */
    protected function upgradeSettings($settings, $prevSettings)
    {
        /*
         * Handle component usage
         */
        $componentProperties = post('component_properties');
        $componentNames = post('component_names');
        $componentAliases = post('component_aliases');

        if ($componentProperties !== null) {
            if ($componentNames === null || $componentAliases === null) {
                throw new ApplicationException(Lang::get('cms::lang.component.invalid_request'));
            }

            $count = count($componentProperties);
            if (count($componentNames) != $count || count($componentAliases) != $count) {
                throw new ApplicationException(Lang::get('cms::lang.component.invalid_request'));
            }

            for ($index = 0; $index < $count; $index++) {
                $componentName = $componentNames[$index];
                $componentAlias = $componentAliases[$index];

                $isSoftComponent = (substr($componentAlias, 0, 1) === '@');
                $componentName = ltrim($componentName, '@');
                $componentAlias = ltrim($componentAlias, '@');

                if ($componentAlias !== $componentName) {
                    $section = $componentName . ' ' . $componentAlias;
                } else {
                    $section = $componentName;
                }
                if ($isSoftComponent) {
                    $section = '@' . $section;
                }

                $properties = json_decode($componentProperties[$index], true);
                unset($properties['oc.alias'], $properties['inspectorProperty'], $properties['inspectorClassName']);

                if (!$properties) {
                    $oldComponentSettings = array_key_exists($section, $prevSettings['components'])
                        ? $prevSettings['components'][$section]
                        : null;
                    if ($isSoftComponent && $oldComponentSettings) {
                        $settings[$section] = $oldComponentSettings;
                    } else {
                        $settings[$section] = $properties;
                    }
                } else {
                    $settings[$section] = $properties;
                }
            }
        }

        /*
         * Handle view bag
         */
        $viewBag = post('viewBag');
        if ($viewBag !== null) {
            $settings['viewBag'] = $viewBag;
        }

        /**
         * @event cms.template.processSettingsBeforeSave
         * Fires before a CMS template (page|partial|layout|content|asset) is saved and provides an opportunity to interact with the settings data. `$dataHolder` = {settings: []}
         *
         * Example usage:
         *
         *     Event::listen('cms.template.processSettingsBeforeSave', function ((\Cms\Controllers\Index) $controller, (object) $dataHolder) {
         *         // Make some modifications to the $dataHolder object
         *     });
         *
         * Or
         *
         *     $CmsIndexController->bindEvent('template.processSettingsBeforeSave', function ((object) $dataHolder) {
         *         // Make some modifications to the $dataHolder object
         *     });
         *
         */
        $dataHolder = (object) ['settings' => $settings];
        $this->fireSystemEvent('cms.template.processSettingsBeforeSave', [$dataHolder]);

        return $dataHolder->settings;
    }

    /**
     * Finds a given component by its alias.
     *
     * If found, this will return the component's name, alias and properties.
     *
     * @param string $aliasQuery The alias to search for
     * @param array $components The array of components to look within.
     * @return array|null
     */
    protected function findComponentByAlias(string $aliasQuery, array $components = [])
    {
        $found = null;

        foreach ($components as $name => $properties) {
            list($name, $alias) = strpos($name, ' ') ? explode(' ', $name) : [$name, $name];

            if (ltrim($alias, '@') === ltrim($aliasQuery, '@')) {
                $found = [
                    'name' => ltrim($name, '@'),
                    'alias' => $alias,
                    'properties' => $properties
                ];
                break;
            }
        }

        return $found;
    }

    /**
     * Binds the active form widget to the controller
     * @return void
     */
    protected function bindFormWidgetToController()
    {
        $alias = Request::input('formWidgetAlias');
        $type = Request::input('templateType');
        $object = $this->loadTemplate($type, Request::input('templatePath'));
        $widget = $this->makeTemplateFormWidget($type, $object, $alias);

        $widget->bindToController();
    }

    /**
     * Replaces Windows style (/r/n) line endings with unix style (/n)
     * line endings.
     * @param string $markup The markup to convert to unix style endings
     * @return string
     */
    protected function convertLineEndings($markup)
    {
        $markup = str_replace(["\r\n", "\r"], "\n", $markup);

        return $markup;
    }
}
