<?php

declare(strict_types=1);

namespace App\Services\DataForSeo;

use App\Enums\MatchStatus;
use App\Models\StoreProductTarget;
use Illuminate\Support\Str;

class TargetProductMatcher
{
    public function __construct(private readonly UrlNormalizer $urlNormalizer) {}

    /**
     * Match the best result from the candidates list for the given target.
     *
     * @param  list<NormalizedResult>  $results
     * @param  array<string, list<string>>  $knownSellerAliases  Map of store-slug => list of known seller aliases/names for that store.
     *                                                           Used to detect when a candidate belongs to a different store.
     *                                                           Example: ['bodega-aurrera' => ['Bodega Aurrera', 'Bodega Aurrera Despensa a tu Casa'], ...]
     */
    public function match(StoreProductTarget $target, array $results, array $knownSellerAliases = []): MatchResult
    {
        if ($results === []) {
            return new MatchResult(MatchStatus::EmptyResults, null, 0, 'DataForSEO no devolvió resultados.', []);
        }

        $deduplicatedResults = $this->deduplicateResults($results);

        $candidates = array_map(
            fn (NormalizedResult $result): MatchCandidate => $this->scoreResult($target, $result, $knownSellerAliases),
            $deduplicatedResults,
        );

        usort($candidates, static fn (MatchCandidate $left, MatchCandidate $right): int => $right->score <=> $left->score);

        $bestCandidate = $candidates[0] ?? null;

        if ($bestCandidate === null) {
            return new MatchResult(MatchStatus::NotMatched, null, 0, 'No se pudieron evaluar candidatos válidos.', []);
        }

        $minMatchScore = max((int) $target->min_match_score, (int) config('dataforseo.min_match_score'));
        $ambiguousScoreGap = (int) config('dataforseo.ambiguous_score_gap', 5);
        $secondCandidate = $candidates[1] ?? null;

        if ($bestCandidate->score < $minMatchScore) {
            return new MatchResult(
                MatchStatus::NotMatched,
                null,
                $bestCandidate->score,
                sprintf('El mejor candidato no alcanzó el score mínimo requerido (%d/%d).', $bestCandidate->score, $minMatchScore),
                array_slice($candidates, 0, 5),
            );
        }

        if ($bestCandidate->result->price === null) {
            return new MatchResult(
                MatchStatus::NotMatched,
                null,
                $bestCandidate->score,
                'El mejor candidato no incluye precio usable.',
                array_slice($candidates, 0, 5),
            );
        }

        if ($secondCandidate !== null && $secondCandidate->score >= $minMatchScore && ($bestCandidate->score - $secondCandidate->score) <= $ambiguousScoreGap) {
            return new MatchResult(
                MatchStatus::Ambiguous,
                null,
                $bestCandidate->score,
                sprintf('Hay dos candidatos con score muy cercano (%d vs %d).', $bestCandidate->score, $secondCandidate->score),
                array_slice($candidates, 0, 5),
            );
        }

        return new MatchResult(
            MatchStatus::Matched,
            $bestCandidate->result,
            $bestCandidate->score,
            implode(' | ', $bestCandidate->reasons),
            array_slice($candidates, 0, 5),
        );
    }

    /**
     * Deduplicate results to avoid marking ambiguous when two entries represent the same product
     * (e.g., google_shopping_serp and google_shopping_carousel_element for the same item).
     * Deduplication keys: data_docid, product_id, gid, or title+seller+price combination.
     * Prefers google_shopping_serp over carousel_element when both represent the same product.
     *
     * @param  list<NormalizedResult>  $results
     * @return list<NormalizedResult>
     */
    private function deduplicateResults(array $results): array
    {
        /** @var array<string, NormalizedResult> $seen */
        $seen = [];

        foreach ($results as $result) {
            $key = $this->deduplicationKey($result);

            if (! isset($seen[$key])) {
                $seen[$key] = $result;

                continue;
            }

            // Prefer google_shopping_serp over carousel_element for the same product.
            if ($result->resultType === 'google_shopping_serp' && $seen[$key]->resultType !== 'google_shopping_serp') {
                $seen[$key] = $result;
            }
        }

        return array_values($seen);
    }

    private function deduplicationKey(NormalizedResult $result): string
    {
        if ($result->dataDocid !== null) {
            return 'docid:'.$result->dataDocid;
        }

        if ($result->productId !== null) {
            return 'pid:'.$result->productId;
        }

        if ($result->gid !== null) {
            return 'gid:'.$result->gid;
        }

        // Fallback: title + seller + price combination.
        return 'tsp:'.implode('|', [
            $this->normalizeText($result->title) ?? '',
            $this->normalizeText($result->seller) ?? '',
            (string) $result->price,
        ]);
    }

    /**
     * @param  array<string, list<string>>  $knownSellerAliases
     */
    private function scoreResult(StoreProductTarget $target, NormalizedResult $result, array $knownSellerAliases): MatchCandidate
    {
        $score = 0;
        $reasons = [];

        // Build haystack from title and seller only (domain/url are scored separately).
        $haystack = Str::lower(trim(implode(' ', array_filter([
            $result->title,
            $result->seller,
        ]))));

        // ── Seller scoring (primary signal) ──────────────────────────────────
        $sellerScore = $this->scoreSellerSignal($target, $result, $knownSellerAliases, $reasons);
        $score += $sellerScore;

        // ── Identity fields from Google Shopping ─────────────────────────────
        if ($target->expected_data_docid !== null && $result->dataDocid !== null) {
            if ($result->dataDocid === $target->expected_data_docid) {
                $score += 35;
                $reasons[] = 'data_docid coincide exactamente.';
            }
        }

        if ($target->expected_product_id !== null && $result->productId !== null) {
            if ($result->productId === $target->expected_product_id) {
                $score += 30;
                $reasons[] = 'product_id coincide exactamente.';
            }
        }

        if ($target->expected_gid !== null && $result->gid !== null) {
            if ($result->gid === $target->expected_gid) {
                $score += 30;
                $reasons[] = 'gid coincide exactamente.';
            }
        }

        // ── Title matching ────────────────────────────────────────────────────
        $expectedTitle = $this->normalizeText($target->expected_title);
        $candidateTitle = $this->normalizeText($result->title);

        if ($expectedTitle !== null && $candidateTitle !== null) {
            if ($candidateTitle === $expectedTitle) {
                $score += 30;
                $reasons[] = 'Título exacto coincide.';
            } elseif (Str::contains($candidateTitle, $expectedTitle) || Str::contains($expectedTitle, $candidateTitle)) {
                $score += 18;
                $reasons[] = 'Título parcial coincide.';
            }
        }

        // ── Required keywords (confirm product) ───────────────────────────────
        $requiredKeywords = $this->normalizeKeywords($target->required_keywords);
        $requiredMatches = $this->countKeywordMatches($haystack, $requiredKeywords);

        if ($requiredKeywords !== []) {
            if ($requiredMatches === count($requiredKeywords)) {
                $score += 25;
                $reasons[] = sprintf('Todas las required_keywords coinciden (%d).', $requiredMatches);
            } elseif ($requiredMatches > 0) {
                $requiredScore = (int) round(15 * ($requiredMatches / count($requiredKeywords)));
                $score += $requiredScore;
                $reasons[] = sprintf('Coincidieron %d/%d keywords requeridas.', $requiredMatches, count($requiredKeywords));
            } else {
                $reasons[] = 'Ninguna required_keyword coincide.';
            }
        }

        // ── Optional keywords ─────────────────────────────────────────────────
        $optionalKeywords = $this->normalizeKeywords($target->optional_keywords);
        $optionalMatches = $this->countKeywordMatches($haystack, $optionalKeywords);

        if ($optionalKeywords !== [] && $optionalMatches > 0) {
            $optionalScore = min(10, $optionalMatches * 3);
            $score += $optionalScore;
            $reasons[] = sprintf('Coincidieron %d keywords opcionales.', $optionalMatches);
        }

        // ── Negative keywords ─────────────────────────────────────────────────
        $negativeKeywords = $this->normalizeKeywords($target->negative_keywords);
        $negativeMatches = $this->countKeywordMatches($haystack, $negativeKeywords);

        if ($negativeMatches > 0) {
            $negativePenalty = min(60, $negativeMatches * 20);
            $score -= $negativePenalty;
            $reasons[] = sprintf('Se detectaron %d keywords negativas (penalización -%d).', $negativeMatches, $negativePenalty);
        }

        // ── Rank/position scoring ─────────────────────────────────────────────
        // expected_rank_group vs result->rankGroup
        if ($target->expected_rank_group !== null && $result->rankGroup !== null) {
            if ($result->rankGroup === $target->expected_rank_group) {
                $score += 20;
                $reasons[] = sprintf('rank_group coincide (%d).', $result->rankGroup);
            }
        }

        // expected_position vs result->rankAbsolute (not position string like "left")
        if ($target->expected_position !== null && $result->rankAbsolute !== null) {
            $distance = abs($target->expected_position - $result->rankAbsolute);
            $positionScore = match (true) {
                $distance === 0 => 20,
                $distance === 1 => 10,
                $distance === 2 => 5,
                default => 0,
            };

            if ($positionScore > 0) {
                $score += $positionScore;
                $reasons[] = sprintf('rank_absolute %s esperado (distancia %d).', $distance === 0 ? 'coincide con' : 'cercano al', $distance);
            }
        }

        // ── URL scoring — only against directUrl, never shoppingUrl ──────────
        if ($result->directUrl !== null) {
            if ($target->expected_url !== null && $this->urlNormalizer->matchesLoosely($result->directUrl, $target->expected_url)) {
                $score += 40;
                $reasons[] = 'URL directa de tienda coincide con expected_url.';
            }

            $urlFragments = is_array($target->expected_url_contains) ? $target->expected_url_contains : [];
            $matchingFragments = $this->urlNormalizer->countMatchingFragments($result->directUrl, $urlFragments);
            $totalFragments = count(array_filter($urlFragments));

            if ($totalFragments > 0 && $matchingFragments > 0) {
                $fragmentScore = (int) round(25 * ($matchingFragments / $totalFragments));
                $score += $fragmentScore;
                $reasons[] = sprintf('Coincidieron %d/%d fragmentos de expected_url_contains.', $matchingFragments, $totalFragments);
            }
        }
        // If directUrl is null, do not penalize for missing expected_url — it's expected behavior in Merchant API.

        // ── Shopping URL fragments (against Google Shopping proxy URL) ────────
        $shoppingUrlFragments = is_array($target->expected_shopping_url_contains) ? $target->expected_shopping_url_contains : [];

        if ($shoppingUrlFragments !== [] && $result->shoppingUrl !== null) {
            $matchingShoppingFragments = 0;

            foreach ($shoppingUrlFragments as $fragment) {
                if (is_string($fragment) && $fragment !== '' && Str::contains($result->shoppingUrl, $fragment)) {
                    $matchingShoppingFragments++;
                }
            }

            if ($matchingShoppingFragments > 0) {
                $shoppingScore = (int) round(15 * ($matchingShoppingFragments / count(array_filter($shoppingUrlFragments))));
                $score += $shoppingScore;
                $reasons[] = sprintf('Coincidieron %d/%d fragmentos de expected_shopping_url_contains.', $matchingShoppingFragments, count(array_filter($shoppingUrlFragments)));
            }
        }

        // ── Domain scoring (inferred from seller when domain is null) ──────────
        $expectedDomain = $this->resolveExpectedDomain($target);
        $candidateDomain = $this->resolveResultDomain($result, $target);

        if ($expectedDomain !== null && $candidateDomain !== null) {
            if ($candidateDomain === $expectedDomain) {
                $score += 15;
                $reasons[] = 'Dominio coincide.';
            } else {
                $score -= 20;
                $reasons[] = 'Dominio no coincide.';
            }
        }

        // ── Price / currency ──────────────────────────────────────────────────
        if ($result->price === null) {
            $score -= 10;
            $reasons[] = 'No trae precio.';
        }

        if ($result->currency !== null && $result->currency !== 'MXN') {
            $score -= 10;
            $reasons[] = sprintf('Moneda inesperada: %s.', $result->currency);
        }

        return new MatchCandidate($result, $score, $reasons);
    }

    /**
     * Scores the seller signal. Returns the score delta (positive or negative).
     *
     * Rules:
     * - +45 if seller matches expected_seller or any alias of the target's store.
     * - -60 if seller clearly belongs to a different known store.
     * - -15 if expected_seller is configured but seller doesn't match anything.
     *
     * @param  array<string, list<string>>  $knownSellerAliases
     * @param  list<string>  $reasons  Mutated in-place.
     */
    private function scoreSellerSignal(StoreProductTarget $target, NormalizedResult $result, array $knownSellerAliases, array &$reasons): int
    {
        $normalizedCandidateSeller = $this->normalizeText($result->seller);

        if ($normalizedCandidateSeller === null) {
            return 0;
        }

        // Build the list of aliases for the TARGET's own store.
        $ownStoreAliases = $this->buildOwnStoreAliases($target);

        // Check if this seller matches the target's own store.
        $matchesOwnStore = $this->sellerMatchesAliases($normalizedCandidateSeller, $ownStoreAliases);

        if ($matchesOwnStore) {
            $reasons[] = 'Seller coincide con la tienda objetivo.';

            return 45;
        }

        // Check if seller belongs to another known store (reject signal).
        $storeSlug = $target->store->slug ?? '';

        foreach ($knownSellerAliases as $otherStoreSlug => $aliases) {
            if ($otherStoreSlug === $storeSlug) {
                continue; // Skip the target's own store — already checked above.
            }

            $normalizedAliases = array_values(array_filter(array_map(
                fn (mixed $alias): ?string => is_string($alias) ? $this->normalizeText($alias) : null,
                $aliases,
            )));

            if ($this->sellerMatchesAliases($normalizedCandidateSeller, $normalizedAliases)) {
                $reasons[] = sprintf('Seller "%s" pertenece a otra tienda conocida (%s).', $result->seller ?? '', $otherStoreSlug);

                return -60;
            }
        }

        // Seller doesn't match own store and doesn't belong to another known store.
        // Apply a small penalty if expected_seller is explicitly configured.
        $expectedSeller = $this->normalizeText($target->expected_seller ?? $target->store->name);

        if ($expectedSeller !== null) {
            $reasons[] = 'Seller no coincide con la tienda esperada.';

            return -15;
        }

        return 0;
    }

    /**
     * Returns normalized aliases for the target's own store (name + aliases).
     *
     * @return list<string>
     */
    private function buildOwnStoreAliases(StoreProductTarget $target): array
    {
        $ownAliases = [];

        // Always include store name.
        $storeName = $this->normalizeText($target->store->name);

        if ($storeName !== null) {
            $ownAliases[] = $storeName;
        }

        // Include expected_seller if explicitly set.
        $expectedSeller = $this->normalizeText($target->expected_seller);

        if ($expectedSeller !== null) {
            $ownAliases[] = $expectedSeller;
        }

        // Include store aliases.
        $storeAliases = is_array($target->store->aliases) ? $target->store->aliases : [];

        foreach ($storeAliases as $alias) {
            $normalizedAlias = $this->normalizeText((string) $alias);

            if ($normalizedAlias !== null) {
                $ownAliases[] = $normalizedAlias;
            }
        }

        return array_values(array_unique($ownAliases));
    }

    /**
     * Returns true if the normalized seller matches any of the given aliases
     * (substring match in either direction).
     *
     * @param  list<string>  $aliases
     */
    private function sellerMatchesAliases(string $normalizedSeller, array $aliases): bool
    {
        foreach ($aliases as $alias) {
            if ($alias !== '' && (Str::contains($normalizedSeller, $alias) || Str::contains($alias, $normalizedSeller))) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolves the domain of a result candidate.
     *
     * When the Merchant API returns `domain: null` (which is typical), it infers the domain
     * from the seller name by checking against the store's name and aliases.
     * This allows domain scoring to work even without a direct store URL.
     */
    private function resolveResultDomain(NormalizedResult $result, StoreProductTarget $target): ?string
    {
        // Use result's domain if it is a real store domain (not a Google redirect domain).
        if ($result->domain !== null && trim($result->domain) !== '' && ! $this->isGoogleDomain($result->domain)) {
            return $this->normalizeText($result->domain);
        }

        // Merchant API returns domain: null. Infer the store domain from the seller name.
        return $this->inferDomainFromSeller($result->seller, $target);
    }

    private function isGoogleDomain(?string $domain): bool
    {
        if ($domain === null) {
            return false;
        }

        return Str::contains(strtolower($domain), 'google.');
    }

    /**
     * Infers the expected store domain from a seller name by matching against the store's
     * name and its configured aliases.
     *
     * Example: "Bodega Aurrera Despensa a tu Casa" → contains "bodega aurrera" → returns "bodegaaurrera.com.mx"
     */
    private function inferDomainFromSeller(?string $seller, StoreProductTarget $target): ?string
    {
        if ($seller === null) {
            return null;
        }

        $normalizedSeller = $this->normalizeText($seller);

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

        $expectedDomain = $this->resolveExpectedDomain($target);
        $storeName = $this->normalizeText($target->store->name);

        if ($storeName !== null && (Str::contains($normalizedSeller, $storeName) || Str::contains($storeName, $normalizedSeller))) {
            return $expectedDomain;
        }

        $aliases = is_array($target->store->aliases) ? $target->store->aliases : [];

        foreach ($aliases as $alias) {
            $normalizedAlias = $this->normalizeText((string) $alias);

            if ($normalizedAlias !== null && (Str::contains($normalizedSeller, $normalizedAlias) || Str::contains($normalizedAlias, $normalizedSeller))) {
                return $expectedDomain;
            }
        }

        return null;
    }

    private function resolveExpectedDomain(StoreProductTarget $target): ?string
    {
        if (is_string($target->expected_domain) && $target->expected_domain !== '') {
            return $this->normalizeText($target->expected_domain);
        }

        $firstDomain = $target->store->domains[0] ?? null;

        return is_string($firstDomain) ? $this->normalizeText($firstDomain) : null;
    }

    private function normalizeText(?string $value): ?string
    {
        if ($value === null || trim($value) === '') {
            return null;
        }

        return Str::of($value)
            ->lower()
            ->ascii()
            ->replaceMatches('/\s+/', ' ')
            ->trim()
            ->value();
    }

    /**
     * @return list<string>
     */
    private function normalizeKeywords(mixed $keywords): array
    {
        if (! is_array($keywords)) {
            return [];
        }

        return array_values(array_filter(array_map(fn (mixed $keyword): ?string => is_string($keyword) ? $this->normalizeText($keyword) : null, $keywords)));
    }

    /**
     * @param  list<string>  $keywords
     */
    private function countKeywordMatches(string $haystack, array $keywords): int
    {
        $matches = 0;

        foreach ($keywords as $keyword) {
            if (Str::contains($haystack, $keyword)) {
                $matches++;
            }
        }

        return $matches;
    }
}
