<?php

declare(strict_types=1);



class FeedParser
{
    public const CANONICAL_KEYS = [
        'guid', 'title', 'description', 'video_url', 'embed_url', 'thumbnail',
        'duration', 'published_at', 'categories', 'tags', 'performers', 'channels', 'views',
        'images', 'media_type',
    ];

    private const UA = 'Mozilla/5.0 (compatible; TubePressFeedBot/1.0; +https://tubepress.io)';

    

    public static function isSafePublicUrl(string $url): bool
    {
        $p = parse_url($url);
        if (!$p || !in_array($p['scheme'] ?? '', ['http', 'https'], true)) {
            return false;
        }
        $host = $p['host'] ?? '';
        if ($host === '') {
            return false;
        }
        $ips = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : (gethostbynamel($host) ?: []);
        if ($ips === []) {
            return false;
        }
        foreach ($ips as $ip) {
            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return false;
            }
        }
        return true;
    }

     
    public static function fetchUrl(string $url, int $timeout = 30, int $maxBytes = 16777216): array
    {
        $url = trim($url);
         
         
        for ($hop = 0; $hop < 6; $hop++) {
            if ($url === '' || !preg_match('#^https?://#i', $url)) {
                return ['ok' => false, 'error' => 'Invalid URL (must start with http:// or https://)', 'body' => '', 'contentType' => ''];
            }
            if (!self::isSafePublicUrl($url)) {
                return ['ok' => false, 'error' => 'Blocked: the URL host resolves to a private or reserved address', 'body' => '', 'contentType' => ''];
            }
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_TIMEOUT        => $timeout,
                CURLOPT_CONNECTTIMEOUT => 15,
                CURLOPT_USERAGENT      => self::UA,
                CURLOPT_ENCODING       => '',
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_PROTOCOLS      => CURLPROTO_HTTP | CURLPROTO_HTTPS,
                CURLOPT_NOPROGRESS     => false,
                CURLOPT_PROGRESSFUNCTION => function ($ch, $dlTotal, $dlNow) use ($maxBytes) {
                    return ($dlNow > $maxBytes) ? 1 : 0;
                },
            ]);
            $body = curl_exec($ch);
            $err  = curl_error($ch);
            $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $ct   = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
            $loc  = (string) curl_getinfo($ch, CURLINFO_REDIRECT_URL);
            curl_close($ch);

            if ($code >= 300 && $code < 400 && $loc !== '') {
                $url = $loc;  
                continue;
            }
            if ($body === false || $body === '') {
                return ['ok' => false, 'error' => $err !== '' ? $err : ('Empty response (HTTP ' . $code . ')'), 'body' => '', 'contentType' => $ct];
            }
            if ($code >= 400) {
                return ['ok' => false, 'error' => 'HTTP ' . $code, 'body' => (string) $body, 'contentType' => $ct];
            }
            return ['ok' => true, 'error' => '', 'body' => (string) $body, 'contentType' => $ct];
        }
        return ['ok' => false, 'error' => 'Too many redirects', 'body' => '', 'contentType' => ''];
    }

     
    public static function detectType(string $content, string $contentType = ''): string
    {
        $trimmed = ltrim($content);
        $head    = substr($trimmed, 0, 4096);
        $ctLower = strtolower($contentType);

        if ($trimmed !== '' && ($trimmed[0] === '[' || $trimmed[0] === '{')) {
            json_decode($trimmed);
            if (json_last_error() === JSON_ERROR_NONE) {
                return 'json';
            }
        }
        if (str_contains($ctLower, 'json')) {
            return 'json';
        }
        if (str_starts_with($head, '<?xml') || str_contains($head, '<rss') || str_contains($head, '<feed') || str_contains($head, '<channel')) {
            return (str_contains($content, 'media:') || str_contains($content, 'yahoo.com/mrss')) ? 'mrss' : 'rss';
        }
        if (str_contains($ctLower, 'csv') || str_contains($ctLower, 'text/plain')) {
            return 'csv';
        }
         
        $firstLine = strtok($trimmed, "\n");
        if ($firstLine !== false && preg_match('/[^\n,;\t|]+[,;\t|]/', $firstLine)) {
            return 'csv';
        }
        if (str_starts_with($head, '<')) {
            return str_contains($content, 'media:') ? 'mrss' : 'rss';
        }
        return 'unknown';
    }

    public static function parse(string $content, string $type): array
    {
        return match ($type) {
            'mrss', 'rss' => self::parseXml($content),
            'csv'         => self::parseCsv($content),
            'json'        => self::parseJson($content),
            default       => self::parseXml($content),
        };
    }

     
    public static function parsePaged(string $url, string $type, string $pageParam, int $maxPages, int $stopAfter = 0): array
    {
        $allRows = [];
        $columns = self::CANONICAL_KEYS;
        $canonical = true;
        $errors = [];
        $maxPages = max(1, min(50, $maxPages));

        for ($page = 1; $page <= $maxPages; $page++) {
            $pageUrl = ($maxPages === 1 && !str_contains($url, '{page}')) ? $url : self::buildPageUrl($url, $pageParam, $page);
            $res = self::fetchUrl($pageUrl);
            if (!$res['ok']) {
                $errors[] = "Page $page: " . $res['error'];
                break;
            }
            $t = ($type === 'auto' || $type === '') ? self::detectType($res['body'], $res['contentType']) : $type;
            $parsed = self::parse($res['body'], $t);
            if (empty($parsed['rows'])) {
                break;
            }
            $allRows   = array_merge($allRows, $parsed['rows']);
            $columns   = $parsed['columns'];
            $canonical = $parsed['canonical'];
            if ($stopAfter > 0 && count($allRows) >= $stopAfter) {
                $allRows = array_slice($allRows, 0, $stopAfter);
                break;
            }
        }
        return ['rows' => $allRows, 'columns' => $columns, 'canonical' => $canonical, 'errors' => $errors];
    }

    public static function buildPageUrl(string $url, string $pageParam, int $page): string
    {
        if (str_contains($url, '{page}')) {
            return str_replace('{page}', (string) $page, $url);
        }
        $param = $pageParam !== '' ? $pageParam : 'page';
        $sep = str_contains($url, '?') ? '&' : '?';
        return $url . $sep . rawurlencode($param) . '=' . $page;
    }

     
    public static function defaultMapping(): array
    {
        return [
            'title'        => 'title',
            'description'  => 'description',
            'thumbnail'    => 'thumbnail',
            'duration'     => 'duration',
            'published_at' => 'published_at',
            'views'        => 'views',
            'video_id'     => 'guid',
            'categories'   => 'categories',
            'tags'         => 'tags',
            'performers'   => 'performers',
            'channels'      => 'channels',
        ];
    }

     

    private static function parseXml(string $content): array
    {
        $prev = libxml_use_internal_errors(true);
        $xml = simplexml_load_string($content, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET);
        libxml_clear_errors();
        libxml_use_internal_errors($prev);

        if ($xml === false) {
            return ['rows' => [], 'columns' => self::CANONICAL_KEYS, 'canonical' => true];
        }

        $ns       = $xml->getNamespaces(true);
        $mediaNs  = $ns['media'] ?? 'http://search.yahoo.com/mrss/';
        $contentNs = $ns['content'] ?? 'http://purl.org/rss/1.0/modules/content/';

        $items = [];
        if (isset($xml->channel->item)) {
            $items = $xml->channel->item;
        } elseif (isset($xml->entry)) {
            $items = $xml->entry;           
        } elseif (isset($xml->item)) {
            $items = $xml->item;            
        }

        $rows = [];
        foreach ($items as $item) {
            $media = $item->children($mediaNs);

            $title = self::t($item->title);
            if ($title === '' && isset($media->title)) $title = self::t($media->title);

            $desc = self::t($item->description);
            if ($desc === '' && isset($media->description)) $desc = self::t($media->description);
            if ($desc === '') {
                $cn = $item->children($contentNs);
                if (isset($cn->encoded)) $desc = self::t($cn->encoded);
            }
            if ($desc === '' && isset($item->summary)) $desc = self::t($item->summary);  

             
            $guid = self::t($item->guid);
            $link = self::t($item->link);
            if ($link === '' && isset($item->link['href'])) $link = (string) $item->link['href'];  
            if ($guid === '' && isset($item->id)) $guid = self::t($item->id);  
            if ($guid === '') $guid = $link;

             
            $best     = self::pickBestMedia($media, $mediaNs);
            $videoUrl = $best['url'];
            $duration = $best['duration'];

             
            if ($videoUrl === '' && isset($item->enclosure['url'])) {
                $encType = strtolower((string) ($item->enclosure['type'] ?? ''));
                if ($encType === '' || str_contains($encType, 'video') || str_contains($encType, 'mp4')) {
                    $videoUrl = (string) $item->enclosure['url'];
                }
            }

             
            $thumb = self::firstMediaThumb($media, $mediaNs);

             
             
            $images = self::collectMediaImages($media, $mediaNs);
            $mediaType = (!empty($images) && $videoUrl === '') ? 'album' : '';

             
            $embed = '';
            if (isset($media->player)) { $p = self::attr($media->player, 'url'); if ($p !== '') $embed = $p; }
            if ($embed === '') $embed = $link;

             
            $pub = self::t($item->pubDate);
            if ($pub === '' && isset($item->published)) $pub = self::t($item->published);  
            if ($pub === '' && isset($item->updated))   $pub = self::t($item->updated);

             
            $cats = [];
            foreach ($item->category as $c) { $v = trim((string) $c); if ($v !== '') $cats[] = $v; }
            foreach ($media->category as $mc) { $v = trim((string) $mc); if ($v !== '') $cats[] = $v; }
            $keywords = isset($media->keywords) ? self::t($media->keywords) : '';

             
            $views = 0;
            if (isset($media->community)) {
                $stats = $media->community->children($mediaNs)->statistics ?? null;
                if ($stats !== null) $views = (int) self::attr($stats, 'views');
            }

            $title = trim(html_entity_decode($title, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
            if ($title === '' && $videoUrl === '' && $embed === '') {
                continue;
            }
            if ($guid === '') $guid = md5($title . '|' . $videoUrl . '|' . $embed);

            $rows[] = [
                'guid'         => mb_substr($guid, 0, 255),
                'title'        => $title,
                'description'  => $desc,
                'video_url'    => $videoUrl,
                'embed_url'    => $embed,
                'thumbnail'    => $thumb,
                'duration'     => $duration,
                'published_at' => $pub,
                'categories'   => implode(', ', array_values(array_unique($cats))),
                'tags'         => $keywords,
                'performers'   => '',
                'channels'      => '',
                'views'        => $views,
                'images'       => implode("\n", $images),
                'media_type'   => $mediaType,
            ];
        }

        return ['rows' => $rows, 'columns' => self::CANONICAL_KEYS, 'canonical' => true];
    }

     
    private static function collectMediaImages($media, string $mediaNs): array
    {
        $out = [];
        $scan = function ($nodes) use (&$out) {
            foreach ($nodes as $c) {
                $url = self::attr($c, 'url');
                if ($url === '') continue;
                $medium = strtolower(self::attr($c, 'medium'));
                $type   = strtolower(self::attr($c, 'type'));
                $isImage = $medium === 'image' || str_contains($type, 'image') || (bool) preg_match('/\.(jpe?g|png|webp|gif|avif)(\?|$)/i', $url);
                if ($isImage && !in_array($url, $out, true)) $out[] = $url;
            }
        };
        if (isset($media->content)) $scan($media->content);
        if (isset($media->group)) {
            $g = $media->group->children($mediaNs);
            if (isset($g->content)) $scan($g->content);
        }
        return $out;
    }

    private static function pickBestMedia($media, string $mediaNs): array
    {
        $candidates = [];
        $collect = function ($nodes) use (&$candidates) {
            foreach ($nodes as $c) {
                $url = self::attr($c, 'url');
                if ($url === '') continue;
                $medium = strtolower(self::attr($c, 'medium'));
                $type   = strtolower(self::attr($c, 'type'));
                $looksVideo = str_contains($type, 'video') || (bool) preg_match('/\.(mp4|m3u8|webm|mov|m4v)(\?|$)/i', $url);
                if ($medium === 'image' || $medium === 'audio') continue;
                if (!$looksVideo && $medium !== 'video' && $medium !== '') continue;
                $candidates[] = [
                    'url'      => $url,
                    'height'   => (int) self::attr($c, 'height'),
                    'bitrate'  => (int) self::attr($c, 'bitrate'),
                    'duration' => (int) self::attr($c, 'duration'),
                    'isVideo'  => ($looksVideo || $medium === 'video') ? 1 : 0,
                ];
            }
        };
        if (isset($media->content)) $collect($media->content);
        if (isset($media->group)) {
            $g = $media->group->children($mediaNs);
            if (isset($g->content)) $collect($g->content);
        }
        if (empty($candidates)) {
            return ['url' => '', 'duration' => 0];
        }
        usort($candidates, function ($a, $b) {
            if ($a['isVideo'] !== $b['isVideo']) return $b['isVideo'] <=> $a['isVideo'];
            if ($a['height']  !== $b['height'])  return $b['height']  <=> $a['height'];
            return $b['bitrate'] <=> $a['bitrate'];
        });
        $dur = 0;
        foreach ($candidates as $c) { if ($c['duration'] > 0) { $dur = $c['duration']; break; } }
        return ['url' => $candidates[0]['url'], 'duration' => $dur];
    }

    private static function firstMediaThumb($media, string $mediaNs): string
    {
        if (isset($media->thumbnail)) { $u = self::attr($media->thumbnail, 'url'); if ($u !== '') return $u; }
        if (isset($media->group)) {
            $g = $media->group->children($mediaNs);
            if (isset($g->thumbnail)) { $u = self::attr($g->thumbnail, 'url'); if ($u !== '') return $u; }
            if (isset($g->content)) {
                foreach ($g->content as $c) {
                    $cm = $c->children($mediaNs);
                    if (isset($cm->thumbnail)) { $u = self::attr($cm->thumbnail, 'url'); if ($u !== '') return $u; }
                }
            }
        }
        if (isset($media->content)) {
            foreach ($media->content as $c) {
                $cm = $c->children($mediaNs);
                if (isset($cm->thumbnail)) { $u = self::attr($cm->thumbnail, 'url'); if ($u !== '') return $u; }
            }
        }
        return '';
    }

    private static function t($node): string
    {
        return $node === null ? '' : trim((string) $node);
    }

    

    private static function attr($node, string $name): string
    {
        if ($node === null) return '';
        $a = $node->attributes();
        if ($a !== null && isset($a[$name])) return (string) $a[$name];
        return isset($node[$name]) ? (string) $node[$name] : '';
    }

     

    private static function parseCsv(string $content): array
    {
        $content = preg_replace('/^\xEF\xBB\xBF/', '', $content);  
        $firstLine = strtok($content, "\r\n") ?: '';
        $delim = self::sniffDelimiter($firstLine);

        $fh = fopen('php://temp', 'r+');
        fwrite($fh, $content);
        rewind($fh);

        $headers = [];
        $rows = [];
        while (($fields = fgetcsv($fh, 0, $delim, '"', '\\')) !== false) {
            if ($fields === null || ($fields[0] === null && count($fields) === 1)) continue;
            if (empty($headers)) {
                $headers = array_map(fn($h) => trim((string) $h), $fields);
                continue;
            }
            $row = [];
            foreach ($headers as $i => $h) {
                if ($h === '') continue;
                $row[$h] = isset($fields[$i]) ? (string) $fields[$i] : '';
            }
            $rows[] = $row;
        }
        fclose($fh);

        return ['rows' => $rows, 'columns' => array_values(array_filter($headers, fn($h) => $h !== '')), 'canonical' => false];
    }

    private static function sniffDelimiter(string $line): string
    {
        $counts = [
            ','  => substr_count($line, ','),
            ';'  => substr_count($line, ';'),
            "\t" => substr_count($line, "\t"),
            '|'  => substr_count($line, '|'),
        ];
        arsort($counts);
        $best = array_key_first($counts);
        return $counts[$best] > 0 ? $best : ',';
    }

     

    private static function parseJson(string $content): array
    {
        $data = json_decode($content, true);
        if (!is_array($data)) {
            return ['rows' => [], 'columns' => [], 'canonical' => false];
        }
        $list = $data;
        if (!array_is_list($data)) {
            $found = null;
            foreach (['videos', 'items', 'data', 'results', 'entries', 'list'] as $k) {
                if (isset($data[$k]) && is_array($data[$k])) { $found = $data[$k]; break; }
            }
            $list = ($found !== null) ? $found : [$data];
        }

        $rows = [];
        $cols = [];
        foreach ($list as $r) {
            if (!is_array($r)) continue;
            $flat = [];
            foreach ($r as $k => $v) {
                $key = (string) $k;
                if (is_scalar($v)) {
                    $flat[$key] = (string) $v;
                } elseif (is_array($v)) {
                    $flat[$key] = implode(', ', array_filter(array_map(
                        fn($x) => is_scalar($x) ? (string) $x : '',
                        $v
                    ), fn($x) => $x !== ''));
                } else {
                    $flat[$key] = '';
                }
                $cols[$key] = true;
            }
            $rows[] = $flat;
        }
        return ['rows' => $rows, 'columns' => array_keys($cols), 'canonical' => false];
    }
}
