<?php
namespace EasyChat\Library\Catalog;

use EasyChat\Library\Http\ExternalHttpClient;







class CatalogCanonicalProbe {

    const MAX_REDIRECTS = 3;
    const MAX_BODY_BYTES = 32768;

    private $http;
    private $resolver;

    public function __construct($registry = null, ?ExternalHttpClient $http = null, ?callable $resolver = null) {
        $this->http = $http ?: new ExternalHttpClient($registry);
        $this->resolver = $resolver;
    }

    public function probe(string $source_url, string $allowed_origin): array {
        $source_url = trim($source_url);
        $allowed_origin = strtolower(rtrim(trim($allowed_origin), '/'));
        if (!CatalogUrlPolicy::isSafeLink($source_url)
            || $allowed_origin === ''
            || !$this->sameOrigin($source_url, $allowed_origin)) {
            return $this->failed('url_not_allowed');
        }

        $current = $source_url;
        for ($redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++) {
            $head = $this->request('HEAD', $current);
            if (empty($head['safe'])) {
                return $this->failed((string)($head['error'] ?? 'transport_failed'));
            }

            $status = (int)($head['http_code'] ?? 0);
            $location = $this->headerFirst($head['headers'] ?? array(), 'location');
            if ($status >= 300 && $status < 400 && $location !== '') {
                if ($redirects >= self::MAX_REDIRECTS) {
                    return $this->failed('redirect_limit', $status);
                }
                $next = $this->resolveUrl($current, $location);
                if ($next === '' || !$this->sameOrigin($next, $allowed_origin)) {
                    return $this->failed('redirect_not_allowed', $status);
                }
                $current = $next;
                continue;
            }

            if ($status === 405 || $status === 501 || ($status >= 200 && $status < 300)) {
                $header_canonical = $this->canonicalFromLinkHeaders($head['headers'] ?? array(), $current);
                if ($header_canonical !== '') {
                    return $this->verified($header_canonical, $status, $allowed_origin);
                }

                $get = $this->request('GET', $current);
                if (empty($get['safe'])) {
                    return $this->failed((string)($get['error'] ?? 'transport_failed'));
                }
                $get_status = (int)($get['http_code'] ?? 0);
                $get_location = $this->headerFirst($get['headers'] ?? array(), 'location');
                if ($get_status >= 300 && $get_status < 400 && $get_location !== '') {
                    if ($redirects >= self::MAX_REDIRECTS) {
                        return $this->failed('redirect_limit', $get_status);
                    }
                    $next = $this->resolveUrl($current, $get_location);
                    if ($next === '' || !$this->sameOrigin($next, $allowed_origin)) {
                        return $this->failed('redirect_not_allowed', $get_status);
                    }
                    $current = $next;
                    continue;
                }
                if ($get_status < 200 || $get_status >= 400) {
                    return $this->failed('http_status', $get_status);
                }
                $canonical = $this->canonicalFromLinkHeaders($get['headers'] ?? array(), $current);
                if ($canonical === '') {
                    $canonical = $this->canonicalFromHtml((string)($get['body'] ?? ''), $current);
                }
                return $this->verified($canonical !== '' ? $canonical : $current, $get_status, $allowed_origin);
            }

            return $this->failed('http_status', $status);
        }

        return $this->failed('redirect_limit');
    }

    private function request(string $method, string $url): array {
        $parts = @parse_url($url);
        $host = strtolower((string)($parts['host'] ?? ''));
        $scheme = strtolower((string)($parts['scheme'] ?? ''));
        if ($host === '' || !in_array($scheme, array('http', 'https'), true)) {
            return array('safe' => false, 'error' => 'url_invalid');
        }
        $ips = $this->resolvePublicIps($host);
        if (!$ips) {
            return array('safe' => false, 'error' => 'dns_not_public');
        }

        $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'https' ? 443 : 80);
        $headers = array();
        $body = '';
        $overflow = false;
        $resolve_ip = $ips[0];
        $resolve_value = strpos($resolve_ip, ':') !== false ? '[' . $resolve_ip . ']' : $resolve_ip;
        $curl_options = array(
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_MAXREDIRS => 0,
            CURLOPT_RESOLVE => array($host . ':' . $port . ':' . $resolve_value),
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
                $length = strlen($line);
                $line = trim($line);
                if ($line === '' || strpos($line, ':') === false) {
                    return $length;
                }
                list($name, $value) = explode(':', $line, 2);
                $name = strtolower(trim($name));
                if ($name !== '') {
                    $headers[$name][] = trim($value);
                }
                return $length;
            },
        );
        if ($method === 'GET') {
            $curl_options[CURLOPT_RANGE] = '0-' . (self::MAX_BODY_BYTES - 1);
            $curl_options[CURLOPT_WRITEFUNCTION] = function ($ch, $chunk) use (&$body, &$overflow) {
                $remaining = self::MAX_BODY_BYTES - strlen($body);
                if ($remaining > 0) {
                    $body .= substr($chunk, 0, $remaining);
                }
                if (strlen($chunk) > $remaining) {
                    $overflow = true;
                }
                return strlen($chunk);
            };
        }

        $result = $this->http->request($method, $url, array(
            'connect_timeout' => 2,
            'timeout' => 3,
            'follow_location' => false,
            'allow_tls_fallback' => true,
            'trust_profile' => 'catalog_public_url',
            'headers' => array('Accept: text/html,application/xhtml+xml;q=0.9,*/*;q=0.1'),
            'curl_options' => $curl_options,
        ));

        return array(
            'safe' => !empty($result['transport_ok']),
            'error' => !empty($result['transport_ok']) ? '' : 'transport_failed',
            'http_code' => (int)($result['http_code'] ?? 0),
            'headers' => $headers,
            'body' => $body,
            'overflow' => $overflow,
        );
    }

    private function resolvePublicIps(string $host): array {
        if ($this->resolver) {
            $ips = call_user_func($this->resolver, $host);
            $ips = is_array($ips) ? $ips : array();
        } elseif (filter_var($host, FILTER_VALIDATE_IP) !== false) {
            $ips = array($host);
        } else {
            $ips = function_exists('gethostbynamel') ? (gethostbynamel($host) ?: array()) : array();
            if (function_exists('dns_get_record') && defined('DNS_AAAA')) {
                foreach ((array)@dns_get_record($host, DNS_AAAA) as $row) {
                    if (!empty($row['ipv6'])) {
                        $ips[] = $row['ipv6'];
                    }
                }
            }
        }
        $public = array();
        foreach (array_unique($ips) as $ip) {
            if (filter_var($ip, FILTER_VALIDATE_IP) === false
                || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
                 
                 
                return array();
            }
            $public[] = $ip;
        }
        return $public;
    }

    private function verified(string $url, int $status, string $origin): array {
        if (!CatalogUrlPolicy::isSafeLink($url) || !$this->sameOrigin($url, $origin)) {
            return $this->failed('canonical_not_allowed', $status);
        }
        return array('status' => 'verified', 'canonical_url' => $url, 'http_code' => $status, 'error_code' => '');
    }

    private function failed(string $code, int $status = 0): array {
        return array('status' => 'failed', 'canonical_url' => '', 'http_code' => $status, 'error_code' => $code);
    }

    private function canonicalFromLinkHeaders(array $headers, string $base): string {
        foreach ((array)($headers['link'] ?? array()) as $value) {
            if (preg_match('/<([^>]+)>\s*;[^,]*\brel\s*=\s*["\']?canonical["\']?/i', $value, $match)) {
                return $this->resolveUrl($base, html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
            }
        }
        return '';
    }

    private function canonicalFromHtml(string $html, string $base): string {
        if ($html === '') {
            return '';
        }
        if (preg_match('/<link\b[^>]*\brel\s*=\s*["\'][^"\']*\bcanonical\b[^"\']*["\'][^>]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $html, $match)
            || preg_match('/<link\b[^>]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*\brel\s*=\s*["\'][^"\']*\bcanonical\b[^"\']*["\'][^>]*>/i', $html, $match)) {
            return $this->resolveUrl($base, html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
        }
        return '';
    }

    private function headerFirst(array $headers, string $name): string {
        $values = $headers[strtolower($name)] ?? array();
        return trim((string)($values[count($values) - 1] ?? ''));
    }

    private function resolveUrl(string $base, string $target): string {
        $target = trim($target);
        if ($target === '' || preg_match('/[\x00-\x20\x7F\\\\]/u', $target)) {
            return '';
        }
        if (preg_match('#^https?://#i', $target)) {
            return CatalogUrlPolicy::isSafeLink($target) ? $target : '';
        }
        $parts = @parse_url($base);
        if (!is_array($parts)) {
            return '';
        }
        $origin = strtolower((string)$parts['scheme']) . '://' . strtolower((string)$parts['host']);
        if (isset($parts['port']) && !$this->isDefaultPort((string)$parts['scheme'], (int)$parts['port'])) {
            $origin .= ':' . (int)$parts['port'];
        }
        if (strpos($target, '//') === 0) {
            $candidate = strtolower((string)$parts['scheme']) . ':' . $target;
        } elseif ($target[0] === '?') {
            $candidate = $origin . (string)($parts['path'] ?? '/') . $target;
        } elseif ($target[0] === '#') {
            $candidate = $origin . (string)($parts['path'] ?? '/');
            if (isset($parts['query'])) {
                $candidate .= '?' . $parts['query'];
            }
            $candidate .= $target;
        } elseif ($target[0] === '/') {
            $candidate = $origin . $target;
        } else {
            $path = (string)($parts['path'] ?? '/');
            $dir = preg_replace('#/[^/]*$#', '/', $path);
            $candidate = $origin . $dir . $target;
        }
        $candidate = $this->removeDotSegments($candidate);
        return CatalogUrlPolicy::isSafeLink($candidate) ? $candidate : '';
    }

    private function removeDotSegments(string $url): string {
        $parts = @parse_url($url);
        if (!is_array($parts)) {
            return '';
        }
        $segments = array();
        foreach (explode('/', (string)($parts['path'] ?? '/')) as $segment) {
            if ($segment === '' || $segment === '.') {
                continue;
            }
            if ($segment === '..') {
                array_pop($segments);
                continue;
            }
            $segments[] = $segment;
        }
        $origin = strtolower((string)$parts['scheme']) . '://' . strtolower((string)$parts['host']);
        if (isset($parts['port']) && !$this->isDefaultPort((string)$parts['scheme'], (int)$parts['port'])) {
            $origin .= ':' . (int)$parts['port'];
        }
        $result = $origin . '/' . implode('/', $segments);
        if (substr((string)($parts['path'] ?? ''), -1) === '/') {
            $result .= '/';
        }
        if (isset($parts['query'])) {
            $result .= '?' . $parts['query'];
        }
        if (isset($parts['fragment'])) {
            $result .= '#' . $parts['fragment'];
        }
        return $result;
    }

    private function sameOrigin(string $url, string $origin): bool {
        $actual = $this->normalizedOrigin($url);
        $expected = $this->normalizedOrigin($origin);
        if ($actual === '' || $expected === '') {
            return false;
        }
        return hash_equals($expected, $actual);
    }

    private function isDefaultPort(string $scheme, int $port): bool {
        $scheme = strtolower($scheme);
        return ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80);
    }

    private function normalizedOrigin(string $value): string {
        $parts = @parse_url($value);
        if (!is_array($parts)) {
            return '';
        }
        $scheme = strtolower((string)($parts['scheme'] ?? ''));
        $host = strtolower((string)($parts['host'] ?? ''));
        if (!in_array($scheme, array('http', 'https'), true) || $host === '') {
            return '';
        }
        $port = isset($parts['port']) ? (int)$parts['port'] : ($scheme === 'https' ? 443 : 80);
        return $scheme . '://' . $host . ':' . $port;
    }
}
