<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Html;

use Psr\Log\LoggerInterface;

readonly class HtmlCropper
{
    protected const TAGS = 'a|abbr|address|area|article|aside|audio|b|bdi|bdo|blockquote|body|br|button|caption|cite|code|col|colgroup|data|datalist|dd|del|dfn|div|dl|dt|em|embed|fieldset|figcaption|figure|font|footer|form|h1|h2|h3|h4|h5|h6|header|hr|i|iframe|img|input|ins|kbd|keygen|label|legend|li|link|main|map|mark|meter|nav|object|ol|optgroup|option|output|p|param|pre|progress|q|rb|rp|rt|rtc|ruby|s|samp|section|select|small|source|span|strong|sub|sup|table|tbody|td|textarea|tfoot|th|thead|time|tr|track|u|ul|ut|var|video|wbr';

    protected const TAGS_REG_EXP = '
        (
            (?:
                <!--.*?-->					# a comment
                |
                <canvas[^>]*>.*?</canvas>   # a canvas tag
                |
                <script[^>]*>.*?</script>   # a script tag
                |
                <noscript[^>]*>.*?</noscript> # a noscript tag
                |
                <template[^>]*>.*?</template> # a template tag
            )
            |
            </?(?:%s)+			# opening tag (\'<tag\') or closing tag (\'</tag\')
            (?:
                (?:
                    (?:
                        \\s+\\w[\\w-]*		# EITHER spaces, followed by attribute names
                        (?:
                            \\s*=?\\s*		# equals
                            (?>
                                ".*?"		# attribute values in double-quotes
                                |
                                \'.*?\'		# attribute values in single-quotes
                                |
                                [^\'">\\s]+	# plain attribute values
                            )
                        )?
                    )
                    |						# OR a single dash (for TYPO3 link tag)
                    (?:
                        \\s+-
                    )
                )+\\s*
                |							# OR only spaces
                \\s*
            )
            /?>								# closing the tag with \'>\' or \'/>\'
        )';

    public function __construct(
        private LoggerInterface $logger,
    ) {}

    /**
     * Implements "cropHTML" which is a modified "substr" function allowing to limit a string length to a certain number
     * of chars (from either start or end of string) and having a pre/postfix applied if the string really was cropped.
     *
     * @param string $content The string to perform the operation on
     * @param int $numberOfChars Max number of chars of the string. Negative value means cropping from end of string.
     * @param string $replacementForEllipsis  The pre/postfix string to apply if cropping occurs.
     * @param bool $cropToSpace If true then crop will be applied at nearest space.
     * @return string The processed input value.
     */
    public function crop(string $content, int $numberOfChars, string $replacementForEllipsis, bool $cropToSpace): string
    {
        $cropFromRight = $numberOfChars < 0;

        $sections = $this->splitContentIntoSections($content, $cropFromRight);

        // Only crop text sections (chars of tag-blocks are not counted).
        $strLengthOfAllPrevTextSections = 0;

        // This is the offset of the content item which was cropped.
        $croppedOffset = null;
        $amountOfSections = count($sections);

        // For cropSectionToNextSpace we need a collection of all processed text sections
        $processedTextSectionsForCropping = [];

        for ($offset = 0; $offset < $amountOfSections; $offset++) {
            if ($this->isTextSection($offset)) {
                $contentOfCurrentSection = $sections[$offset];
                $strLengthOfCurrentSection = mb_strlen(
                    html_entity_decode($contentOfCurrentSection, ENT_COMPAT, 'UTF-8'),
                    'utf-8'
                );

                if ($strLengthOfAllPrevTextSections + $strLengthOfCurrentSection > abs($numberOfChars)) {
                    $croppedOffset = $offset;
                    $cropPosition = $this->getCropPosition(
                        $contentOfCurrentSection,
                        $numberOfChars,
                        $strLengthOfAllPrevTextSections,
                        $cropFromRight
                    );

                    // Main cropping. Note the +1 and -1. These are there to be able to
                    // check for space characters later on.
                    $contentOfCurrentSection = !$cropFromRight
                        ? mb_substr($contentOfCurrentSection, 0, $cropPosition + 1)
                        : mb_substr($contentOfCurrentSection, -$cropPosition - 1);

                    $contentOfCurrentSection = $this->cropSectionToNextSpace(
                        $contentOfCurrentSection,
                        $processedTextSectionsForCropping,
                        $cropToSpace,
                        $cropFromRight
                    );

                    $sections[$offset] = $contentOfCurrentSection;
                    break;
                }
                $strLengthOfAllPrevTextSections += $strLengthOfCurrentSection;
                if ($contentOfCurrentSection !== '') {
                    $processedTextSectionsForCropping[] = $contentOfCurrentSection;
                }
            }
        }

        $sections = $this->closeCroppedTags($sections, $croppedOffset, $numberOfChars, $replacementForEllipsis);

        // Reverse array once again if we are cropping from the end.
        if ($numberOfChars < 0) {
            $sections = array_reverse($sections);
        }

        return implode('', $sections);
    }

    /**
     * Split $content into an array(even items in the array are outside the tags, odd numbers are tag-blocks).
     */
    protected function splitContentIntoSections(string $content, bool $cropFromRight): array
    {
        $splitPattern = sprintf(
            self::TAGS_REG_EXP,
            self::TAGS
        );

        $sections = preg_split(
            '%' . $splitPattern . '%xs',
            $content,
            -1,
            PREG_SPLIT_DELIM_CAPTURE
        );
        if ($sections === false) {
            $this->logger->debug('Unable to split "{content}" into tags.', ['content' => $content]);
            $sections = [];
        }

        // Reverse array if we are cropping from right.
        if ($cropFromRight) {
            $sections = array_reverse($sections);
        }

        return $sections;
    }

    protected function getCropPosition(
        string $contentOfCurrentSection,
        int $numberOfChars,
        int $strLengthOfAllPrevTextSections,
        bool $cropFromRight
    ): int {
        $cropPosition = abs($numberOfChars) - $strLengthOfAllPrevTextSections;

        // The snippet "&[^&\s;]{2,8};" in the RegEx below represents entities.
        $entityPattern = '/&[^&\\s;]{2,8};/';
        preg_match_all($entityPattern, $contentOfCurrentSection, $matches);
        $entityMatches = $matches[0];

        // If we have found any html entities, these should be counted as 1 character.
        // Strategy is to replace all found entities with an arbitrary character ($)
        // and use this new string to count offsets.
        if ($entityMatches !== []) {
            $escapedContent = str_replace('$', ' ', $contentOfCurrentSection);
            $replacedContent = preg_replace($entityPattern, '$', $escapedContent, -1);
            $croppedContent = !$cropFromRight
                ? mb_substr($replacedContent, 0, $cropPosition)
                : mb_substr($replacedContent, $numberOfChars, $cropPosition);

            // In case of negative offsets, we need to reverse everything.
            // Because the string is cropped from behind, the entities
            // have to be replaced in reverse, too.
            if ($cropFromRight) {
                $croppedContent = strrev($croppedContent);
                $entityMatches = array_reverse($entityMatches);
            }

            foreach ($entityMatches as $entity) {
                $croppedContent = preg_replace('/\$/', $entity, $croppedContent, 1);
            }

            $cropPosition = mb_strlen($croppedContent);
        }

        return $cropPosition;
    }

    protected function closeCroppedTags(
        array $sections,
        ?int $croppedOffset,
        int $numberOfChars,
        string $replacementForEllipsis
    ): array {
        $closingTags = [];
        if ($croppedOffset !== null) {
            $openingTagRegEx = '#^<(\\w+)(?:\\s|>)#';
            $closingTagRegEx = '#^</(\\w+)(?:\\s|>)#';
            for ($offset = $croppedOffset - 1; $offset >= 0; $offset = $offset - 2) {
                if (str_ends_with($sections[$offset], '/>')) {
                    // Ignore empty element tags (e.g. <br />).
                    continue;
                }

                preg_match($numberOfChars < 0 ? $closingTagRegEx : $openingTagRegEx, $sections[$offset], $matches);
                $tagName = $matches[1] ?? null;
                if ($tagName !== null) {
                    // Seek for the closing (or opening) tag.
                    $amountOfSections = count($sections);
                    for ($seekingOffset = $offset + 2; $seekingOffset < $amountOfSections; $seekingOffset = $seekingOffset + 2) {
                        preg_match($numberOfChars < 0 ? $openingTagRegEx : $closingTagRegEx, $sections[$seekingOffset], $matches);
                        $seekingTagName = $matches[1] ?? null;
                        if ($tagName === $seekingTagName) {
                            // We found a matching tag.
                            // Add closing tag only if it occurs after the cropped content item.
                            if ($seekingOffset > $croppedOffset) {
                                $closingTags[] = $sections[$seekingOffset];
                            }
                            break;
                        }
                    }
                }
            }
            // Drop the cropped items of the content array. The $closingTags will be added later on again.
            array_splice($sections, $croppedOffset + 1);
        }

        return array_merge($sections, [
            $croppedOffset !== null ? trim($replacementForEllipsis) : '',
        ], $closingTags);
    }

    protected function cropSectionToNextSpace(
        string $contentOfCurrentSection,
        array $processedTextSectionsForCropping,
        bool $cropToSpace,
        bool $cropFromRight
    ): string {
        // Crop to space means, we ensure to crop before (or after) a space.
        // If there are no spaces, this option has no effect.
        $cropToSpaceApplied = false;
        if ($cropToSpace) {
            $exploded = explode(' ', $contentOfCurrentSection);
            if (!$cropFromRight) {
                array_unshift(
                    $exploded,
                    ...$processedTextSectionsForCropping
                );
            } else {
                array_push(
                    $exploded,
                    ...$processedTextSectionsForCropping
                );
            }

            if (count($exploded) > 1) {
                if (!$cropFromRight && $exploded[count($exploded) - 1] !== ' ') {
                    array_pop($exploded);
                    $cropToSpaceApplied = true;
                } elseif ($exploded[0] !== ' ') {
                    array_shift($exploded);
                    $cropToSpaceApplied = true;
                }
            }
            $exploded = array_diff($exploded, $processedTextSectionsForCropping);
            $contentOfCurrentSection = implode(' ', $exploded);
        }

        // Only remove the extra character again, if crop2space did not apply anything.
        if (!$cropToSpaceApplied) {
            $contentOfCurrentSection = !$cropFromRight
                ? mb_substr($contentOfCurrentSection, 0, -1)
                : mb_substr($contentOfCurrentSection, 1);
        }

        return $contentOfCurrentSection;
    }

    protected function isTextSection(int $offset): bool
    {
        return $offset % 2 === 0;
    }
}
