<?php

namespace MintyPHP\Service\Docs;

use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;

class DocsCatalogService
{
    /** @var array<string, array<string, string>>|null */
    private static ?array $docGroupsCache = null;

    /** @var array<string, string>|null */
    private static ?array $allDocsCache = null;

    private static function docsDir(): ?string
    {
        $docsDir = realpath(dirname(__DIR__, 3) . '/docs');
        if ($docsDir === false || !is_dir($docsDir)) {
            return null;
        }

        return $docsDir;
    }

    private static function humanizeGroupLabel(string $groupHeading): string
    {
        $knownGroups = [
            'Lernpfad (chronologisch)' => t('Learning path'),
            'Konzepte (Explanation)' => t('Concepts'),
            'Aufgabenanleitungen (How-to)' => t('How-to guides'),
            'Referenz (Reference)' => t('Reference'),
            'Betrieb (Operations)' => t('Operations'),
            'Mitwirken (Contributor Guide)' => t('Contributor guide'),
            'Start hier (neue Entwickler)' => t('Getting started'),
            'Architektur und Regeln' => t('Architecture & rules'),
            'Frontend' => t('Frontend'),
            'Fach- und Feature-Dokumentation' => t('Features'),
            'API Referenz' => t('API reference'),
            'Betriebswissen' => t('Operations'),
        ];

        return $knownGroups[$groupHeading] ?? t($groupHeading);
    }

    private static function normalizePlainText(string $value): string
    {
        $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $normalized = preg_replace('/\s+/u', ' ', $decoded);
        return trim((string) $normalized);
    }

    private static function titleFromMarkdownFile(string $docPath, string $docSlug): string
    {
        $content = file_get_contents($docPath);
        if (!is_string($content) || trim($content) === '') {
            return str_replace('-', ' ', $docSlug);
        }

        if (preg_match('/^#\s+(.+)$/m', $content, $match)) {
            $title = self::normalizePlainText(strip_tags((string) $match[1]));
            if ($title !== '') {
                return $title;
            }
        }

        return str_replace('-', ' ', $docSlug);
    }

    /**
     * @param array<string, array<string, string>> $groups
     * @return array<string, array<string, string>>
     */
    private static function ensureUniqueLabels(array $groups): array
    {
        $labelCounts = [];
        foreach ($groups as $items) {
            foreach ($items as $label) {
                $key = mb_strtolower(trim($label));
                if ($key === '') {
                    continue;
                }
                $labelCounts[$key] = ($labelCounts[$key] ?? 0) + 1;
            }
        }

        foreach ($groups as $groupLabel => $items) {
            foreach ($items as $docSlug => $label) {
                $key = mb_strtolower(trim($label));
                if (($labelCounts[$key] ?? 0) > 1) {
                    $groups[$groupLabel][$docSlug] = $label . ' (' . $docSlug . ')';
                }
            }
        }

        return $groups;
    }

    /**
     * @return array<string, array<string, string>>
     */
    private static function parseDocGroupsFromIndex(string $indexFile, string $docsDir): array
    {
        if (!is_readable($indexFile)) {
            return [];
        }

        $indexContent = file_get_contents($indexFile);
        if (!is_string($indexContent) || trim($indexContent) === '') {
            return [];
        }

        $groups = [];
        $currentGroup = null;
        $lines = preg_split('/\R/', $indexContent) ?: [];
        foreach ($lines as $line) {
            $trimmedLine = trim($line);

            if (preg_match('/^##\s+(.+)$/', $trimmedLine, $headingMatch)) {
                $groupHeading = trim($headingMatch[1]);
                $currentGroup = $groupHeading !== '' ? self::humanizeGroupLabel($groupHeading) : '';
                if ($currentGroup !== '' && !isset($groups[$currentGroup])) {
                    $groups[$currentGroup] = [];
                }
                continue;
            }

            if ($currentGroup === null) {
                continue;
            }

            if (!preg_match('~^(?:\d+\.|-)\s+`?/docs/([a-z0-9-]+)\.md`?$~i', $trimmedLine, $docMatch)) {
                continue;
            }

            $docSlug = strtolower((string) $docMatch[1]);
            $docPath = $docsDir . DIRECTORY_SEPARATOR . $docSlug . '.md';
            if (!is_readable($docPath)) {
                continue;
            }

            $groups[$currentGroup][$docSlug] = self::titleFromMarkdownFile($docPath, $docSlug);
        }

        $groups = array_filter(
            $groups,
            static fn (array $items): bool => $items !== []
        );

        return self::ensureUniqueLabels($groups);
    }

    /**
     * @return array<string, array<string, string>>
     */
    public static function docGroups(): array
    {
        if (self::$docGroupsCache !== null) {
            return self::$docGroupsCache;
        }

        $docsDir = self::docsDir();
        if ($docsDir === null) {
            self::$docGroupsCache = [];
            return self::$docGroupsCache;
        }

        self::$docGroupsCache = self::parseDocGroupsFromIndex($docsDir . DIRECTORY_SEPARATOR . 'index.md', $docsDir);
        return self::$docGroupsCache;
    }

    /**
     * @return array<string, string>
     */
    public static function allDocs(): array
    {
        if (self::$allDocsCache !== null) {
            return self::$allDocsCache;
        }

        $allDocs = [];
        foreach (self::docGroups() as $items) {
            foreach ($items as $docSlug => $label) {
                $allDocs[$docSlug] = $label;
            }
        }

        self::$allDocsCache = $allDocs;
        return self::$allDocsCache;
    }

    public static function defaultSlug(): string
    {
        return array_key_first(self::allDocs()) ?: '00-systemueberblick';
    }

    public static function docPathBySlug(string $slug): ?string
    {
        $slug = trim(strtolower($slug));
        if ($slug === '') {
            return null;
        }

        $allDocs = self::allDocs();
        if (!array_key_exists($slug, $allDocs)) {
            return null;
        }

        $docsDir = self::docsDir();
        if ($docsDir === null) {
            return null;
        }

        $path = $docsDir . DIRECTORY_SEPARATOR . $slug . '.md';
        if (!is_readable($path)) {
            return null;
        }

        return $path;
    }

    public static function readMarkdown(string $slug): ?string
    {
        $path = self::docPathBySlug($slug);
        if ($path === null) {
            return null;
        }

        $rawMarkdown = file_get_contents($path);
        if (!is_string($rawMarkdown)) {
            return null;
        }

        return $rawMarkdown;
    }

    private static function markdownEnvironment(): Environment
    {
        $environment = new Environment([
            'heading_permalink' => [
                'html_class' => 'docs-heading-anchor',
                'id_prefix' => '',
                'fragment_prefix' => '',
                'title' => '',
                'symbol' => '',
                'insert' => 'before',
            ],
        ]);
        $environment->addExtension(new CommonMarkCoreExtension());
        $environment->addExtension(new GithubFlavoredMarkdownExtension());
        $environment->addExtension(new TaskListExtension());
        $environment->addExtension(new HeadingPermalinkExtension());

        return $environment;
    }

    public static function renderMarkdown(string $rawMarkdown): string
    {
        $converter = new MarkdownConverter(self::markdownEnvironment());
        return (string) $converter->convert($rawMarkdown);
    }

    private static function slugifyHeading(string $heading): string
    {
        $slug = strtolower((string) preg_replace('/[^a-z0-9]+/i', '-', $heading));
        return trim($slug, '-');
    }

    /**
     * @param array<string, int> $seenAnchors
     */
    private static function nextUniqueAnchor(string $baseAnchor, array &$seenAnchors): string
    {
        $anchor = $baseAnchor !== '' ? $baseAnchor : 'section';
        if (!isset($seenAnchors[$anchor])) {
            $seenAnchors[$anchor] = 1;
            return $anchor;
        }

        $seenAnchors[$anchor]++;
        return $anchor . '-' . $seenAnchors[$anchor];
    }

    /**
     * @param int[] $levels
     * @return array{html:string, headings:array<int, array{level:int,text:string,anchor:string,body:string}>}
     */
    public static function prepareRenderedDocument(string $rawMarkdown, array $levels = [1, 2, 3]): array
    {
        $renderedHtml = self::renderMarkdown($rawMarkdown);
        $sourceHtml = $renderedHtml;

        preg_match_all('/<h([1-3])([^>]*)>(.*?)<\/h\1>/is', $sourceHtml, $headingBlocks, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
        if ($headingBlocks === []) {
            return [
                'html' => $renderedHtml,
                'headings' => [],
            ];
        }

        $seenAnchors = [];
        $hasMissingId = false;
        $parsedHeadings = [];

        foreach ($headingBlocks as $headingBlock) {
            $level = (int) $headingBlock[1][0];
            if (!in_array($level, [1, 2, 3], true)) {
                continue;
            }

            $attrs = (string) $headingBlock[2][0];
            $html = (string) $headingBlock[3][0];
            $text = self::normalizePlainText(strip_tags($html));
            if ($text === '') {
                continue;
            }

            $id = '';
            if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs, $idMatch)) {
                $id = trim((string) $idMatch[2]);
            }

            if ($id === '') {
                $id = self::nextUniqueAnchor(self::slugifyHeading($text), $seenAnchors);
                $hasMissingId = true;
            } else {
                $id = self::nextUniqueAnchor($id, $seenAnchors);
            }

            $fullHtml = (string) $headingBlock[0][0];
            $start = (int) $headingBlock[0][1];
            $end = $start + strlen($fullHtml);

            $parsedHeadings[] = [
                'level' => $level,
                'attrs' => $attrs,
                'html' => $html,
                'text' => $text,
                'anchor' => $id,
                'start' => $start,
                'end' => $end,
            ];
        }

        if ($parsedHeadings === []) {
            return [
                'html' => $renderedHtml,
                'headings' => [],
            ];
        }

        if ($hasMissingId) {
            $headingIndex = 0;
            $renderedHtml = preg_replace_callback(
                '/<h([1-3])([^>]*)>(.*?)<\/h\1>/is',
                static function (array $match) use (&$headingIndex, $parsedHeadings): string {
                    if (!isset($parsedHeadings[$headingIndex])) {
                        return $match[0];
                    }

                    $heading = $parsedHeadings[$headingIndex++];
                    $level = (string) $match[1];
                    $attrs = (string) $match[2];
                    $html = (string) $match[3];

                    if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs)) {
                        return "<h{$level}{$attrs}>{$html}</h{$level}>";
                    }

                    return "<h{$level}{$attrs} id=\"{$heading['anchor']}\">{$html}</h{$level}>";
                },
                $renderedHtml
            ) ?? $renderedHtml;
        }

        $headings = [];
        $countHeadings = count($parsedHeadings);
        foreach ($parsedHeadings as $index => $heading) {
            $nextStart = $index + 1 < $countHeadings ? (int) $parsedHeadings[$index + 1]['start'] : strlen($sourceHtml);
            $bodyLength = max(0, $nextStart - (int) $heading['end']);
            $bodyHtml = substr($sourceHtml, (int) $heading['end'], $bodyLength);
            $bodyText = self::normalizePlainText(strip_tags($bodyHtml));

            $level = (int) $heading['level'];
            if (!in_array($level, $levels, true)) {
                continue;
            }

            $headings[] = [
                'level' => $level,
                'text' => (string) $heading['text'],
                'anchor' => (string) $heading['anchor'],
                'body' => $bodyText,
            ];
        }

        return [
            'html' => $renderedHtml,
            'headings' => $headings,
        ];
    }

    private static function titleScore(string $needle, string $title): int
    {
        $title = mb_strtolower(trim($title));
        if ($title === '' || $needle === '') {
            return 0;
        }

        if ($title === $needle) {
            return 1000;
        }
        if (str_starts_with($title, $needle)) {
            return 900;
        }
        if (str_contains($title, $needle)) {
            return 800;
        }

        return 0;
    }

    private static function sectionScore(string $needle, string $section, string $body): int
    {
        $section = mb_strtolower(trim($section));
        $body = mb_strtolower(trim($body));
        if ($needle === '') {
            return 0;
        }

        $score = 0;
        if ($section !== '') {
            if ($section === $needle) {
                $score = max($score, 700);
            } elseif (str_starts_with($section, $needle)) {
                $score = max($score, 650);
            } elseif (str_contains($section, $needle)) {
                $score = max($score, 600);
            }
        }
        if ($body !== '' && str_contains($body, $needle)) {
            $score = max($score, 500);
        }

        return $score;
    }

    private static function buildSnippet(string $body, string $needle): string
    {
        $body = self::normalizePlainText($body);
        if ($body === '') {
            return '';
        }

        $maxLength = 160;
        $needlePos = mb_stripos($body, $needle);
        if ($needlePos === false) {
            $snippet = mb_substr($body, 0, $maxLength);
            if (mb_strlen($body) > $maxLength) {
                $snippet .= '...';
            }

            return $snippet;
        }

        $start = max(0, $needlePos - 40);
        $snippet = mb_substr($body, $start, $maxLength);
        if ($start > 0) {
            $snippet = '... ' . ltrim($snippet);
        }
        if ($start + $maxLength < mb_strlen($body)) {
            $snippet = rtrim($snippet) . ' ...';
        }

        return $snippet;
    }

    /**
     * @return array<int, array{slug:string,title:string,section:string,anchor:string,snippet:string,score:int}>
     */
    public static function search(string $query): array
    {
        $needle = mb_strtolower(trim($query));
        if ($needle === '') {
            return [];
        }

        $resultsByKey = [];
        foreach (self::allDocs() as $slug => $title) {
            $rawMarkdown = self::readMarkdown($slug);
            if ($rawMarkdown === null) {
                continue;
            }

            $prepared = self::prepareRenderedDocument($rawMarkdown, [1, 2, 3]);
            $headings = $prepared['headings'];
            if ($headings === []) {
                $bodyText = self::normalizePlainText(strip_tags($prepared['html']));
                $score = max(
                    self::titleScore($needle, $title),
                    self::sectionScore($needle, '', $bodyText)
                );
                if ($score <= 0) {
                    continue;
                }

                $key = $slug . '#';
                $resultsByKey[$key] = [
                    'slug' => $slug,
                    'title' => $title,
                    'section' => '',
                    'anchor' => '',
                    'snippet' => self::buildSnippet($bodyText, $needle),
                    'score' => $score,
                ];
                continue;
            }

            $docScore = self::titleScore($needle, $title);
            if ($docScore > 0) {
                $firstHeading = $headings[0];
                $docKey = $slug . '#' . (string) $firstHeading['anchor'];
                $resultsByKey[$docKey] = [
                    'slug' => $slug,
                    'title' => $title,
                    'section' => '',
                    'anchor' => (string) $firstHeading['anchor'],
                    'snippet' => self::buildSnippet((string) $firstHeading['body'], $needle),
                    'score' => $docScore,
                ];
            }

            foreach ($headings as $heading) {
                $sectionText = (string) $heading['text'];
                $anchor = (string) $heading['anchor'];
                $body = (string) $heading['body'];
                $score = self::sectionScore($needle, $sectionText, $body);
                if ($score <= 0) {
                    continue;
                }

                $key = $slug . '#' . $anchor;
                $candidate = [
                    'slug' => $slug,
                    'title' => $title,
                    'section' => $sectionText,
                    'anchor' => $anchor,
                    'snippet' => self::buildSnippet($body, $needle),
                    'score' => $score,
                ];

                if (!isset($resultsByKey[$key]) || (int) $resultsByKey[$key]['score'] < $score) {
                    $resultsByKey[$key] = $candidate;
                }
            }
        }

        $results = array_values($resultsByKey);
        usort($results, static function (array $a, array $b): int {
            $scoreCmp = ((int) $b['score']) <=> ((int) $a['score']);
            if ($scoreCmp !== 0) {
                return $scoreCmp;
            }

            $titleCmp = strcmp((string) $a['title'], (string) $b['title']);
            if ($titleCmp !== 0) {
                return $titleCmp;
            }

            return strcmp((string) $a['section'], (string) $b['section']);
        });

        return $results;
    }
}
