<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CmsBundle\Translation;

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class DeeplTranslator
{
    public function __construct(
        private readonly HttpClientInterface $deeplClient,
        private readonly CacheItemPoolInterface $machineTranslationCache,
    ) {
    }

    public function getTargetLang(string $locale): string
    {
        $locale = strtoupper($locale);

        return match ($locale) {
            'EN' => 'EN-US',
            default => $locale,
        };
    }

    public function translate(string $content, string $targetLang, string $sourceLang = 'DE'): ?string
    {
        $sourceLang = strtoupper($sourceLang);
        $key = 'deepl_'.hash('sha256', $sourceLang.'|'.$targetLang.'|'.$content);

        $item = $this->machineTranslationCache->getItem($key);
        if ($item->isHit()) {
            return $item->get();
        }

        $result = $this->doTranslate($content, $targetLang, $sourceLang);

        if (null !== $result) {
            $this->machineTranslationCache->save($item->set($result));
        }

        return $result;
    }

    private function doTranslate(string $content, string $targetLang, string $sourceLang): ?string
    {
        try {
            [$content, $piiMap] = $this->truncatePii($content);

            $response = $this->deeplClient->request('POST', '/v2/translate', [
                'body' => [
                    'text' => [$content],
                    'target_lang' => $targetLang,
                    'source_lang' => $sourceLang,
                    'tag_handling' => 'html',
                    'tag_handling_version' => 'v2',
                ],
                'timeout' => 10,
            ]);

            $data = $response->toArray();

            $translated = $data['translations'][0]['text'] ?? null;

            if (null === $translated) {
                return null;
            }

            return $this->restorePii($translated, $piiMap);
        } catch (\Throwable) {
            return null;
        }
    }

    private function truncatePii(string $content): array
    {
        if ('' === $content || !str_contains($content, 'translate="no|pii"')) {
            return [$content, []];
        }

        $piiMap = [];
        $i = 0;

        // We use a regex to find opening tags with translate="no|pii"
        while (preg_match('/<([a-z0-9]+)[^>]*?\btranslate="no\|pii"[^>]*?>/is', $content, $matches, \PREG_OFFSET_CAPTURE)) {
            $tagName = (string) $matches[1][0];
            $tagStartPos = (int) $matches[0][1];
            $openingTagEndPos = $tagStartPos + \strlen((string) $matches[0][0]);
            $openingTag = (string) $matches[0][0];

            // Void elements don't have closing tags in HTML
            $isVoid = \in_array(strtolower($tagName), ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'], true);
            $isSelfClosing = $isVoid || str_ends_with(rtrim($openingTag, '> '), '/');

            if ($isSelfClosing) {
                $endOffset = $openingTagEndPos;
            } else {
                $closingTagOffset = $this->findClosingTag($content, $tagName, $openingTagEndPos);
                if (-1 === $closingTagOffset) {
                    // If no closing tag is found, we just replace the opening tag
                    // This is for invalid HTML
                    $endOffset = $openingTagEndPos;
                } else {
                    $endOffset = $closingTagOffset + \strlen("</$tagName>");
                }
            }

            $fullElement = substr($content, $tagStartPos, $endOffset - $tagStartPos);
            $id = 'deepl-ignore-'.$i++;
            $piiMap[$id] = $fullElement;

            $placeholder = '<span id="'.$id.'" translate="no"></span>';
            $content = substr_replace($content, $placeholder, $tagStartPos, $endOffset - $tagStartPos);
        }

        return [trim($content), $piiMap];
    }

    private function findClosingTag(string $content, string $tagName, int $offset): int
    {
        $depth = 1;
        $pos = $offset;

        // Match either an opening or closing tag of the same name
        // We use \b to ensure we match the exact tag name
        while ($depth > 0 && preg_match('/<(\/)?'.preg_quote($tagName, '/').'\b[^>]*?>/is', $content, $matches, \PREG_OFFSET_CAPTURE, $pos)) {
            if (isset($matches[1]) && '/' === $matches[1][0]) {
                --$depth;
            } else {
                $innerTag = (string) $matches[0][0];
                $isVoid = \in_array(strtolower($tagName), ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'], true);
                if (!$isVoid && !str_ends_with(rtrim($innerTag, '> '), '/')) {
                    ++$depth;
                }
            }

            $pos = (int) $matches[0][1] + \strlen((string) $matches[0][0]);
            if (0 === $depth) {
                return (int) $matches[0][1];
            }
        }

        return -1;
    }

    private function restorePii(string $content, array $piiMap): string
    {
        foreach ($piiMap as $id => $originalHtml) {
            $pattern = '/<span[^>]*id="'.preg_quote($id, '/').'"[^>]*>.*?<\/span>/s';
            $content = preg_replace($pattern, $originalHtml, (string) $content);
        }

        return (string) $content;
    }
}
