<?php

/**
 * @package    Grav\Framework\Flex
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Framework\Flex\Pages\Traits;

use Exception;
use Grav\Common\Config\Config;
use Grav\Common\Grav;
use Grav\Common\Markdown\Parsedown;
use Grav\Common\Markdown\ParsedownExtra;
use Grav\Common\Media\MediaRouteUrls;
use Grav\Common\Page\Header;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Markdown\Excerpts;
use Grav\Common\Page\Media;
use Grav\Common\Security;
use Grav\Common\Twig\Twig;
use Grav\Common\Utils;
use Grav\Framework\File\Formatter\YamlFormatter;
use RocketTheme\Toolbox\Event\Event;
use stdClass;
use function in_array;
use function is_array;
use function is_string;

/**
 * Implements PageContentInterface.
 */
trait PageContentTrait
{
    /** @var array */
    protected static $headerProperties = [
        'slug'              => 'slug',
        'routes'            => false,
        'title'             => 'title',
        'language'          => 'language',
        'template'          => 'template',
        'menu'              => 'menu',
        'routable'          => 'routable',
        'visible'           => 'visible',
        'redirect'          => 'redirect',
        'external_url'      => false,
        'order_dir'         => 'orderDir',
        'order_by'          => 'orderBy',
        'order_manual'      => 'orderManual',
        'dateformat'        => 'dateformat',
        'date'              => 'date',
        'markdown_extra'    => false,
        'taxonomy'          => 'taxonomy',
        'max_count'         => 'maxCount',
        'process'           => 'process',
        'published'         => 'published',
        'publish_date'      => 'publishDate',
        'unpublish_date'    => 'unpublishDate',
        'expires'           => 'expires',
        'cache_control'     => 'cacheControl',
        'etag'              => 'eTag',
        'last_modified'     => 'lastModified',
        'ssl'               => 'ssl',
        'template_format'   => 'templateFormat',
        'debugger'          => false,
    ];

    /** @var array */
    protected static $calculatedProperties = [
        'name' => 'name',
        'parent' => 'parent',
        'parent_key' => 'parentStorageKey',
        'folder' => 'folder',
        'order' => 'order',
        'template' => 'template',
    ];

    /** @var object|null */
    protected $header;

    /** @var string|null */
    protected $_summary;

    /** @var string|null */
    protected $_content;

    /**
     * Method to normalize the route.
     *
     * @param string $route
     * @return string
     * @internal
     */
    public static function normalizeRoute($route): string
    {
        $case_insensitive = Grav::instance()['config']->get('system.force_lowercase_urls');

        return $case_insensitive ? mb_strtolower($route) : $route;
    }

    /**
     * @inheritdoc
     * @return Header
     */
    public function header($var = null)
    {
        if (null !== $var) {
            $this->setProperty('header', $var);
        }

        return $this->getProperty('header');
    }

    /**
     * @inheritdoc
     */
    public function summary($size = null, $textOnly = false): string
    {
        return $this->processSummary($size, $textOnly);
    }

    /**
     * @inheritdoc
     */
    public function setSummary($summary): void
    {
        $this->_summary = $summary;
    }

    /**
     * @inheritdoc
     * @throws Exception
     */
    public function content($var = null): string
    {
        if (null !== $var) {
            $this->_content = $var;
        }

        return $this->_content ?? $this->processContent($this->getRawContent());
    }

    /**
     * @inheritdoc
     */
    public function getRawContent(): string
    {
        return $this->_content ?? $this->getArrayProperty('markdown') ?? '';
    }

    /**
     * @inheritdoc
     */
    public function setRawContent($content): void
    {
        $this->_content = $content ?? '';
    }

    /**
     * @inheritdoc
     */
    public function rawMarkdown($var = null): string
    {
        if ($var !== null) {
            $this->setProperty('markdown', $var);
        }

        return $this->getProperty('markdown') ?? '';
    }

    /**
     * @inheritdoc
     *
     * Implement by calling:
     *
     * $test = new \stdClass();
     * $value = $this->pageContentValue($name, $test);
     * if ($value !== $test) {
     *     return $value;
     * }
     * return parent::value($name, $default);
     */
    abstract public function value($name, $default = null, $separator = null);

    /**
     * @inheritdoc
     */
    public function media($var = null): Media
    {
        if ($var instanceof Media) {
            $this->setProperty('media', $var);
        }

        $media = $this->getProperty('media');

        // Applied here rather than where the collection is built because the
        // route depends on the active language and base route. See Page::media().
        if ($this instanceof PageInterface) {
            MediaRouteUrls::apply($this, $media);
        }

        return $media;
    }

    /**
     * @inheritdoc
     */
    public function title($var = null): string
    {
        return $this->loadHeaderProperty(
            'title',
            $var,
            fn($value) => trim((string) ($value ?? ($this->root() ? '<root>' : ucfirst($this->slug()))))
        );
    }

    /**
     * @inheritdoc
     */
    public function menu($var = null): string
    {
        return $this->loadHeaderProperty(
            'menu',
            $var,
            fn($value) => trim((string) ($value ?: $this->title()))
        );
    }

    /**
     * @inheritdoc
     */
    public function visible($var = null): bool
    {
        $value = $this->loadHeaderProperty(
            'visible',
            $var,
            // An empty string (e.g. an untouched toggleable `visible` field
            // saved by admin) is not a meaningful boolean — coerce it to null
            // so the folder-order auto-detection still runs instead of forcing
            // the page out of navigation. See getgrav/grav#4153.
            fn($value) => ((($value ?? '') === '' ? null : $value) ?? $this->order() !== false) && !$this->isModule()
        );

        return $value && $this->published();
    }

    /**
     * @inheritdoc
     */
    public function published($var = null): bool
    {
        // Only an explicit value is kept: without one, the publishing dates decide at the time of asking.
        $published = $this->loadHeaderProperty(
            'published',
            $var,
            static fn($value) => null !== $value ? (bool)$value : null
        );

        return $published ?? $this->isPublishedByDates();
    }

    /**
     * Handle publishing dates when there is no explicit published option, like Page::setPublishState() does.
     * Routing, menus, collections and taxonomy all ask published(), so the dates are applied there. The cache
     * lifetime is shortened to the next date, so cached data does not outlive a publish or unpublish.
     *
     * @return bool
     */
    protected function isPublishedByDates(): bool
    {
        $grav = Grav::instance();
        if (!$grav['config']->get('system.pages.publish_dates')) {
            return true;
        }

        $time = time();
        $start = $this->publishDate();
        if ($start && $start > $time) {
            $grav['cache']->setLifetime($start);

            return false;
        }

        $stop = $this->unpublishDate();
        if ($stop) {
            if ($stop < $time) {
                return false;
            }

            $grav['cache']->setLifetime($stop);
        }

        return true;
    }

    /**
     * @inheritdoc
     */
    public function publishDate($var = null): ?int
    {
        return $this->loadHeaderProperty(
            'publish_date',
            $var,
            fn($value) => $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null
        );
    }

    /**
     * @inheritdoc
     */
    public function unpublishDate($var = null): ?int
    {
        return $this->loadHeaderProperty(
            'unpublish_date',
            $var,
            fn($value) => $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : null
        );
    }

    /**
     * @inheritdoc
     */
    public function process($var = null): array
    {
        return $this->loadHeaderProperty(
            'process',
            $var,
            function ($value) {
                $defaults = Security::applyProcessDefaults(
                    (array) Grav::instance()['config']->get('system.pages.process', [])
                );
                $value = array_replace($defaults, is_array($value) ? $value : []);
                foreach ($value as $process => $status) {
                    $value[$process] = (bool)$status;
                }

                return $value;
            }
        );
    }

    /**
     * @inheritdoc
     */
    public function slug($var = null)
    {
        return $this->loadHeaderProperty(
            'slug',
            $var,
            function ($value) {
                if (is_string($value)) {
                    return $value;
                }

                $folder = $this->folder();
                if (null === $folder) {
                    return null;
                }

                $folder = preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', $folder);
                if (null === $folder) {
                    return null;
                }

                return static::normalizeRoute($folder);
            }
        );
    }

    /**
     * @inheritdoc
     */
    public function order($var = null)
    {
        $property = $this->loadProperty(
            'order',
            $var,
            function ($value) {
                if (null === $value) {
                    $folder = $this->folder();
                    if (null !== $folder) {
                        preg_match(static::PAGE_ORDER_REGEX, $folder, $order);
                    }

                    $value = $order[1] ?? false;
                }

                if ($value === '') {
                    $value = false;
                }
                if ($value !== false) {
                    $value = (int)$value;
                }

                return $value;
            }
        );

        if ($property === false) {
            return false;
        }

        // Preserve the original prefix width from the current folder so saves
        // do not normalize "005." → "05." across a load/edit/save round trip.
        $digits = \Grav\Common\Page\PageOrdering::digitsFromFolder($this->folder());

        return \Grav\Common\Page\PageOrdering::prefix($property, $digits);
    }

    /**
     * @inheritdoc
     */
    public function id($var = null): string
    {
        $property = 'id';
        $value = null === $var ? $this->getProperty($property) : null;
        if (null === $value) {
            $value = $this->language() . ($var ?? ($this->modified() . md5('flex-' . $this->getFlexType() . '-' . $this->getKey())));

            $this->setProperty($property, $value);
            if ($this->doHasProperty($property)) {
                $value = $this->getProperty($property);
            }
        }

        return $value;
    }

    /**
     * @inheritdoc
     */
    public function modified($var = null): int
    {
        $property = 'modified';
        $value = null === $var ? $this->getProperty($property) : null;
        if (null === $value) {
            $value = (int)($var ?: $this->getTimestamp());

            $this->setProperty($property, $value);
            if ($this->doHasProperty($property)) {
                $value = $this->getProperty($property);
            }
        }

        return $value;
    }

    /**
     * @inheritdoc
     */
    public function lastModified($var = null): bool
    {
        return $this->loadHeaderProperty(
            'last_modified',
            $var,
            static fn($value) => (bool)($value ?? Grav::instance()['config']->get('system.pages.last_modified'))
        );
    }

    /**
     * @inheritdoc
     */
    public function date($var = null): int
    {
        return $this->loadHeaderProperty(
            'date',
            $var,
            function ($value) {
                $value = $value ? Utils::date2timestamp($value, $this->getProperty('dateformat')) : false;

                return $value ?: $this->modified();
            }
        );
    }

    /**
     * @inheritdoc
     */
    public function dateformat($var = null): ?string
    {
        return $this->loadHeaderProperty(
            'dateformat',
            $var,
            static fn($value) => $value
        );
    }

    /**
     * @inheritdoc
     */
    public function taxonomy($var = null): array
    {
        return $this->loadHeaderProperty(
            'taxonomy',
            $var,
            static function ($value) {
                if (is_array($value)) {
                    // make sure first level are arrays
                    array_walk($value, static function (&$val) {
                        $val = (array) $val;
                    });
                    // make sure all values are strings
                    array_walk_recursive($value, static function (&$val) {
                        $val = (string) $val;
                    });
                }

                return $value ?? [];
            }
        );
    }

    /**
     * @inheritdoc
     */
    public function shouldProcess($process): bool
    {
        $test = $this->process();

        return !empty($test[$process]);
    }

    /**
     * @inheritdoc
     */
    public function isPage(): bool
    {
        return !in_array($this->template(), ['', 'folder'], true);
    }

    /**
     * @inheritdoc
     */
    public function isDir(): bool
    {
        return !$this->isPage();
    }

    /**
     * @return bool
     */
    public function isModule(): bool
    {
        return $this->modularTwig();
    }

    /**
     * @param Header|stdClass|array|null $value
     * @return Header
     */
    protected function offsetLoad_header($value)
    {
        if ($value instanceof Header) {
            return $value;
        }

        if (null === $value) {
            $value = [];
        } elseif ($value instanceof stdClass) {
            $value = (array)$value;
        }

        return new Header($value);
    }

    /**
     * @param Header|stdClass|array|null $value
     * @return Header
     */
    protected function offsetPrepare_header($value)
    {
        // A header set at runtime holds plain data only, as one loaded from storage does.
        return $this->offsetLoad_header(Header::plainData($value));
    }

    /**
     * @param Header|null $value
     * @return array
     */
    protected function offsetSerialize_header(?Header $value)
    {
        return $value ? $value->toArray() : [];
    }

    /**
     * @param string $name
     * @param mixed|null $default
     * @return mixed
     */
    protected function pageContentValue($name, $default = null)
    {
        switch ($name) {
            case 'frontmatter':
                $frontmatter = $this->getArrayProperty('frontmatter');
                if ($frontmatter === null) {
                    $header = $this->prepareStorage()['header'] ?? null;
                    if ($header) {
                        $formatter = new YamlFormatter();
                        $frontmatter = $formatter->encode($header);
                    } else {
                        $frontmatter = '';
                    }
                }
                return $frontmatter;
            case 'content':
                return $this->getProperty('markdown');
            case 'order':
                return (string)$this->order();
            case 'menu':
                return $this->menu();
            case 'ordering':
                return $this->order() !== false ? '1' : '0';
            case 'folder':
                $folder = $this->folder();

                return null !== $folder ? preg_replace(static::PAGE_ORDER_PREFIX_REGEX, '', (string) $folder) : '';
            case 'slug':
                return $this->slug();
            case 'published':
                return $this->published();
            case 'visible':
                return $this->visible();
            case 'media':
                return $this->media()->all();
            case 'media.file':
                return $this->media()->files();
            case 'media.video':
                return $this->media()->videos();
            case 'media.image':
                return $this->media()->images();
            case 'media.audio':
                return $this->media()->audios();
        }

        return $default;
    }

    /**
     * @param int|null $size
     * @param bool $textOnly
     * @return string
     */
    protected function processSummary($size = null, $textOnly = false): string
    {
        $config = (array)Grav::instance()['config']->get('site.summary');
        $config_page = (array)$this->getNestedProperty('header.summary');
        if ($config_page) {
            $config = array_merge($config, $config_page);
        }

        // Summary is not enabled, return the whole content.
        if (empty($config['enabled'])) {
            return $this->content();
        }

        $content = $this->_summary ?? $this->content();
        if ($textOnly) {
            $content =  strip_tags((string) $content);
        }
        $content_size = mb_strwidth((string) $content, 'utf-8');
        $summary_size = $this->_summary !== null ? $content_size : $this->getProperty('summary_size');

        // Return calculated summary based on summary divider's position.
        $format = $config['format'] ?? '';

        // Return entire page content on wrong/unknown format.
        if ($format !== 'long' && $format !== 'short') {
            return $content;
        }

        if ($format === 'short' && null !== $summary_size) {
            // Slice the string on breakpoint.
            if ($content_size > $summary_size) {
                return mb_substr((string) $content, 0, $summary_size);
            }

            return $content;
        }

        // If needed, get summary size from the config.
        $size ??= $config['size'] ?? null;

        // Return calculated summary based on defaults.
        $size = is_numeric($size) ? (int)$size : -1;
        if ($size < 0) {
            $size = 300;
        }

        // If the size is zero or smaller than the summary limit, return the entire page content.
        if ($size === 0 || $content_size <= $size) {
            return $content;
        }

        // Only return string but not html, wrap whatever html tag you want when using.
        if ($textOnly) {
            return mb_strimwidth((string) $content, 0, $size, '...', 'UTF-8');
        }

        $summary = Utils::truncateHTML($content, $size);

        // Only undo the entities the truncator leaves for non-ASCII characters; decoding &lt; and friends as well
        // would turn text that the content escaped back into markup.
        return Utils::decodeNonAsciiEntities($summary);
    }

    /**
     * Gets and Sets the content based on content portion of the .md file
     *
     * @param  string $content
     * @return string
     * @throws Exception
     */
    protected function processContent($content): string
    {
        $content = is_string($content) ? $content : '';
        $grav = Grav::instance();

        /** @var Config $config */
        $config = $grav['config'];

        $process_markdown = $this->shouldProcess('markdown');

        // security.twig_content.process_enabled gates editor-authored Twig
        // in page content. isModule() is theme-controlled (modular templates
        // render their own children with Twig) and stays unconditionally
        // enabled. See system/config/security.yaml.
        $content_twig_requested = $this->shouldProcess('twig');
        $content_twig_allowed = (bool) $config->get('security.twig_content.process_enabled', false);
        // Only log when the gate actually stops a render; modular pages
        // bypass the gate so they're not blocked and shouldn't appear in
        // the gate-blocked audit log.
        if ($content_twig_requested && !$content_twig_allowed && !$this->isModule()) {
            Security::logTwigContentGateBlocked((string) ($this->route() ?? $this->filePath() ?? 'unknown'), 'content');
        }
        // Editor-authored content Twig is gated by process_enabled; trusted
        // modular/theme Twig renders unconditionally. XSS in assembled content
        // Twig is caught at save time (Security::detectXssInEditorContent), not
        // here. Shares the boolean with that guard so the two cannot drift
        // apart. Mirrors Page::content(). (GHSA-2c4f-86xc-cr74, GHSA-fg8g-663r-f366)
        $process_twig = Security::willProcessContentTwig($this);

        $cache_enable = $this->getNestedProperty('header.cache_enable') ?? $config->get('system.cache.enabled', true);

        $twig_first = $this->getNestedProperty('header.twig_first') ?? $config->get('system.pages.twig_first', false);
        $never_cache_twig = $this->getNestedProperty('header.never_cache_twig') ?? $config->get('system.pages.never_cache_twig', false);

        // Editor-authored content Twig is request-aware even inside the sandbox, and
        // the render cache below is keyed on the page key and the config checksum
        // only, with no session or request dimension. Cache the markdown, re-run the
        // Twig every request, including editor-authored module bodies. Mirrors
        // Page::content(). (GHSA-pp89-h475-7gj6)
        if ($process_twig) {
            $never_cache_twig = true;
        }

        // Twig-first content that is never cached has no Twig-free stage to read
        // back either: the Twig output is what Markdown parses.
        $twig_first_uncached = $twig_first && $process_twig && $never_cache_twig;

        if ($cache_enable && !$twig_first_uncached) {
            $cache = $this->getCache('render');
            // Mix the full config checksum into the cache id so any change
            // to system, site, security, or plugin config (including the
            // security.twig_content gates) evicts previously rendered output.
            // Matches the invalidation strategy classic Page uses in
            // getPageContentCacheKey().
            $key = md5($this->getCacheKey() . '-content:cfg=' . (string) $config->checksum());
            $cached = $cache->get($key);
            if ($cached && $cached['checksum'] === $this->getCacheChecksum()) {
                $this->_content = $cached['content'] ?? '';
                $this->_content_meta = $cached['content_meta'] ?? null;

                if ($process_twig && $never_cache_twig) {
                    $this->_content = $this->processTwig($this->_content);
                }
            }
        }

        if (null === $this->_content) {
            $markdown_options = [];
            if ($process_markdown) {
                // Build markdown options.
                $markdown_options = (array)$config->get('system.pages.markdown');
                $markdown_page_options = (array)$this->getNestedProperty('header.markdown');
                if ($markdown_page_options) {
                    $markdown_options = array_merge($markdown_options, $markdown_page_options);
                }

                // pages.markdown_extra is deprecated, but still check it...
                if (!isset($markdown_options['extra'])) {
                    $extra = $this->getNestedProperty('header.markdown_extra') ?? $config->get('system.pages.markdown_extra');
                    if (null !== $extra) {
                        user_error('Configuration option \'system.pages.markdown_extra\' is deprecated since Grav 1.5, use \'system.pages.markdown.extra\' instead', E_USER_DEPRECATED);

                        $markdown_options['extra'] = $extra;
                    }
                }
            }
            $options = [
                'markdown' => $markdown_options,
                'images' => $config->get('system.images', [])
            ];

            $this->_content = $content;
            $grav->fireEvent('onPageContentRaw', new Event(['page' => $this]));

            if ($twig_first) {
                if ($process_twig) {
                    $this->_content = $this->processTwig($this->_content);
                }

                if ($process_markdown) {
                    $this->_content = $this->processMarkdown($this->_content, $options);
                }

                // Content Processed but not cached yet
                $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this]));
            } else {
                if ($process_markdown) {
                    $options['keep_twig'] = $process_twig;
                    $this->_content = $this->processMarkdown($this->_content, $options);
                }

                // Content Processed but not cached yet
                $grav->fireEvent('onPageContentProcessed', new Event(['page' => $this]));

                if ($cache_enable && $never_cache_twig) {
                    $this->cachePageContent();
                }

                if ($process_twig) {
                    \assert(is_string($this->_content));
                    $this->_content = $this->processTwig($this->_content);
                }
            }

            if ($cache_enable && !$never_cache_twig) {
                $this->cachePageContent();
            }
        }

        \assert(is_string($this->_content));

        // Handle summary divider, only where it stands as a block of its own between tags:
        // cut out of the middle of raw HTML, what is left would join into new markup.
        // Mirrors Page::content().
        $delimiter = $config->get('site.summary.delimiter', '===');
        $divider = '~(?:\G|>)\s*\K' . preg_quote("<p>{$delimiter}</p>", '~') . '(?=\s*(?:<|$))~';
        $parts = preg_split($divider, $this->_content);
        if ($parts && count($parts) > 1) {
            $this->setProperty('summary_size', mb_strlen($parts[0]));
            $this->_content = implode('', $parts);
        }

        // Fire event when Page::content() is called
        $grav->fireEvent('onPageContent', new Event(['page' => $this]));

        return $this->_content;
    }

    /**
     * Process the Twig page content.
     *
     * @param  string $content
     * @return string
     */
    protected function processTwig($content): string
    {
        /** @var Twig $twig */
        $twig = Grav::instance()['twig'];

        /** @var PageInterface $this */
        return $twig->processPage($this, $content);
    }

    /**
     * Process the Markdown content.
     *
     * Uses Parsedown or Parsedown Extra depending on configuration.
     *
     * @param string $content
     * @param array  $options
     * @return string
     * @throws Exception
     */
    protected function processMarkdown($content, array $options = []): string
    {
        /** @var PageInterface $self */
        $self = $this;

        $excerpts = new Excerpts($self, $options);

        // Initialize the preferred variant of markdown parser.
        if (isset($options['extra'])) {
            $parsedown = new ParsedownExtra($excerpts);
        } else {
            $parsedown = new Parsedown($excerpts);
        }

        $keepTwig = (bool)($options['keep_twig'] ?? false);
        if ($keepTwig) {
            $token = [
                '/' . Utils::generateRandomString(3),
                Utils::generateRandomString(3) . '/'
            ];
            // Base64 encode any twig.
            $content = preg_replace_callback(
                ['/({#.*?#})/mu', '/({{.*?}})/mu', '/({%.*?%})/mu'],
                static fn($matches) => $token[0] . base64_encode((string) $matches[1]) . $token[1],
                $content
            );
        }

        $content = $parsedown->text($content);

        if ($keepTwig) {
            // Base64 decode the encoded twig.
            $content = preg_replace_callback(
                ['`' . $token[0] . '([A-Za-z0-9+/]+={0,2})' . $token[1] . '`mu'],
                static fn($matches) => base64_decode((string) $matches[1]),
                (string) $content
            );
        }

        return $content;
    }

    abstract protected function loadHeaderProperty(string $property, $var, callable $filter);
}
