<?php

namespace App\Services;

class ProductBM25Scorer
{
    private const STOP_WORDS = [
        'the', 'and', 'for', 'are', 'this', 'that', 'with', 'have',
        'what', 'how', 'much', 'want', 'need', 'get', 'can', 'please',
        'product', 'products', 'item', 'items', 'buy', 'purchase', 'price',
        'prices', 'pricing', 'cost', 'rate', 'available', 'stock', 'show',
        'give', 'tell', 'know', 'about', 'share', 'provide', 'send',
        'ki', 'kiki', 'ache', 'ase', 'ace', 'acche', 'dam', 'koto', 'taka',
        'dao', 'den', 'din', 'details', 'detail', 'info', 'information',
        'ea', 'er', 'ar', 'ra', 'karo', 'koren', 'karon', 'cilck', 'click',
        'দাম', 'কত', 'আছে', 'চাই', 'দিন', 'বলুন', 'জানতে', 'এর', 'টা', 'টি',
        'কি', 'কী', 'কিকি', 'পণ্য', 'পন্য', 'পূর্ণ', 'প্রোডাক্ট', 'প্রাইস',
        'যে', 'তোমাকে', 'করতে', 'পারো', 'খুঁজতে', 'খুঁজে', 'করা', 'দেখতে',
    ];

    /**
     * BM25-inspired relevance score for a product item against search tokens.
     *
     * Scoring hierarchy:
     *   Name token match    → +10 + charLen×2  (character-weighted)
     *   Position bonus      → +8 if token starts the name
     *   Sub-category match  → +6 + charLen
     *   Parent-cat match    → +4 + charLen/2
     *   Coverage bonus      → quadratic up to +40 (rewards matching MORE tokens)
     *
     * Accepts items with either LEFT JOIN attributes (_cat_name, _parent_cat_name)
     * or eager-loaded category.parent relations — whichever is present.
     */
    public static function score(object $item, array $tokens): int
    {
        $name      = mb_strtolower((string) ($item->name ?? ''));
        $catName   = mb_strtolower((string) (
            $item->_cat_name
            ?? optional($item->category ?? null)->name
            ?? ''
        ));
        $parentCat = mb_strtolower((string) (
            $item->_parent_cat_name
            ?? optional(optional($item->category ?? null)->parent)->name
            ?? ''
        ));
        $brandName = mb_strtolower((string) (
            $item->_brand_name
            ?? optional(optional($item->ecommerce_item_details ?? null)->brand)->name
            ?? ''
        ));
        $description = self::searchableText($item->description ?? '');
        $translations = self::searchableText($item->translations ?? '');

        $score   = 0;
        $total   = count($tokens);

        foreach ($tokens as $token) {
            if ($token === '') {
                continue;
            }

            $tLen     = mb_strlen($token);
            $inName   = str_contains($name, $token);
            $inCat    = str_contains($catName, $token);
            $inParent = str_contains($parentCat, $token);
            $inBrand  = str_contains($brandName, $token);
            $inText   = str_contains($description, $token) || str_contains($translations, $token);

            if ($inName) {
                $score += 10 + $tLen * 2;
                if (str_starts_with($name, $token)) {
                    $score += 8;
                }
            } elseif (self::fuzzyTokenMatchesField($token, $name)) {
                $score += 8 + $tLen;
            }
            if ($inBrand && !$inName) {
                $score += 9 + $tLen * 2;
            } elseif (!$inName && self::fuzzyTokenMatchesField($token, $brandName)) {
                $score += 7 + $tLen;
            }
            if ($inCat && !$inName) {
                $score += 6 + $tLen;
            } elseif (!$inName && self::fuzzyTokenMatchesField($token, $catName)) {
                $score += 5 + $tLen;
            }
            if ($inParent && !$inName && !$inCat) {
                $score += 4 + (int) ($tLen / 2);
            } elseif (!$inName && !$inCat && self::fuzzyTokenMatchesField($token, $parentCat)) {
                $score += 4;
            }
            if ($inText && !$inName && !$inCat && !$inParent && !$inBrand) {
                $score += 2 + (int) floor($tLen / 2);
            }
        }

        $matched = self::countCoveredTokens($tokens, [$name, $catName, $parentCat, $brandName, $translations, $description]);

        // Quadratic coverage bonus: 4/4 tokens >> 2/4 >> 1/4
        if ($total > 0) {
            $coverage = $matched / $total;
            $score   += (int) ($coverage * $coverage * 40);
        }

        return $score;
    }

    public static function passesCoverageGate(object $item, array $tokens): bool
    {
        $tokens = self::prepareTokens($tokens);
        $total = count($tokens);
        if ($total === 0) {
            return false;
        }

        $fields = [
            mb_strtolower((string) ($item->name ?? '')),
            mb_strtolower((string) ($item->_cat_name ?? optional($item->category ?? null)->name ?? '')),
            mb_strtolower((string) ($item->_parent_cat_name ?? optional(optional($item->category ?? null)->parent)->name ?? '')),
            mb_strtolower((string) ($item->_brand_name ?? optional(optional($item->ecommerce_item_details ?? null)->brand)->name ?? '')),
            self::searchableText($item->translations ?? ''),
            self::searchableText($item->description ?? ''),
        ];

        $matched = self::countCoveredTokens($tokens, $fields);
        if ($total === 1) {
            return $matched >= 1;
        }

        $required = $total === 2 ? 2 : (int) ceil($total * 0.75);
        return $matched >= $required;
    }

    private static function countCoveredTokens(array $tokens, array $fields): int
    {
        $matched = 0;

        foreach (self::prepareTokens($tokens) as $token) {
            foreach ($fields as $field) {
                if (str_contains($field, $token) || self::fuzzyTokenMatchesField($token, $field)) {
                    $matched++;
                    break;
                }
            }
        }

        return $matched;
    }

    private static function searchableText(mixed $value): string
    {
        if (is_string($value)) {
            return mb_strtolower(trim(strip_tags($value)));
        }

        if ($value instanceof \Illuminate\Support\Collection) {
            $value = $value->all();
        }

        if (is_array($value)) {
            $parts = [];
            array_walk_recursive($value, static function ($item) use (&$parts) {
                if (is_scalar($item)) {
                    $parts[] = (string) $item;
                }
            });

            return mb_strtolower(trim(strip_tags(implode(' ', $parts))));
        }

        return '';
    }

    private static function fuzzyTokenMatchesField(string $token, string $field): bool
    {
        if ($token === '' || $field === '' || mb_strlen($token) < 3) {
            return false;
        }

        foreach (explode(' ', $field) as $word) {
            $word = trim($word);
            if ($word === '') {
                continue;
            }

            if ($word === $token || str_contains($word, $token) || str_contains($token, $word)) {
                return true;
            }

            $tokenLength = mb_strlen($token);
            $wordLength = mb_strlen($word);
            if (abs($tokenLength - $wordLength) > max(2, (int) floor($tokenLength * 0.35))) {
                continue;
            }

            $distance = levenshtein($token, $word);
            $allowedDistance = $tokenLength <= 4 ? 1 : max(1, (int) floor($tokenLength * 0.28));
            if ($distance <= $allowedDistance) {
                return true;
            }

            similar_text($token, $word, $percent);
            if ($percent >= 82) {
                return true;
            }
        }

        return false;
    }

    /**
     * Sort a Collection or array of items by BM25 relevance, best-first.
     */
    public static function sort(iterable $items, string $searchQuery): array
    {
        $tokens = self::tokenize($searchQuery);
        if (empty($tokens)) {
            return [];
        }

        $scored = [];
        foreach ($items as $item) {
            $score = self::score($item, $tokens);
            if ($score <= 0 || !self::passesCoverageGate($item, $tokens)) {
                continue;
            }

            $scored[] = ['item' => $item, 'score' => $score];
        }

        usort($scored, static fn ($a, $b) => $b['score'] - $a['score']);

        return array_column($scored, 'item');
    }

    /**
     * Lowercase-split a query string into non-empty tokens.
     */
    public static function tokenize(string $query): array
    {
        $query = preg_replace('/\bp\s*\.?\s*c\b/u', ' pressure cooker ', mb_strtolower($query));
        $query = preg_replace('/[^\p{L}\p{M}\p{N}\s]+/u', ' ', (string) $query);
        $query = preg_replace('/\s+/u', ' ', trim((string) $query));

        return self::prepareTokens(explode(' ', $query));
    }

    private static function prepareTokens(array $tokens): array
    {
        return array_values(array_unique(array_filter(
            array_map(
                static function ($t) {
                    $token = preg_replace('/[^\p{L}\p{M}\p{N}]+/u', '', mb_strtolower((string) $t));
                    $token = str_starts_with((string) $token, '0p') ? 'op' . mb_substr((string) $token, 2) : $token;

                    return $token;
                },
                $tokens
            ),
            static fn ($t) => $t !== '' && mb_strlen($t) >= 2 && !in_array($t, self::STOP_WORDS, true)
        )));
    }
}
