<?php
declare(strict_types=1);

/**
 * Image scraper — fetches a product page and extracts the best image URL.
 * Priority ladder (highest → lowest):
 *   1. og:image
 *   2. twitter:image
 *   3. img[data-zoom-image]
 *   4. img[data-large-image]
 *   5. img[data-image] / img[data-src]
 *   6. srcset (largest descriptor)
 *   7. CSS-selector fallbacks (main img, .product-image img, etc.)
 */
class Scraper
{
    private int    $timeoutS;
    private string $userAgent;
    private int    $maxRetries;
    private int    $delayMs;

    public function __construct(
        int    $timeoutS   = DEFAULT_TIMEOUT,
        string $userAgent  = DEFAULT_USER_AGENT,
        int    $maxRetries = DEFAULT_MAX_RETRIES,
        int    $delayMs    = DEFAULT_DELAY_MS
    ) {
        $this->timeoutS   = $timeoutS;
        $this->userAgent  = $userAgent ?: DEFAULT_USER_AGENT;
        $this->maxRetries = $maxRetries;
        $this->delayMs    = $delayMs;
    }

    /**
     * Main entry — returns ['image_url' => string|null, 'fallback_level' => int, 'error' => string|null]
     */
    public function extract(string $productUrl): array
    {
        $attempt = 0;
        $lastError = null;

        while ($attempt < $this->maxRetries) {
            $attempt++;
            if ($attempt > 1) {
                usleep((int)(pow(2, $attempt - 1) * 500_000)); // exponential backoff
            }

            [$html, $error, $finalUrl] = $this->fetch($productUrl);
            if ($html === null) {
                $lastError = $error;
                continue;
            }

            $result = $this->parseImage($html, $finalUrl);
            if ($result['image_url'] !== null) {
                return array_merge($result, ['error' => null]);
            }
            $lastError = 'No image found on page';
        }

        return ['image_url' => null, 'fallback_level' => 0, 'error' => $lastError];
    }

    // --- Fetcher ---

    private function fetch(string $url): array
    {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS      => MAX_REDIRECTS,
            CURLOPT_TIMEOUT        => $this->timeoutS,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_USERAGENT      => $this->userAgent,
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => false,
            CURLOPT_HTTPHEADER     => [
                'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
                'Accept-Language: en-US,en;q=0.5',
                'Accept-Encoding: gzip, deflate, br',
                'Connection: keep-alive',
                'Upgrade-Insecure-Requests: 1',
                'Cache-Control: max-age=0',
            ],
            CURLOPT_ENCODING       => '',
        ]);

        $body     = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $finalUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
        $curlErr  = curl_error($ch);
        curl_close($ch);

        if ($body === false || $curlErr) {
            return [null, "cURL error: $curlErr", $url];
        }
        if ($httpCode === 0 || $httpCode >= 500) {
            return [null, "HTTP $httpCode", $url];
        }
        if ($httpCode === 403) {
            return [null, "HTTP 403 Forbidden (bot protection)", $url];
        }
        if ($httpCode === 404) {
            return [null, "HTTP 404 Not Found", $url];
        }
        return [$body, null, $finalUrl ?: $url];
    }

    // --- Image parser ---

    private function parseImage(string $html, string $pageUrl): array
    {
        $dom = new DOMDocument();
        libxml_use_internal_errors(true);
        @$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
        libxml_clear_errors();
        $xpath = new DOMXPath($dom);

        // Level 1: og:image
        $nodes = $xpath->query('//meta[@property="og:image"]/@content');
        if ($nodes && $nodes->length > 0) {
            $url = $this->normalize($nodes->item(0)->nodeValue, $pageUrl);
            if ($url) return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 1];
        }

        // Level 2: twitter:image
        foreach (['//meta[@name="twitter:image"]/@content', '//meta[@name="twitter:image:src"]/@content'] as $q) {
            $nodes = $xpath->query($q);
            if ($nodes && $nodes->length > 0) {
                $url = $this->normalize($nodes->item(0)->nodeValue, $pageUrl);
                if ($url) return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 2];
            }
        }

        // Level 3: data-zoom-image
        $nodes = $xpath->query('//*[@data-zoom-image]');
        if ($nodes && $nodes->length > 0) {
            $url = $this->normalize($nodes->item(0)->getAttribute('data-zoom-image'), $pageUrl);
            if ($url) return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 3];
        }

        // Level 4: data-large-image / data-full-size / data-original
        foreach (['data-large-image', 'data-full-size', 'data-original', 'data-hi-res-image'] as $attr) {
            $nodes = $xpath->query("//*[@{$attr}]");
            if ($nodes && $nodes->length > 0) {
                $url = $this->normalize($nodes->item(0)->getAttribute($attr), $pageUrl);
                if ($url) return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 4];
            }
        }

        // Level 5: data-image / data-src on img
        foreach (['data-image', 'data-src', 'data-lazy-src', 'data-lazyload'] as $attr) {
            $nodes = $xpath->query("//img[@{$attr}]");
            if ($nodes && $nodes->length > 0) {
                $url = $this->normalize($nodes->item(0)->getAttribute($attr), $pageUrl);
                if ($url && !str_contains($url, 'placeholder') && !str_contains($url, 'blank')) {
                    return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 5];
                }
            }
        }

        // Level 6: srcset — pick largest
        $nodes = $xpath->query('//img[@srcset]');
        if ($nodes && $nodes->length > 0) {
            $best = $this->parseSrcset($nodes->item(0)->getAttribute('srcset'), $pageUrl);
            if ($best) return ['image_url' => $this->upgradeResolution($best), 'fallback_level' => 6];
        }

        // Level 7: CSS-selector-style XPath fallbacks
        $selectors = [
            '//img[contains(@class,"product-image")]',
            '//img[contains(@class,"main-image")]',
            '//img[contains(@class,"primary-image")]',
            '//img[contains(@id,"product-image")]',
            '//img[contains(@id,"main-image")]',
            '//*[contains(@class,"product-image")]//img',
            '//*[contains(@class,"product-gallery")]//img',
            '//*[contains(@class,"gallery")]//img',
            '//*[@id="main-product-image"]',
            '//main//img',
            '//article//img',
            '//img[not(contains(@src,"logo")) and not(contains(@src,"icon")) and not(contains(@src,"placeholder"))]',
        ];

        foreach ($selectors as $i => $sel) {
            $nodes = $xpath->query($sel);
            if ($nodes && $nodes->length > 0) {
                $el  = $nodes->item(0);
                $src = $el->getAttribute('src') ?: $el->getAttribute('data-src');
                $url = $this->normalize($src, $pageUrl);
                if ($url && $this->isProductImage($url)) {
                    return ['image_url' => $this->upgradeResolution($url), 'fallback_level' => 7];
                }
            }
        }

        return ['image_url' => null, 'fallback_level' => 0];
    }

    // --- Helpers ---

    /**
     * Convert relative URLs to absolute, validate.
     */
    private function normalize(string $url, string $baseUrl): ?string
    {
        $url = trim($url);
        if (empty($url) || str_starts_with($url, 'data:')) return null;

        if (str_starts_with($url, '//')) {
            $scheme = parse_url($baseUrl, PHP_URL_SCHEME) ?: 'https';
            $url    = $scheme . ':' . $url;
        } elseif (!str_starts_with($url, 'http')) {
            $parsed = parse_url($baseUrl);
            $scheme = $parsed['scheme'] ?? 'https';
            $host   = $parsed['host']   ?? '';
            if (str_starts_with($url, '/')) {
                $url = $scheme . '://' . $host . $url;
            } else {
                $path = rtrim(dirname($parsed['path'] ?? '/'), '/');
                $url  = $scheme . '://' . $host . $path . '/' . $url;
            }
        }

        return filter_var($url, FILTER_VALIDATE_URL) ? $url : null;
    }

    /**
     * Upgrade thumbnail URLs to full-resolution:
     * - Remove common size parameters (?w=200&h=200)
     * - Replace size suffixes (_200x200 → _1200x1200 or just remove)
     */
    private function upgradeResolution(string $url): string
    {
        // Shopify: add _2048x2048 or replace existing size
        if (str_contains($url, '.myshopify.com') || str_contains($url, 'cdn.shopify.com')) {
            $url = preg_replace('/_\d+x\d+(\.\w+)(\?.*)?$/', '_2048x2048$1$2', $url) ?? $url;
        }

        // WooCommerce / common pattern: remove -WxH suffix before extension
        $url = preg_replace('/-(\d+)x(\d+)(\.\w{2,5})(\?.*)?$/', '$3$4', $url) ?? $url;

        // Remove known thumbnail query params
        foreach (['width', 'height', 'w', 'h', 'size', 'resize'] as $param) {
            $url = preg_replace('/([?&])' . $param . '=\d+/', '$1', $url) ?? $url;
        }
        $url = rtrim($url, '?&');

        // CDN size variants: image_200x200.jpg → image_1200x1200.jpg
        if (preg_match('/(_)(\d+)x(\d+)(\.\w{2,5}(\?.*)?$)/', $url)) {
            $url = preg_replace('/(_)\d+x\d+(\.\w{2,5}(\?.*)?$)/', '${1}1200x1200$2', $url) ?? $url;
        }

        return $url;
    }

    /**
     * Parse srcset and return the URL with the largest width descriptor.
     */
    private function parseSrcset(string $srcset, string $baseUrl): ?string
    {
        $parts   = array_map('trim', explode(',', $srcset));
        $best    = ['url' => null, 'width' => 0];

        foreach ($parts as $part) {
            $tokens = preg_split('/\s+/', trim($part));
            if (empty($tokens[0])) continue;
            $url = $this->normalize($tokens[0], $baseUrl);
            if (!$url) continue;
            $width = 0;
            if (isset($tokens[1])) {
                $width = (int)preg_replace('/[^\d]/', '', $tokens[1]);
            }
            if ($width > $best['width']) {
                $best = ['url' => $url, 'width' => $width];
            }
        }
        return $best['url'];
    }

    /**
     * Basic heuristic to exclude navigation/logo images.
     */
    private function isProductImage(string $url): bool
    {
        $lower = strtolower($url);
        $skip  = ['logo', 'icon', 'sprite', 'banner', 'header', 'footer', 'nav', 'pixel', 'tracking', 'placeholder', 'blank.gif', 'spacer'];
        foreach ($skip as $kw) {
            if (str_contains($lower, $kw)) return false;
        }
        return true;
    }
}
