<?php

namespace App\Services;

use App\Support\PublicHttp;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class ProductPageScraper
{
    public const LISTING_TIMEOUT_SECONDS = 30;

    public const PRODUCT_TIMEOUT_SECONDS = 30;

    /** Maximum HTML bytes sampled for visible text extraction after head/json-ld/meta are captured. */
    public const MAX_HTML_BYTES_FOR_TEXT = 2_097_152;

    /** Maximum characters returned to downstream AI/scraping callers. */
    public const MAX_PAGE_TEXT_CHARS = 20_000;

    /** HTML prefix scanned for DOM image tags on very large pages. JSON-LD/meta use the full HTML. */
    private const MAX_HTML_BYTES_FOR_IMAGES = 3_145_728;

    public function fetchHtml(string $url, int $timeout = self::PRODUCT_TIMEOUT_SECONDS): string
    {
        return $this->tryFetchHtml($url, $timeout)['html'];
    }

    /**
     * @return array{html: string, timed_out: bool}
     */
    public function tryFetchHtml(string $url, int $timeout = self::PRODUCT_TIMEOUT_SECONDS): array
    {
        try {
            return [
                'html' => (string) $this->request($url, $timeout)->body(),
                'timed_out' => false,
            ];
        } catch (ConnectionException $e) {
            Log::warning('Catalog page fetch timed out', [
                'url' => $url,
                'timeout' => $timeout,
                'error' => $e->getMessage(),
            ]);

            return [
                'html' => '',
                'timed_out' => true,
            ];
        }
    }

    public function fetchLiveHtml(string $url): ?string
    {
        try {
            $response = $this->request($url, self::PRODUCT_TIMEOUT_SECONDS);
        } catch (ConnectionException $e) {
            Log::warning('Catalog product page fetch timed out', [
                'url' => $url,
                'timeout' => self::PRODUCT_TIMEOUT_SECONDS,
                'error' => $e->getMessage(),
            ]);

            return null;
        }

        if (! $response->successful()) {
            return null;
        }

        $html = (string) $response->body();

        if ($this->pageText($html) === '') {
            return null;
        }

        return $html;
    }

    public function pageText(string $html): string
    {
        $structured = array_values(array_filter([
            $this->extractDocumentTitle($html),
            $this->extractMetaText($html),
            $this->extractJsonLdText($html),
        ]));

        $visibleBudget = self::MAX_PAGE_TEXT_CHARS;

        if ($structured !== []) {
            $visibleBudget -= strlen(implode("\n\n", $structured)) + 2;
        }

        $visible = $this->limitVisibleText(
            $this->extractVisibleText($html),
            max(1_000, $visibleBudget),
        );

        $sections = [...$structured, ...array_filter([$visible])];

        if ($sections === []) {
            return '';
        }

        return $this->squishText(implode("\n\n", $sections));
    }

    private function squishText(string $text): string
    {
        $text = trim($text);

        if ($text === '') {
            return '';
        }

        return preg_replace('/\s+/u', ' ', $text) ?? '';
    }

    public function primaryImageUrl(string $html, string $pageUrl): ?string
    {
        foreach ($this->jsonLdImages($html) as $imageUrl) {
            $absolute = $this->absoluteUrl(html_entity_decode($imageUrl), $pageUrl);

            if (! filled($absolute) || $this->isDecorativeImage($absolute)) {
                continue;
            }

            if ($this->listingImageMatchesProduct($absolute, $pageUrl)) {
                return $absolute;
            }
        }

        $truncatedHtml = $this->htmlForImageExtraction($html);
        $tokens = $this->productTokensFromUrl($pageUrl);
        $bestUrl = null;
        $bestScore = PHP_INT_MIN;

        foreach ($this->imageCandidates($html, $truncatedHtml) as $candidate) {
            $absolute = $this->absoluteUrl(html_entity_decode($candidate['url']), $pageUrl);

            if (! filled($absolute) || $this->isDecorativeImage($absolute)) {
                continue;
            }

            $score = $this->scoreImageCandidate(
                $absolute,
                $candidate['hint'],
                $candidate['source'],
                $tokens,
                $candidate['width'] ?? 0,
                $candidate['height'] ?? 0,
            );

            if ($score > $bestScore) {
                $bestScore = $score;
                $bestUrl = $absolute;
            }
        }

        return $bestUrl;
    }

    /**
     * @return array{price: string, list_price: ?string}|null
     */
    public function productOfferFromHtml(string $html): ?array
    {
        foreach ($this->jsonLdProductNodes($html) as $node) {
            if (! isset($node['offers']) || ! is_array($node['offers'])) {
                continue;
            }

            $offers = array_is_list($node['offers']) ? $node['offers'] : [$node['offers']];

            foreach ($offers as $offer) {
                if (! is_array($offer)) {
                    continue;
                }

                $price = $offer['price'] ?? $offer['lowPrice'] ?? null;

                if (! is_string($price) && ! is_numeric($price)) {
                    continue;
                }

                $currency = is_string($offer['priceCurrency'] ?? null)
                    ? strtoupper($offer['priceCurrency'])
                    : 'USD';
                $formatted = $currency === 'USD'
                    ? '$'.number_format((float) $price, 2, '.', '')
                    : trim("{$currency} {$price}");

                $listPrice = null;
                $highPrice = $offer['highPrice'] ?? null;

                if (is_string($highPrice) || is_numeric($highPrice)) {
                    $listPrice = $currency === 'USD'
                        ? '$'.number_format((float) $highPrice, 2, '.', '')
                        : trim("{$currency} {$highPrice}");
                }

                return [
                    'price' => $formatted,
                    'list_price' => $listPrice !== $formatted ? $listPrice : null,
                ];
            }
        }

        return null;
    }

    /**
     * @return array{rating: ?string, review_count: ?string}|null
     */
    public function productRatingsFromHtml(string $html): ?array
    {
        foreach ($this->jsonLdProductNodes($html) as $node) {
            if (! isset($node['aggregateRating']) || ! is_array($node['aggregateRating'])) {
                continue;
            }

            $ratings = $this->normalizeRatings(
                $node['aggregateRating']['ratingValue'] ?? null,
                $node['aggregateRating']['reviewCount'] ?? ($node['aggregateRating']['ratingCount'] ?? null),
            );

            if ($ratings !== null) {
                return $ratings;
            }
        }

        if (preg_match('/aria-label="([\d.]+)\s+out of 5 stars\.\s*([\d,]+)\s+reviews?/i', $html, $match) === 1) {
            return $this->normalizeRatings($match[1], $match[2]);
        }

        if (preg_match('/class="od-reviews-count-number"[^>]*>\(([\d,]+)\)</', $html, $match) === 1) {
            return $this->normalizeRatings(null, $match[1]);
        }

        return null;
    }

    /**
     * @return array{rating: ?string, review_count: ?string}|null
     */
    private function normalizeRatings(mixed $rating, mixed $reviewCount): ?array
    {
        $normalizedRating = null;
        $normalizedReviewCount = null;

        if (is_string($rating) || is_numeric($rating)) {
            $ratingValue = trim((string) $rating);

            if ($ratingValue !== '' && is_numeric($ratingValue)) {
                $normalizedRating = rtrim(rtrim(number_format((float) $ratingValue, 1, '.', ''), '0'), '.');
            }
        }

        if (is_string($reviewCount) || is_numeric($reviewCount)) {
            $digits = preg_replace('/[^\d]/', '', (string) $reviewCount) ?: '';

            if ($digits !== '') {
                $normalizedReviewCount = $digits;
            }
        }

        if ($normalizedRating === null && $normalizedReviewCount === null) {
            return null;
        }

        return [
            'rating' => $normalizedRating,
            'review_count' => $normalizedReviewCount,
        ];
    }

    public function productIdFromUrl(string $productUrl): ?string
    {
        if (preg_match('#/a/products/(\d+)/#i', $productUrl, $matches) === 1) {
            return $matches[1];
        }

        if (preg_match('#/product/prd-(\d+)/#i', $productUrl, $matches) === 1) {
            return $matches[1];
        }

        if (preg_match('#/jump/product/.+/(\d+)(?:\?|$)#i', $productUrl, $matches) === 1) {
            return $matches[1];
        }

        if (preg_match('#/(?:dp|gp/product)/([A-Z0-9]{10})#i', $productUrl, $matches) === 1) {
            return strtoupper($matches[1]);
        }

        return null;
    }

    public function listingImageMatchesProduct(string $imageUrl, string $productUrl): bool
    {
        $productId = $this->productIdFromUrl($productUrl);

        if ($productId === null || ! filled($imageUrl) || $this->isDecorativeImage($imageUrl)) {
            return false;
        }

        $lower = strtolower($imageUrl);

        if (str_contains($lower, '/products/'.$productId.'/')
            || str_contains($lower, '/products/'.$productId.'_')
            || preg_match('#/products/'.$productId.'(/|_|\\?|$)#i', $imageUrl) === 1) {
            return true;
        }

        if (preg_match('#/(?:is/image/kohls/|kohlsimg\.com/is/image/kohls/)'.$productId.'(?:[_/?]|$)#i', $imageUrl) === 1) {
            return true;
        }

        if (preg_match('#prd\['.$productId.'(?:_|]|,)#i', $imageUrl) === 1) {
            return true;
        }

        if (preg_match('#\b'.$productId.'(?:[_/?]|$)#', $imageUrl) === 1
            && (str_contains($lower, 'kohls') || str_contains($lower, 'marshalls') || str_contains($lower, 'tjmaxx'))) {
            return true;
        }

        if (preg_match('#/(?:dp|gp/product)/([A-Z0-9]{10})#i', $productUrl) === 1
            && (str_contains($lower, 'media-amazon.com')
                || str_contains($lower, 'images-amazon.com')
                || str_contains($lower, 'ssl-images-amazon.com'))) {
            return true;
        }

        return false;
    }

    public function isDecorativeImage(string $url): bool
    {
        $path = strtolower((string) (parse_url($url, PHP_URL_PATH) ?: $url));

        if (str_ends_with($path, '.ico')) {
            return true;
        }

        $filename = strtolower((string) pathinfo($path, PATHINFO_FILENAME));
        $segments = array_values(array_filter(explode('/', trim($path, '/'))));

        foreach (['header', 'footer', 'icon', 'icons'] as $segment) {
            if (in_array($segment, $segments, true)) {
                return true;
            }
        }

        if (in_array($filename, ['question', 'close', 'search', 'cart', 'menu', 'help'], true)) {
            return true;
        }

        foreach (['logo', 'brand', 'favicon', 'sprite', 'pixel', 'spacer', 'blank', 'tracking', 'placeholder', 'badge', '1x1'] as $word) {
            if (in_array($word, $segments, true)) {
                return true;
            }

            if (preg_match('/^'.preg_quote($word, '/').'(?:[._-]|$)/i', $filename) === 1) {
                return true;
            }

            if (preg_match('/(?:^|[._-])'.preg_quote($word, '/').'(?:[._-]|$)/i', $filename) === 1) {
                return true;
            }
        }

        if (in_array('assets', $segments, true) && preg_match('/logo|brand|badge|promo|banner/i', $filename) === 1) {
            return true;
        }

        if (preg_match('#/creative/#i', $path) === 1 && ! preg_match('#/products/\d+#i', $path)) {
            return true;
        }

        return false;
    }

    /**
     * Heuristic check for pages that are effectively empty to us: JS-rendered
     * shells with no server-rendered content, or bot-protection challenge
     * pages (Cloudflare/Akamai/etc) that return HTTP 200 with no real content.
     */
    public function looksBlockedOrEmpty(string $html): bool
    {
        $text = $this->pageText($html);

        if (Str::length($text) < 200) {
            return true;
        }

        return (bool) preg_match(
            '/(checking your browser|enable javascript|cf-browser-verification|cf-chl|captcha|access denied|are you a human|just a moment)/i',
            $text,
        );
    }

    private function extractDocumentTitle(string $html): string
    {
        if (preg_match('/<title[^>]*>(.*?)<\/title>/is', $html, $match) !== 1) {
            return '';
        }

        return trim(html_entity_decode(strip_tags($match[1]), ENT_QUOTES | ENT_HTML5));
    }

    private function extractMetaText(string $html): string
    {
        $values = [];

        foreach ([
            '/<meta[^>]+(?:property|name)=["\'](?:description|og:description|twitter:description)["\'][^>]+content=["\']([^"\']+)/i',
            '/<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\'](?:description|og:description|twitter:description)["\']/i',
            '/<meta[^>]+(?:property|name)=["\'](?:og:title|twitter:title)["\'][^>]+content=["\']([^"\']+)/i',
            '/<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\'](?:og:title|twitter:title)["\']/i',
        ] as $pattern) {
            if (preg_match_all($pattern, $html, $matches) === 0) {
                continue;
            }

            foreach ($matches[1] as $value) {
                $value = trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5));

                if ($value !== '') {
                    $values[] = $value;
                }
            }
        }

        return implode("\n", array_values(array_unique($values)));
    }

    private function extractJsonLdText(string $html): string
    {
        if (preg_match_all('/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html, $blocks) === 0) {
            return '';
        }

        $lines = [];

        foreach ($blocks[1] as $block) {
            $json = json_decode(trim($block), true);
            $lines = [...$lines, ...$this->textLinesFromJsonLd($json)];
        }

        return implode("\n", array_values(array_unique(array_filter($lines))));
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function jsonLdProductNodes(string $html): array
    {
        if (preg_match_all('/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html, $blocks) === 0) {
            return [];
        }

        $products = [];

        foreach ($blocks[1] as $block) {
            $json = json_decode(trim($block), true);

            if (! is_array($json)) {
                continue;
            }

            $nodes = array_is_list($json)
                ? $json
                : (isset($json['@graph']) && is_array($json['@graph']) ? $json['@graph'] : [$json]);

            foreach ($nodes as $node) {
                if (! is_array($node)) {
                    continue;
                }

                $type = $node['@type'] ?? null;
                $isProduct = $type === 'Product'
                    || (is_array($type) && in_array('Product', $type, true));

                if ($isProduct) {
                    $products[] = $node;
                }
            }
        }

        return $products;
    }

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

        $nodes = array_is_list($json)
            ? $json
            : (isset($json['@graph']) && is_array($json['@graph']) ? $json['@graph'] : [$json]);

        $lines = [];

        foreach ($nodes as $node) {
            if (! is_array($node)) {
                continue;
            }

            $type = $node['@type'] ?? null;
            $isProduct = $type === 'Product'
                || (is_array($type) && in_array('Product', $type, true));

            if (! $isProduct) {
                continue;
            }

            foreach (['name', 'description', 'sku', 'gtin', 'mpn', 'category'] as $key) {
                if (filled($node[$key] ?? null) && is_string($node[$key])) {
                    $lines[] = $node[$key];
                }
            }

            if (isset($node['brand']) && is_array($node['brand']) && filled($node['brand']['name'] ?? null)) {
                $lines[] = (string) $node['brand']['name'];
            } elseif (isset($node['brand']) && is_string($node['brand'])) {
                $lines[] = $node['brand'];
            }

            if (isset($node['offers']) && is_array($node['offers'])) {
                $offers = array_is_list($node['offers']) ? $node['offers'] : [$node['offers']];

                foreach ($offers as $offer) {
                    if (! is_array($offer)) {
                        continue;
                    }

                    foreach (['price', 'priceCurrency', 'availability', 'itemCondition'] as $key) {
                        if (filled($offer[$key] ?? null) && (is_string($offer[$key]) || is_numeric($offer[$key]))) {
                            $lines[] = (string) $offer[$key];
                        }
                    }
                }
            }

            if (isset($node['aggregateRating']) && is_array($node['aggregateRating'])) {
                foreach (['ratingValue', 'reviewCount', 'ratingCount'] as $key) {
                    if (filled($node['aggregateRating'][$key] ?? null)) {
                        $lines[] = (string) $node['aggregateRating'][$key];
                    }
                }
            }
        }

        return $lines;
    }

    private function extractVisibleText(string $html): string
    {
        $html = $this->removeHeavyBlocks($html);
        $html = $this->sampleHtmlForText($html);

        return trim(strip_tags($html));
    }

    private function removeHeavyBlocks(string $html): string
    {
        foreach (['script', 'style', 'noscript', 'template', 'svg'] as $tag) {
            $open = '<'.$tag;
            $close = '</'.$tag.'>';
            $offset = 0;

            while (($start = stripos($html, $open, $offset)) !== false) {
                $end = stripos($html, $close, $start);

                if ($end === false) {
                    break;
                }

                $html = substr($html, 0, $start).' '.substr($html, $end + strlen($close));
                $offset = max(0, $start);
            }
        }

        return $html;
    }

    private function sampleHtmlForText(string $html): string
    {
        if (strlen($html) <= self::MAX_HTML_BYTES_FOR_TEXT) {
            return $html;
        }

        $headEnd = stripos($html, '</head>');
        $head = $headEnd !== false ? substr($html, 0, $headEnd + 7) : '';
        $body = $headEnd !== false ? substr($html, $headEnd + 7) : $html;
        $bodyLength = strlen($body);
        $budget = self::MAX_HTML_BYTES_FOR_TEXT - strlen($head);
        $chunk = max(80_000, (int) floor($budget / 3));
        $samples = [];

        foreach ([0.0, 0.33, 0.66] as $ratio) {
            $start = (int) floor(max(0, ($bodyLength - $chunk) * $ratio));
            $samples[] = substr($body, $start, $chunk);
        }

        return $head.implode("\n...\n", $samples);
    }

    private function htmlForImageExtraction(string $html): string
    {
        if (strlen($html) <= self::MAX_HTML_BYTES_FOR_IMAGES) {
            return $html;
        }

        $headEnd = stripos($html, '</head>');
        $head = $headEnd !== false ? substr($html, 0, $headEnd + 7) : '';
        $body = $headEnd !== false ? substr($html, $headEnd + 7) : $html;
        $remaining = self::MAX_HTML_BYTES_FOR_IMAGES - strlen($head);

        return $head.substr($body, 0, max(0, $remaining));
    }

    private function limitVisibleText(string $text, int $maxChars): string
    {
        if (strlen($text) <= $maxChars) {
            return $text;
        }

        $chunk = max(500, (int) floor($maxChars / 3));
        $samples = [];

        foreach ([0.0, 0.33, 0.66] as $ratio) {
            $start = (int) floor(max(0, (strlen($text) - $chunk) * $ratio));
            $samples[] = substr($text, $start, $chunk);
        }

        return trim(implode("\n...\n", $samples));
    }

    private function request(string $url, int $timeout): Response
    {
        // The same-host and public-address checks the callers make are all
        // pre-redirect, and this is the one place the request is actually
        // built, so the hop guard belongs here rather than in each of them.
        return PublicHttp::send(
            Http::connectTimeout(5)
                ->timeout($timeout)
                ->withHeaders(PublicHttp::browserHeaders()),
            $url,
        );
    }

    /**
     * @return list<array{url: string, source: string, hint: string, width: int, height: int}>
     */
    private function imageCandidates(string $fullHtml, ?string $truncatedHtml = null): array
    {
        $domHtml = $truncatedHtml ?? $fullHtml;

        return [
            ...$this->asCandidates($this->jsonLdImages($fullHtml), 'jsonld'),
            ...$this->asCandidates($this->metaImages($fullHtml), 'meta'),
            ...$this->asCandidates($this->itempropImages($domHtml), 'itemprop'),
            ...$this->imgTagImages($domHtml),
        ];
    }

    /**
     * @param  list<string>  $urls
     * @return list<array{url: string, source: string, hint: string, width: int, height: int}>
     */
    private function asCandidates(array $urls, string $source): array
    {
        $candidates = [];

        foreach (array_values(array_unique(array_filter($urls))) as $url) {
            $candidates[] = [
                'url' => $url,
                'source' => $source,
                'hint' => '',
                'width' => 0,
                'height' => 0,
            ];
        }

        return $candidates;
    }

    /**
     * @return list<string>
     */
    private function jsonLdImages(string $html): array
    {
        $images = [];

        if (preg_match_all('/<script[^>]*type=["\']application\/ld\+json["\'][^>]*>(.*?)<\/script>/is', $html, $blocks) === 0) {
            return $images;
        }

        foreach ($blocks[1] as $block) {
            $json = json_decode(trim($block), true);
            $images = [...$images, ...$this->imagesFromJsonLd($json)];
        }

        return $images;
    }

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

        if (array_is_list($json)) {
            $nodes = $json;
        } elseif (isset($json['@graph']) && is_array($json['@graph'])) {
            $nodes = $json['@graph'];
        } else {
            $nodes = [$json];
        }

        $images = [];

        foreach ($nodes as $node) {
            if (! is_array($node)) {
                continue;
            }

            $type = $node['@type'] ?? null;
            $isProduct = $type === 'Product' || (is_array($type) && in_array('Product', $type, true));

            if (! $isProduct || ! isset($node['image'])) {
                continue;
            }

            foreach ($this->urlsFromJsonLdImage($node['image']) as $url) {
                $images[] = $url;
            }
        }

        return $images;
    }

    /**
     * @return list<string>
     */
    private function urlsFromJsonLdImage(mixed $image): array
    {
        if (is_string($image)) {
            return $this->isImageUrlCandidate($image) ? [$image] : [];
        }

        if (! is_array($image)) {
            return [];
        }

        $urls = [];

        foreach (['url', 'contentUrl'] as $key) {
            if (isset($image[$key]) && is_string($image[$key]) && $this->isImageUrlCandidate($image[$key])) {
                $urls[] = $image[$key];
            }
        }

        if ($urls !== []) {
            return array_values(array_unique($urls));
        }

        foreach ($image as $key => $entry) {
            if (is_string($key) && str_starts_with($key, '@')) {
                continue;
            }

            $urls = [...$urls, ...$this->urlsFromJsonLdImage($entry)];
        }

        return array_values(array_unique($urls));
    }

    private function isImageUrlCandidate(string $value): bool
    {
        $value = trim($value);

        if ($value === '' || str_starts_with($value, 'data:')) {
            return false;
        }

        if (str_starts_with($value, '//') || str_starts_with($value, '/') || preg_match('#^https?://#i', $value) === 1) {
            return true;
        }

        return str_contains($value, '/')
            || preg_match('/\.(jpe?g|png|gif|webp|avif|svg)(?:\?.*)?$/i', $value) === 1;
    }

    /**
     * @return list<string>
     */
    private function metaImages(string $html): array
    {
        $images = [];

        if (preg_match_all('/<meta[^>]+(?:property|name)=["\'](?:og:image(?::url)?|twitter:image)["\'][^>]+content=["\']([^"\']+)/i', $html, $matches)) {
            $images = [...$images, ...$matches[1]];
        }

        if (preg_match_all('/<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\'](?:og:image(?::url)?|twitter:image)["\']/i', $html, $matches)) {
            $images = [...$images, ...$matches[1]];
        }

        return $images;
    }

    /**
     * @return list<string>
     */
    private function itempropImages(string $html): array
    {
        $images = [];

        if (preg_match_all('/itemprop=["\']image["\'][^>]+(?:content|src)=["\']([^"\']+)/i', $html, $matches)) {
            $images = [...$images, ...$matches[1]];
        }

        if (preg_match_all('/(?:content|src)=["\']([^"\']+)["\'][^>]+itemprop=["\']image["\']/i', $html, $matches)) {
            $images = [...$images, ...$matches[1]];
        }

        return $images;
    }

    /**
     * @return list<array{url: string, source: string, hint: string, width: int, height: int}>
     */
    private function imgTagImages(string $html): array
    {
        $images = [];

        if (preg_match_all('/<img\b[^>]*>/i', $html, $tags) === 0) {
            return $images;
        }

        foreach ($tags[0] as $tag) {
            $url = $this->bestImageUrlFromTag($tag);

            if (! filled($url)) {
                continue;
            }

            $hint = $this->attributeValue($tag, 'class') ?? '';
            $hint .= ' '.($this->attributeValue($tag, 'id') ?? '');

            $images[] = [
                'url' => $url,
                'source' => 'img',
                'hint' => trim($hint),
                'width' => $this->intAttribute($tag, 'width'),
                'height' => $this->intAttribute($tag, 'height'),
            ];
        }

        return $images;
    }

    private function bestImageUrlFromTag(string $tag): ?string
    {
        foreach (['data-zoom-image', 'data-zoom-src', 'data-large-image', 'data-large', 'data-old-hires', 'data-zoom'] as $attribute) {
            $value = $this->attributeValue($tag, $attribute);

            if (filled($value) && $this->isImageUrlCandidate($value)) {
                return $value;
            }
        }

        $fallback = $this->firstFilledImageAttribute($tag, ['data-src', 'src']);

        foreach (['data-srcset', 'srcset'] as $attribute) {
            $srcset = $this->attributeValue($tag, $attribute);

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

            $largest = $this->largestSrcsetUrl($srcset);

            if (! filled($largest) || ! $this->isImageUrlCandidate($largest)) {
                continue;
            }

            if ($fallback !== null && $this->srcsetUrlLooksTruncated($largest, $fallback)) {
                continue;
            }

            return $largest;
        }

        return $fallback;
    }

    /**
     * @param  list<string>  $names
     */
    private function firstFilledImageAttribute(string $tag, array $names): ?string
    {
        foreach ($names as $name) {
            $value = $this->attributeValue($tag, $name);

            if (filled($value) && $this->isImageUrlCandidate($value)) {
                return $value;
            }
        }

        return null;
    }

    private function srcsetUrlLooksTruncated(string $srcsetUrl, string $src): bool
    {
        return str_starts_with($src, $srcsetUrl) && strlen($src) > strlen($srcsetUrl);
    }

    private function largestSrcsetUrl(string $srcset): ?string
    {
        $bestUrl = null;
        $bestSize = -1;
        $parts = preg_split('/,(?=\s*(?:https?:)?\/\/|\/)/i', $srcset) ?: [];

        foreach ($parts as $part) {
            $part = trim($part);

            if ($part === '') {
                continue;
            }

            $bits = preg_split('/\s+/', $part) ?: [];
            $url = $bits[0] ?? '';
            $descriptor = $bits[1] ?? '1x';
            $size = 1;

            if (str_ends_with($descriptor, 'w')) {
                $size = (int) $descriptor;
            } elseif (str_ends_with($descriptor, 'x')) {
                $size = (int) round((float) $descriptor * 1000);
            }

            if ($url !== '' && $size >= $bestSize) {
                $bestSize = $size;
                $bestUrl = $url;
            }
        }

        return $bestUrl;
    }

    private function attributeValue(string $tag, string $name): ?string
    {
        if (preg_match('/\b'.preg_quote($name, '/').'=["\']([^"\']*)/i', $tag, $match) !== 1) {
            return null;
        }

        $value = trim(html_entity_decode($match[1]));

        return $value === '' ? null : $value;
    }

    private function intAttribute(string $tag, string $name): int
    {
        $value = $this->attributeValue($tag, $name);

        if ($value === null || ! is_numeric($value)) {
            return 0;
        }

        return max(0, (int) $value);
    }

    /**
     * @return list<string>
     */
    private function productTokensFromUrl(string $pageUrl): array
    {
        $tokens = [];
        $path = strtolower((string) (parse_url($pageUrl, PHP_URL_PATH) ?: ''));
        parse_str((string) (parse_url($pageUrl, PHP_URL_QUERY) ?: ''), $query);

        foreach (['sku', 'pid', 'id', 'product_id', 'productid', 'item', 'itemid', 'asin'] as $key) {
            $value = $query[$key] ?? null;

            if (is_string($value) && $value !== '') {
                $tokens[] = strtolower($value);
            }
        }

        $skipSegments = [
            'a', 'p', 'dp', 'gp', 'ip', 'sku', 'en', 'us', 'uk', 'ca', 'www', 'shop', 'store',
            'product', 'products', 'item', 'items', 'category', 'categories', 'collection',
            'collections', 'search', 'pages', 'catalog', 'b', 'aw', 'd', 'pd',
        ];

        foreach (array_filter(explode('/', $path)) as $segment) {
            $segment = strtolower(urldecode($segment));

            if ($segment === '' || in_array($segment, $skipSegments, true)) {
                continue;
            }

            if (preg_match_all('/\d{4,}/', $segment, $numbers) > 0) {
                foreach ($numbers[0] as $number) {
                    $tokens[] = $number;
                }
            }

            if (preg_match('/^b0[a-z0-9]{8}$/i', $segment) === 1) {
                $tokens[] = $segment;
            }

            foreach (preg_split('/[-_.]+/', $segment) ?: [] as $word) {
                if ($this->isUsefulSlugWord($word)) {
                    $tokens[] = $word;
                }
            }
        }

        return array_values(array_unique($tokens));
    }

    private function isUsefulSlugWord(string $word): bool
    {
        if (Str::length($word) < 5 || is_numeric($word)) {
            return false;
        }

        return ! in_array($word, [
            'brand', 'official', 'default', 'image', 'images', 'html', 'index', 'home',
            'color', 'size', 'pack', 'count', 'with', 'from', 'this', 'that', 'product',
            'products', 'item', 'items', 'shop', 'store', 'letter',
        ], true);
    }

    /**
     * @param  list<string>  $tokens
     */
    private function scoreImageCandidate(string $url, string $hint, string $source, array $tokens, int $width = 0, int $height = 0): int
    {
        $path = strtolower((string) (parse_url($url, PHP_URL_PATH) ?: $url));
        $score = 0;
        $ids = [];
        $words = [];

        foreach ($tokens as $token) {
            if (preg_match('/^\d{4,}$/', $token) === 1 || preg_match('/^b0[a-z0-9]{8}$/i', $token) === 1) {
                $ids[] = $token;

                continue;
            }

            $words[] = $token;
        }

        $idMatched = false;

        foreach ($ids as $id) {
            if ($this->pathContainsToken($path, $id)) {
                $idMatched = true;
                $score += 80;
                break;
            }
        }

        $matchedWords = 0;

        foreach ($words as $word) {
            if ($this->pathContainsToken($path, $word)) {
                $matchedWords++;
            }
        }

        if ($matchedWords >= 2) {
            $score += 25;
        } elseif ($matchedWords === 1) {
            $score += 8;
        }

        if (preg_match('#/(products?|product[-_]?images?|pdp|media/catalog|cdn/shop)/#i', $path) === 1) {
            $score += 20;
        }

        if (! $idMatched && preg_match('#/(assets|logos?|banners?|icons?)/#i', $path) === 1) {
            $score -= 45;
        }

        if ($this->looksLikeSmallTile($path)) {
            $score -= 40;
        }

        if ($width > 0 && $height > 0) {
            $area = $width * $height;

            if ($area < 2304) {
                $score -= 50;
            } elseif (min($width, $height) >= 300) {
                $score += 15;
            }
        }

        if (preg_match('/\.(jpe?g|webp|avif)(?:\?|$)/i', $path) === 1) {
            $score += 10;
        } elseif (preg_match('/\.png(?:\?|$)/i', $path) === 1 && $score < 50) {
            $score -= 5;
        }

        $score += match ($source) {
            'jsonld' => 8,
            'itemprop' => 6,
            'img' => 4,
            'meta' => 2,
            default => 0,
        };

        $hint = strtolower($hint);

        if (preg_match('/gallery|product[-_]?image|pdp|zoom|hero[-_]?image|main[-_]?image|primary/i', $hint) === 1) {
            $score += 25;
        }

        if (preg_match('/thumb|icon|badge|sprite|logo|swatch/i', $hint) === 1) {
            $score -= 30;
        }

        return $score;
    }

    private function looksLikeSmallTile(string $path): bool
    {
        if (preg_match('/(?<!\d)(\d{2,4})x(\d{2,4})(?!\d)/i', $path, $match) !== 1) {
            return false;
        }

        $area = (int) $match[1] * (int) $match[2];

        return $area > 0 && $area < 120000;
    }

    private function pathContainsToken(string $path, string $token): bool
    {
        return preg_match(
            '/(?:^|[\/._-])'.preg_quote($token, '/').'(?:[\/._-]|$)/i',
            $path,
        ) === 1;
    }

    private function absoluteUrl(string $src, string $baseUrl): ?string
    {
        $src = trim($src);

        if ($src === '' || str_starts_with($src, 'data:')) {
            return null;
        }

        if (str_starts_with($src, '//')) {
            return 'https:'.$src;
        }

        if (preg_match('#^https?://#i', $src) === 1) {
            return $src;
        }

        $parts = parse_url($baseUrl);

        if ($parts === false || empty($parts['host'])) {
            return $src;
        }

        $origin = ($parts['scheme'] ?? 'https').'://'.$parts['host'];

        if (! empty($parts['port'])) {
            $origin .= ':'.$parts['port'];
        }

        if (str_starts_with($src, '/')) {
            return $origin.$src;
        }

        $directory = rtrim(str_replace('\\', '/', dirname($parts['path'] ?? '/')), '/');

        if ($directory === '.' || $directory === '') {
            $directory = '';
        }

        return $origin.$directory.'/'.$src;
    }
}
