<?php
declare(strict_types=1);

/**
 * /link_preview.php
 * AJAX endpoint: отримати preview для URL (OG/meta) + кеш у БД.
 *
 * GET: ?url=https://...
 * Optional: &refresh=1  (ігнорувати кеш і перетягнути заново)
 * Response: JSON { ok:true, url, title, description, image, site_name }
 */

require_once __DIR__ . '/includes/start.php';

header('Content-Type: application/json; charset=UTF-8');

try {
    $pdo = isset($pdo) && ($pdo instanceof PDO) ? $pdo : db();
} catch (Throwable $e) {
    http_response_code(500);
    echo json_encode(['ok' => false, 'err' => 'DB error'], JSON_UNESCAPED_UNICODE);
    exit;
}

$url = trim((string)($_GET['url'] ?? ''));
$url = normalize_url($url);

$forceRefresh = (string)($_GET['refresh'] ?? '') === '1';

if ($url === '') {
    http_response_code(400);
    echo json_encode(['ok' => false, 'err' => 'Bad url'], JSON_UNESCAPED_UNICODE);
    exit;
}

/* =======================
   SSRF protection helpers
======================= */
function lp_is_private_ip(string $ip): bool {
    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
        $long = ip2long($ip);
        if ($long === false) return true;
        $ranges = [
            ['0.0.0.0','0.255.255.255'],
            ['10.0.0.0','10.255.255.255'],
            ['127.0.0.0','127.255.255.255'],
            ['169.254.0.0','169.254.255.255'],
            ['172.16.0.0','172.31.255.255'],
            ['192.168.0.0','192.168.255.255'],
        ];
        foreach ($ranges as $r) {
            $a = ip2long($r[0]); $b = ip2long($r[1]);
            if ($a !== false && $b !== false && $long >= $a && $long <= $b) return true;
        }
        return false;
    }

    if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
        if ($ip === '::1') return true;
        if (str_starts_with($ip, 'fc') || str_starts_with($ip, 'fd')) return true; // unique local
        if (str_starts_with($ip, 'fe80')) return true; // link-local
        return false;
    }

    return true;
}

function lp_host_is_safe(string $host): bool {
    $host = trim($host);
    if ($host === '') return false;

    $low = strtolower($host);
    if ($low === 'localhost') return false;
    if (str_ends_with($low, '.local')) return false;

    if (filter_var($host, FILTER_VALIDATE_IP)) {
        return !lp_is_private_ip($host);
    }

    $ips = [];

    $a = @dns_get_record($host, DNS_A);
    if (is_array($a)) {
        foreach ($a as $r) {
            if (!empty($r['ip']) && is_string($r['ip'])) $ips[] = $r['ip'];
        }
    }

    $aaaa = @dns_get_record($host, DNS_AAAA);
    if (is_array($aaaa)) {
        foreach ($aaaa as $r) {
            if (!empty($r['ipv6']) && is_string($r['ipv6'])) $ips[] = $r['ipv6'];
        }
    }

    if (!$ips) return false;

    foreach ($ips as $ip) {
        if (lp_is_private_ip($ip)) return false;
    }
    return true;
}

function lp_looks_mojibake(string $text): bool {
    $text = trim($text);
    if ($text === '') return false;

    // Типові маркери битого UTF-8 у cp1251/latin1-інтерпретації
    return (bool)preg_match('/(?:Ð.|Ñ.|Â.|Ã.|ðŸ|â€”|â€œ|â€|â„¢)/u', $text);
}

function lp_fix_mojibake_text(string $text): string {
    $text = trim($text);
    if ($text === '') return '';

    if (lp_looks_mojibake($text)) {
        $try = @iconv('Windows-1252', 'UTF-8//IGNORE', $text);
        if (is_string($try) && $try !== '' && !lp_looks_mojibake($try)) {
            $text = $try;
        } else {
            $try = @mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
            if (is_string($try) && $try !== '' && !lp_looks_mojibake($try)) {
                $text = $try;
            }
        }
    }

    $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = preg_replace('~\s+~u', ' ', $text) ?? $text;
    return trim($text);
}

function lp_normalize_html_encoding(string $html): string {
    $html = trim($html);
    if ($html === '') return '';

    $enc = '';
    if (preg_match('~<meta[^>]+charset\s*=\s*["\']?\s*([a-zA-Z0-9\-_]+)~i', $html, $m)) {
        $enc = strtoupper(trim((string)$m[1]));
    } elseif (preg_match('~<meta[^>]+content\s*=\s*["\'][^"\']*charset\s*=\s*([a-zA-Z0-9\-_]+)~i', $html, $m)) {
        $enc = strtoupper(trim((string)$m[1]));
    }

    if ($enc !== '' && $enc !== 'UTF-8') {
        $converted = @mb_convert_encoding($html, 'UTF-8', $enc);
        if (is_string($converted) && $converted !== '') {
            $html = $converted;
        }
    } elseif (!mb_detect_encoding($html, 'UTF-8', true)) {
        $converted = @mb_convert_encoding($html, 'UTF-8', 'Windows-1251,CP1251,ISO-8859-1,Windows-1252,UTF-8');
        if (is_string($converted) && $converted !== '') {
            $html = $converted;
        }
    }

    // Примусово оголошуємо UTF-8 для DOMDocument
    if (!preg_match('~<meta[^>]+charset\s*=~i', $html)) {
        $html = preg_replace('~<head([^>]*)>~i', '<head$1><meta charset="UTF-8">', $html, 1) ?? $html;
    }

    return $html;
}

$u = @parse_url($url);
$host = (string)($u['host'] ?? '');
$scheme = (string)($u['scheme'] ?? 'https');

if ($host === '' || !lp_host_is_safe($host)) {
    http_response_code(400);
    echo json_encode(['ok' => false, 'err' => 'Host not allowed'], JSON_UNESCAPED_UNICODE);
    exit;
}

/* =======================
   Cache table
======================= */
try {
    if (!db_has_table($pdo, 'link_previews')) {
        $pdo->exec("
            CREATE TABLE IF NOT EXISTS link_previews (
              url_hash CHAR(40) NOT NULL PRIMARY KEY,
              url VARCHAR(2048) NOT NULL,
              title VARCHAR(512) NOT NULL DEFAULT '',
              description VARCHAR(1024) NOT NULL DEFAULT '',
              image VARCHAR(2048) NOT NULL DEFAULT '',
              site_name VARCHAR(255) NOT NULL DEFAULT '',
              fetched_at DATETIME NOT NULL,
              INDEX(url(255)),
              INDEX(fetched_at)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
        ");
    }
} catch (Throwable $e) {
    // work without cache
}

$hash = url_hash($url);
$cacheTtlSeconds = 7 * 24 * 3600;

$ua = 'VMEREGI-LinkPreview/1.1';

/**
 * Перевіряє, що image URL реально віддає картинку.
 * Повертає true якщо status 200..299 і Content-Type починається з image/
 */
function lp_image_is_alive(string $imgUrl, string $ua): bool {
    $imgUrl = trim($imgUrl);
    if ($imgUrl === '') return false;

    $ctx = stream_context_create([
        'http' => [
            'method' => 'HEAD',
            'timeout' => 3,
            'header' => "User-Agent: {$ua}\r\nAccept: image/*,*/*;q=0.8\r\n",
            'follow_location' => 1,
            'max_redirects' => 3,
        ],
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
        ],
    ]);

    $headers = @get_headers($imgUrl, true, $ctx);
    if (!is_array($headers) || !$headers) return false;

    $statusLine = $headers[0] ?? '';
    if (is_array($statusLine)) $statusLine = end($statusLine);
    $statusLine = (string)$statusLine;

    if (!preg_match('~\s(\d{3})\s~', $statusLine, $m)) return false;
    $code = (int)$m[1];
    if ($code < 200 || $code >= 300) return false;

    $ct = $headers['Content-Type'] ?? $headers['Content-type'] ?? '';
    if (is_array($ct)) $ct = end($ct);
    $ct = strtolower(trim((string)$ct));

    return ($ct !== '' && str_starts_with($ct, 'image/'));
}

/**
 * Робить абсолютний URL зображення:
 * - якщо вже http/https — ок
 * - якщо починається з // — додаємо scheme:
 * - якщо відносний — додаємо scheme://host + /
 */
function lp_make_abs_image(string $image, string $scheme, string $host): string {
    $image = trim($image);
    if ($image === '') return '';

    if (preg_match('~^https?://~i', $image)) return $image;

    if (str_starts_with($image, '//')) {
        return $scheme . ':' . $image;
    }

    if (!str_starts_with($image, '/')) $image = '/' . $image;
    return $scheme . '://' . $host . $image;
}

/* =======================
   Try cache (unless refresh=1)
======================= */
if (!$forceRefresh) {
    try {
        if (db_has_table($pdo, 'link_previews')) {
            $st = $pdo->prepare("SELECT * FROM link_previews WHERE url_hash=? LIMIT 1");
            $st->execute([$hash]);
            $row = $st->fetch(PDO::FETCH_ASSOC);

            if ($row) {
                $fetchedAt = strtotime((string)$row['fetched_at']);
                $isFresh = ($fetchedAt !== false && (time() - $fetchedAt) < $cacheTtlSeconds);

                $cachedTitle = (string)($row['title'] ?? '');
                $cachedDescription = (string)($row['description'] ?? '');
                $cachedSite = (string)($row['site_name'] ?? '');
                $cachedImage = (string)($row['image'] ?? '');

                $cacheLooksBroken = lp_looks_mojibake($cachedTitle)
                    || lp_looks_mojibake($cachedDescription)
                    || lp_looks_mojibake($cachedSite);

                // Якщо кеш свіжий, але картинка мертва або текст кривий — форсуємо перефетч
                if ($isFresh && !$cacheLooksBroken) {
                    if ($cachedImage !== '' && !lp_image_is_alive($cachedImage, $ua)) {
                        $forceRefresh = true;
                    } else {
                        echo json_encode([
                            'ok' => true,
                            'url' => (string)$row['url'],
                            'title' => lp_fix_mojibake_text($cachedTitle),
                            'description' => lp_fix_mojibake_text($cachedDescription),
                            'image' => $cachedImage,
                            'site_name' => lp_fix_mojibake_text($cachedSite),
                            'cached' => true,
                        ], JSON_UNESCAPED_UNICODE);
                        exit;
                    }
                } else {
                    $forceRefresh = true;
                }
            }
        }
    } catch (Throwable $e) {
        // ignore cache errors
    }
}

/* =======================
   Fetch HTML
======================= */
$html = '';

$ctx = stream_context_create([
    'http' => [
        'method' => 'GET',
        'timeout' => 5,
        'header' => "User-Agent: {$ua}\r\nAccept: text/html,application/xhtml+xml\r\n",
        'follow_location' => 1,
        'max_redirects' => 3,
    ],
    'ssl' => [
        'verify_peer' => true,
        'verify_peer_name' => true,
    ],
]);

$raw = @file_get_contents($url, false, $ctx);
if (is_string($raw)) {
    if (strlen($raw) > 900000) $raw = substr($raw, 0, 900000);
    $html = lp_normalize_html_encoding($raw);
}

if ($html === '') {
    http_response_code(200);
    echo json_encode([
        'ok' => true,
        'url' => $url,
        'title' => '',
        'description' => '',
        'image' => '',
        'site_name' => '',
        'cached' => false
    ], JSON_UNESCAPED_UNICODE);
    exit;
}

/* =======================
   Parse meta
======================= */
libxml_use_internal_errors(true);
$dom = new DOMDocument();
@$dom->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);

$title = '';
$description = '';
$image = '';
$site = '';

$xpath = new DOMXPath($dom);

// og:* via property=
$nodes = $xpath->query("//meta[@property]");
if ($nodes) {
    foreach ($nodes as $n) {
        $prop = strtolower(trim((string)$n->getAttribute('property')));
        $content = trim((string)$n->getAttribute('content'));
        if ($content === '') continue;

        if ($prop === 'og:title' && $title === '') $title = $content;
        if ($prop === 'og:description' && $description === '') $description = $content;
        if ($prop === 'og:image' && $image === '') $image = $content;
        if ($prop === 'og:site_name' && $site === '') $site = $content;
    }
}

// meta name=
$nodes = $xpath->query("//meta[@name]");
if ($nodes) {
    foreach ($nodes as $n) {
        $name = strtolower(trim((string)$n->getAttribute('name')));
        $content = trim((string)$n->getAttribute('content'));
        if ($content === '') continue;

        if (($name === 'description' || $name === 'twitter:description') && $description === '') $description = $content;
        if (($name === 'twitter:title') && $title === '') $title = $content;
        if (($name === 'twitter:image') && $image === '') $image = $content;
    }
}

// <title>
if ($title === '') {
    $t = $xpath->query("//title");
    if ($t && $t->length > 0) {
        $title = trim((string)$t->item(0)->textContent);
    }
}

// absolute image url if relative
if ($image !== '') {
    $image = lp_make_abs_image($image, $scheme, $host);
}

// trims + fixes + limits
$title = lp_fix_mojibake_text($title);
$description = lp_fix_mojibake_text($description);
$site = lp_fix_mojibake_text($site);

if (mb_strlen($title, 'UTF-8') > 512) $title = mb_substr($title, 0, 512, 'UTF-8');
if (mb_strlen($description, 'UTF-8') > 1024) $description = mb_substr($description, 0, 1024, 'UTF-8');
if (mb_strlen($site, 'UTF-8') > 255) $site = mb_substr($site, 0, 255, 'UTF-8');
if (strlen($image) > 2048) $image = substr($image, 0, 2048);

/* =======================
   Save cache
======================= */
try {
    if (db_has_table($pdo, 'link_previews')) {
        $st = $pdo->prepare("
            INSERT INTO link_previews (url_hash, url, title, description, image, site_name, fetched_at)
            VALUES (?,?,?,?,?,?,NOW())
            ON DUPLICATE KEY UPDATE
              url=VALUES(url),
              title=VALUES(title),
              description=VALUES(description),
              image=VALUES(image),
              site_name=VALUES(site_name),
              fetched_at=VALUES(fetched_at)
        ");
        $st->execute([$hash, $url, $title, $description, $image, $site]);
    }
} catch (Throwable $e) {
    // ignore cache write errors
}

echo json_encode([
    'ok' => true,
    'url' => $url,
    'title' => $title,
    'description' => $description,
    'image' => $image,
    'site_name' => $site,
    'cached' => false,
], JSON_UNESCAPED_UNICODE);
