<?php

declare(strict_types=1);

/*
 * This file is part of the VITICORE2 package.
 *
 * (c) CP Creation <web@cpcreation.fr>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace CpCreation\VitiCore\Bridge\Symfony\Bundle\Twig;

use CpCreation\VitiCore\PageBuilder\Builder\BuilderUi;
use CpCreation\VitiCore\PageBuilder\Model\Page;
use CpCreation\VitiCore\PageBuilder\Model\PageSection;
use CpCreation\VitiCore\PageBuilder\Rendering\PageLayout;
use CpCreation\VitiCore\PageBuilder\Rendering\PageLayoutResolver;
use DOMDocument;
use DOMElement;
use DOMXPath;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

final class PageBuilderExtension extends AbstractExtension
{
    public function __construct(
        private readonly PageLayoutResolver $pageLayoutResolver,
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('page_builder_apply_mutations', [$this, 'applyMutations'], ['is_safe' => ['html']]),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('page_builder_theme_overrides_style', [$this, 'themeOverridesStyle'], ['is_safe' => ['html']]),
            new TwigFunction('builder_ui', [$this, 'builderUi']),
            new TwigFunction('page_layout', [$this, 'pageLayout']),
        ];
    }

    public function builderUi(?PageSection $section = null, ?string $locale = null): BuilderUi
    {
        return new BuilderUi($section, $locale);
    }

    public function pageLayout(?Page $page, bool $builderMode = false): PageLayout
    {
        return $this->pageLayoutResolver->resolve($page, $builderMode);
    }

    public function themeOverridesStyle(?Page $page): string
    {
        if (!$page instanceof Page) {
            return '';
        }

        $overrides = $page->getSetting('globalThemeOverrides', []);
        if (!\is_array($overrides) || [] === $overrides) {
            return '';
        }

        $declarations = [];
        foreach ($overrides as $token => $value) {
            $token = trim((string) $token);
            $value = trim((string) $value);
            if (!$this->isAllowedGlobalThemeToken($token) || !$this->isAllowedCssValue($value)) {
                continue;
            }

            $declarations[] = sprintf('%s:%s', $token, $value);
        }

        if ([] === $declarations) {
            return '';
        }

        return '<style data-page-builder-theme-overrides>:root{'.htmlspecialchars(implode(';', $declarations), \ENT_NOQUOTES | \ENT_SUBSTITUTE, 'UTF-8').';}</style>';
    }

    public function applyMutations(string $html, PageSection $section, ?string $locale = null): string
    {
        $mutations = $this->sectionMutations($section, $locale);
        if ([] === $mutations || '' === trim($html)) {
            return $html;
        }

        $document = new DOMDocument('1.0', 'UTF-8');
        $previous = libxml_use_internal_errors(true);
        $document->loadHTML(
            '<?xml encoding="UTF-8"><div data-page-builder-fragment="true">'.$html.'</div>',
            \LIBXML_HTML_NOIMPLIED | \LIBXML_HTML_NODEFDTD,
        );
        libxml_clear_errors();
        libxml_use_internal_errors($previous);

        $xpath = new DOMXPath($document);
        $root = $xpath->query('//*[@data-page-builder-fragment="true"]')->item(0);
        if (!$root instanceof DOMElement) {
            return $html;
        }

        foreach ($mutations as $mutation) {
            $target = \is_string($mutation['target'] ?? null) ? $mutation['target'] : '';
            $element = $this->findTarget($root, $target);
            if (!$element instanceof DOMElement) {
                continue;
            }

            $this->applyAttributes($element, $mutation['attributes'] ?? []);
            $this->applyStyles($element, $mutation['styles'] ?? []);
            $this->applyTextContent($document, $element, $mutation['textContent'] ?? null);
            foreach (($mutation['removeAttributes'] ?? []) as $attribute) {
                if (\is_string($attribute) && $this->isRemovableAttributeName($attribute)) {
                    $element->removeAttribute($attribute);
                }
            }
        }

        $output = '';
        foreach ($root->childNodes as $child) {
            $output .= $document->saveHTML($child);
        }

        return $output;
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function sectionMutations(PageSection $section, ?string $locale): array
    {
        $settings = $section->getSettings();

        return [
            ...$this->decodeMutations($settings['domMutations'] ?? null),
            ...$this->localizedMutations($settings['contentMutationsByLocale'] ?? null, $locale),
        ];
    }

    private function isAllowedGlobalThemeToken(string $token): bool
    {
        return 1 === preg_match('/^--(?:color|font|text|radius|spacing|shadow|border)-[a-z0-9-]+(?:--[a-z0-9-]+)?$/', $token);
    }

    private function isAllowedCssValue(string $value): bool
    {
        return '' !== $value && \strlen($value) <= 240 && 1 !== preg_match('/[<>{};]/', $value);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function localizedMutations(mixed $rawValues, ?string $locale): array
    {
        if (null === $locale || !\is_array($rawValues)) {
            return [];
        }

        return $this->decodeMutations($rawValues[$locale] ?? null);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function decodeMutations(mixed $raw): array
    {
        if (!\is_string($raw) || '' === trim($raw)) {
            return [];
        }

        try {
            $mutations = json_decode($raw, true, flags: \JSON_THROW_ON_ERROR);
        } catch (\JsonException) {
            return [];
        }

        return \is_array($mutations) ? array_values(array_filter($mutations, \is_array(...))) : [];
    }

    private function findTarget(DOMElement $root, string $target): ?DOMElement
    {
        if (preg_match('/^section:(\d+)$/', $target, $matches)) {
            return $this->elementsByTags($root, ['section'])[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^container:(\d+)$/', $target, $matches)) {
            return $this->containerElements($root)[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^background:(\d+)$/', $target, $matches)) {
            return $this->backgroundElements($root)[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^heading:(\d+)$/', $target, $matches)) {
            return $this->elementsByTags($root, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^paragraph:(\d+)$/', $target, $matches)) {
            return $this->elementsByTags($root, ['p'])[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^link:(\d+)$/', $target, $matches)) {
            return $this->elementsByTags($root, ['a'])[(int) $matches[1]] ?? null;
        }

        if (preg_match('/^buttons:(\d+)$/', $target, $matches)) {
            return $this->buttonGroupElements($root)[(int) $matches[1]] ?? null;
        }

        return null;
    }

    /**
     * @param list<string> $tags
     *
     * @return list<DOMElement>
     */
    private function elementsByTags(DOMElement $root, array $tags): array
    {
        $wanted = array_flip($tags);
        $elements = [];
        foreach ($root->getElementsByTagName('*') as $element) {
            if ($element instanceof DOMElement && isset($wanted[strtolower($element->tagName)])) {
                $elements[] = $element;
            }
        }

        return $elements;
    }

    /**
     * @return list<DOMElement>
     */
    private function backgroundElements(DOMElement $root): array
    {
        $elements = [];
        foreach ($root->getElementsByTagName('*') as $element) {
            if (!$element instanceof DOMElement) {
                continue;
            }

            $class = ' '.$element->getAttribute('class').' ';
            $style = $element->getAttribute('style');
            $tag = strtolower($element->tagName);
            $isAbsoluteInset = str_contains($class, ' absolute ') && str_contains($class, ' inset-0 ');
            $looksLikeBackground = 'img' === $tag
                || str_contains($class, ' bg-')
                || str_contains($style, 'background:') || str_contains($style, 'background-image:');

            if ($isAbsoluteInset && $looksLikeBackground) {
                $elements[] = $element;
            }
        }

        return $elements;
    }

    /**
     * @return list<DOMElement>
     */
    private function containerElements(DOMElement $root): array
    {
        $containers = [];
        foreach ($this->elementsByTags($root, ['section']) as $section) {
            foreach ($section->childNodes as $child) {
                if ($child instanceof DOMElement && 'div' === strtolower($child->tagName)) {
                    $containers[] = $child;
                    break;
                }
            }
        }

        if ([] !== $containers) {
            return $containers;
        }

        return array_values(array_filter(
            $this->elementsByTags($root, ['div']),
            static fn (DOMElement $element): bool => str_contains(' '.$element->getAttribute('class').' ', ' relative ')
                && !str_contains(' '.$element->getAttribute('class').' ', ' absolute '),
        ));
    }

    /**
     * @return list<DOMElement>
     */
    private function buttonGroupElements(DOMElement $root): array
    {
        return array_values(array_filter(
            $this->elementsByTags($root, ['div']),
            static fn (DOMElement $element): bool => str_contains(' '.$element->getAttribute('class').' ', ' flex ')
                && self::hasDirectLinkChild($element),
        ));
    }

    private static function hasDirectLinkChild(DOMElement $element): bool
    {
        foreach ($element->childNodes as $child) {
            if ($child instanceof DOMElement && 'a' === strtolower($child->tagName)) {
                return true;
            }
        }

        return false;
    }

    private function applyAttributes(DOMElement $element, mixed $attributes): void
    {
        if (!\is_array($attributes)) {
            return;
        }

        foreach ($attributes as $name => $value) {
            $name = (string) $name;
            if (!$this->isSafeAttributeName($name) || !$this->isSafeAttributeValue($name, $value)) {
                continue;
            }

            $element->setAttribute($name, (string) $value);
        }
    }

    private function applyStyles(DOMElement $element, mixed $styles): void
    {
        if (!\is_array($styles)) {
            return;
        }

        $style = trim($element->getAttribute('style'));
        foreach ($styles as $property => $value) {
            if (!\is_string($property) || !$this->isSafeStyleProperty($property) || !$this->isSafeStyleValue((string) $value)) {
                continue;
            }

            $style .= ('' === $style || str_ends_with($style, ';') ? ' ' : '; ').$property.': '.(string) $value.';';
        }

        if ('' !== trim($style)) {
            $element->setAttribute('style', trim($style));
        }
    }

    private function applyTextContent(DOMDocument $document, DOMElement $element, mixed $value): void
    {
        if (!\is_scalar($value)) {
            return;
        }

        while ($element->firstChild) {
            $element->removeChild($element->firstChild);
        }

        $element->appendChild($document->createTextNode((string) $value));
    }

    private function isSafeAttributeName(string $name): bool
    {
        return 1 === preg_match('/^(class|style|src|href|alt|data-[a-z0-9_-]+)$/i', $name);
    }

    private function isRemovableAttributeName(string $name): bool
    {
        return 'style' === strtolower($name) || $this->isSafeAttributeName($name);
    }

    private function isSafeAttributeValue(string $name, mixed $value): bool
    {
        if (!\is_scalar($value) || str_contains((string) $value, '<') || str_contains((string) $value, '`')) {
            return false;
        }

        if ('style' === strtolower($name)) {
            return $this->isSafeStyleValue((string) $value);
        }

        return !\in_array(strtolower($name), ['href', 'src'], true) || !preg_match('/^\s*javascript:/i', (string) $value);
    }

    private function isSafeStyleProperty(string $property): bool
    {
        return 1 === preg_match('/^-?[a-z][a-z0-9-]*$/i', $property);
    }

    private function isSafeStyleValue(string $value): bool
    {
        return !preg_match('/(?:<|`|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|@import)/i', $value);
    }
}
