<?php
declare(strict_types=1);

namespace App\Questions;

use App\Support\TextNormalizer;
use PDO;

final class DuplicateDetectionService
{
    private const MIN_SCORE = 0.24;
    private const MAX_CANDIDATES = 50;

    /** @param array<string,list<string>> $stopwordsByLocale */
    public function __construct(
        private readonly PDO $pdo,
        private readonly array $stopwordsByLocale = [],
        ?TextNormalizer $normalizer = null,
    ) {
        $this->normalizer = $normalizer ?? new TextNormalizer();
    }

    private readonly TextNormalizer $normalizer;

    /**
     * @param list<int> $tagIds
     * @return list<array{id:int,title:string,slug:string,answers_count:int,content_locale:string}>
     */
    public function findSimilar(
        string $title,
        int $limit = 5,
        ?string $contentLocale = null,
        ?int $categoryId = null,
        array $tagIds = [],
    ): array {
        $title = trim($title);
        if ($title === '' || $this->unicodeLength($title) < 3) {
            return [];
        }

        $limit = max(1, min(5, $limit));
        $locale = $this->normalizeLocale($contentLocale);
        $queryTokens = $this->searchTokens($title, $locale);
        if ($queryTokens === []) {
            return [];
        }
        $queryText = $this->normalizeText($title, $locale);
        $searchTerms = $this->candidateSearchTerms($queryText, $queryTokens);

        $conditions = [];
        $hitExpressions = [];
        $params = ['status' => 'published'];
        foreach (array_slice($searchTerms, 0, 8) as $index => $token) {
            $conditions[] = "COALESCE(q.title_normalized,q.title) LIKE :term{$index}";
            $hitExpressions[] = "CASE WHEN COALESCE(q.title_normalized,q.title) LIKE :hit{$index} THEN 1 ELSE 0 END";
            $like = '%' . $token . '%';
            $params["term{$index}"] = $like;
            $params["hit{$index}"] = $like;
        }

        $localeSql = '';
        if ($locale !== null) {
            $localeSql = ' AND LOWER(q.content_locale) = :content_locale';
            $params['content_locale'] = $locale;
        }

        $sql = sprintf(
            'SELECT q.id, q.title, q.slug, q.answers_count, q.content_locale, q.category_id,
                    (%s) AS candidate_hits,
                    (SELECT GROUP_CONCAT(qt.tag_id ORDER BY qt.tag_id SEPARATOR \',\')
                     FROM question_tags qt WHERE qt.question_id = q.id) AS tag_ids
             FROM questions q
             WHERE q.status = :status%s
               AND (%s)
             ORDER BY candidate_hits DESC, q.answers_count DESC, q.id ASC
             LIMIT %d',
            implode(' + ', $hitExpressions),
            $localeSql,
            implode(' OR ', $conditions),
            self::MAX_CANDIDATES
        );

        $statement = $this->pdo->prepare($sql);
        $statement->execute($params);
        $normalizedTags = array_values(array_unique(array_filter(array_map('intval', $tagIds), static fn(int $id): bool => $id > 0)));
        $ranked = [];

        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
            $rowLocale = $this->normalizeLocale((string) ($row['content_locale'] ?? ''));
            if ($locale !== null && $rowLocale !== $locale) {
                continue;
            }

            $candidateTitle = trim((string) ($row['title'] ?? ''));
            if ($candidateTitle === '') {
                continue;
            }

            $candidateTokens = $this->searchTokens($candidateTitle, $rowLocale);
            $score = $this->tokenSimilarity($queryTokens, $candidateTokens);
            if (count($queryTokens) <= 2 || count($candidateTokens) <= 2) {
                $characterScore = $this->characterSimilarity($queryText, $this->normalizeText($candidateTitle, $rowLocale));
                $score = max($score, 0.85 * $characterScore);
            }

            if ($categoryId !== null && $categoryId > 0 && (int) ($row['category_id'] ?? 0) === $categoryId) {
                $score += 0.08;
            }

            $candidateTags = $this->parseTagIds($row['tag_ids'] ?? null);
            if ($normalizedTags !== [] && $candidateTags !== []) {
                $tagOverlap = count(array_intersect($normalizedTags, $candidateTags));
                if ($tagOverlap > 0) {
                    $score += min(0.12, 0.12 * ($tagOverlap / max(1, count($normalizedTags))));
                }
            }

            if ($score < self::MIN_SCORE) {
                continue;
            }

            $ranked[] = [
                'score' => $score,
                'id' => (int) ($row['id'] ?? 0),
                'title' => $candidateTitle,
                'slug' => (string) ($row['slug'] ?? ''),
                'answers_count' => (int) ($row['answers_count'] ?? 0),
                'content_locale' => (string) ($row['content_locale'] ?? ''),
            ];
        }

        usort($ranked, static function (array $a, array $b): int {
            $scoreOrder = $b['score'] <=> $a['score'];
            if ($scoreOrder !== 0) return $scoreOrder;
            $answerOrder = $b['answers_count'] <=> $a['answers_count'];
            return $answerOrder !== 0 ? $answerOrder : ($a['id'] <=> $b['id']);
        });

        $result = [];
        foreach (array_slice($ranked, 0, $limit) as $row) {
            $result[] = [
                'id' => (int) $row['id'],
                'title' => (string) $row['title'],
                'slug' => (string) $row['slug'],
                'answers_count' => (int) $row['answers_count'],
                'content_locale' => (string) $row['content_locale'],
            ];
        }

        return $result;
    }

    /** @return list<string> */
    private function searchTokens(string $title, ?string $locale): array
    {
        $normalized = $this->normalizeText($title, $locale);
        $parts = preg_split('/[^\p{L}\p{N}]+/u', $normalized);
        if ($parts === false) return [];

        $stopwords = [];
        if ($locale !== null && isset($this->stopwordsByLocale[$locale])) {
            foreach ($this->stopwordsByLocale[$locale] as $word) {
                $word = $this->normalizeText((string) $word, $locale);
                if ($word !== '') $stopwords[$word] = true;
            }
        }

        $tokens = [];
        foreach ($parts as $part) {
            $part = trim((string) $part);
            if ($part === '' || $this->unicodeLength($part) < 2 || isset($stopwords[$part])) continue;
            $tokens[$part] = $part;
        }

        return array_values($tokens);
    }

    private function normalizeText(string $value, ?string $locale = null): string
    {
        return $this->normalizer->normalize($value, $locale);
    }

    /** @param list<string> $tokens @return list<string> */
    private function candidateSearchTerms(string $normalizedTitle, array $tokens): array
    {
        $terms = $tokens;
        if (count($tokens) <= 2) {
            foreach ($this->characterNgrams($normalizedTitle, 2) as $gram) {
                if (!in_array($gram, $terms, true)) $terms[] = $gram;
            }
        }
        return $terms;
    }

    private function characterSimilarity(string $left, string $right): float
    {
        $leftGrams = $this->characterNgrams($left, 2);
        $rightGrams = $this->characterNgrams($right, 2);
        if ($leftGrams === [] || $rightGrams === []) return 0.0;

        $leftSet = array_fill_keys($leftGrams, true);
        $rightSet = array_fill_keys($rightGrams, true);
        $intersection = count(array_intersect_key($leftSet, $rightSet));
        if ($intersection === 0) return 0.0;
        $union = count($leftSet) + count($rightSet) - $intersection;
        $jaccard = $union > 0 ? $intersection / $union : 0.0;
        $coverage = $intersection / max(1, min(count($leftSet), count($rightSet)));
        return (0.65 * $jaccard) + (0.35 * $coverage);
    }

    /** @return list<string> */
    private function characterNgrams(string $value, int $size): array
    {
        $compact = preg_replace('/[^\p{L}\p{N}]+/u', '', $value) ?? $value;
        $characters = preg_split('//u', $compact, -1, PREG_SPLIT_NO_EMPTY);
        if ($characters === false || count($characters) < $size) return [];

        $grams = [];
        $limit = count($characters) - $size;
        for ($index = 0; $index <= $limit; $index++) {
            $gram = implode('', array_slice($characters, $index, $size));
            $grams[$gram] = $gram;
        }
        return array_values($grams);
    }

    /** @param list<string> $left @param list<string> $right */
    private function tokenSimilarity(array $left, array $right): float
    {
        if ($left === [] || $right === []) return 0.0;

        $leftWeights = $this->tokenWeights($left);
        $rightWeights = $this->tokenWeights($right);
        $intersection = 0.0;
        foreach ($leftWeights as $token => $weight) {
            if (isset($rightWeights[$token])) $intersection += min($weight, $rightWeights[$token]);
        }
        if ($intersection <= 0.0) return 0.0;

        $leftTotal = array_sum($leftWeights);
        $rightTotal = array_sum($rightWeights);
        $union = $leftTotal + $rightTotal - $intersection;
        $jaccard = $union > 0.0 ? $intersection / $union : 0.0;
        $coverage = $intersection / max(1.0, min($leftTotal, $rightTotal));

        return (0.65 * $jaccard) + (0.35 * $coverage);
    }

    /** @param list<string> $tokens @return array<string,float> */
    private function tokenWeights(array $tokens): array
    {
        $weights = [];
        foreach ($tokens as $token) {
            $weights[$token] = (float) max(2, min(12, $this->unicodeLength($token)));
        }
        return $weights;
    }

    /** @return list<int> */
    private function parseTagIds(mixed $value): array
    {
        if (is_array($value)) {
            return array_values(array_unique(array_filter(array_map('intval', $value), static fn(int $id): bool => $id > 0)));
        }
        if (!is_string($value) || trim($value) === '') return [];
        return array_values(array_unique(array_filter(array_map('intval', explode(',', $value)), static fn(int $id): bool => $id > 0)));
    }

    private function normalizeLocale(?string $locale): ?string
    {
        $locale = strtolower(str_replace('_', '-', trim((string) $locale)));
        return $locale === '' ? null : $locale;
    }

    private function unicodeLength(string $value): int
    {
        if (function_exists('mb_strlen')) return mb_strlen($value, 'UTF-8');
        $count = preg_match_all('/./us', $value, $matches);
        return $count === false ? strlen($value) : $count;
    }
}
