<?php
/**
 * Server-side page-grid preview generator.
 *
 * The page grid only trusts a real JPEG at `impulse-previews/page-<id>.jpg`
 * (>= 500 bytes); anything else is discarded and the placeholder logo shown.
 * That file is normally written by the browser html2canvas capture when the
 * editor closes, but that capture fails silently on many pages (WebGL
 * environments, depth-mapped heroes, screen-mode sections) and every failure
 * path is a bare `return` with no error surfaced. The result is a page that can
 * never get a preview no matter how often it is edited.
 *
 * This generates the same file server-side from the page's own imagery, so a
 * missing preview always heals instead of depending on the browser. It never
 * overwrites a capture that did succeed, and it never touches the `thumbnail`
 * column (for posts that column is the author's chosen cover, read back by the
 * post editor) - the grid derives the preview URL from the file itself.
 *
 * @package Instinctor
 */

namespace Instinctor\Core;

if (!defined('ABSPATH')) exit;

class PagePreview
{
    private const W = 1200;
    private const H = 800;

    /** Candidate fonts, first that exists wins. Title is skipped when none do. */
    private const FONTS = [
        '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf',
        '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
        '/usr/share/fonts/truetype/freefont/FreeSansBold.ttf',
    ];

    public static function rel(int $pageId): string
    {
        return 'impulse-previews/page-' . $pageId . '.jpg';
    }

    public static function file(int $pageId): string
    {
        return rtrim(Storage::basedir('impulse-previews'), '/') . '/page-' . $pageId . '.jpg';
    }

    /**
     * A usable preview already on disk? A failed html2canvas capture often
     * writes a solid white 1200x800 frame — a few KB, so a plain size check
     * passes it and the grid shows a blank card forever. Only small files are
     * pixel-checked, so this stays cheap on sites with hundreds of pages
     * (real photos and title cards are far larger than the threshold).
     */
    public static function exists(int $pageId): bool
    {
        $f = self::file($pageId);
        if (!is_file($f)) return false;
        $size = filesize($f);
        if ($size < 500) return false;
        if ($size < 9000 && self::isBlank($f)) return false;
        return true;
    }

    /** True when the image is effectively one flat colour (a dead capture). */
    private static function isBlank(string $file): bool
    {
        if (!function_exists('imagecreatefromjpeg')) return false;
        $im = @imagecreatefromjpeg($file);
        if (!$im) return false;
        $w = imagesx($im); $h = imagesy($im);
        if ($w < 8 || $h < 8) { imagedestroy($im); return true; }

        $min = 255; $max = 0;
        for ($x = 0; $x < 16; $x++) {
            for ($y = 0; $y < 16; $y++) {
                $rgb = imagecolorat($im, (int)($x * ($w - 1) / 15), (int)($y * ($h - 1) / 15));
                $lum = (int) round(0.299 * (($rgb >> 16) & 0xFF) + 0.587 * (($rgb >> 8) & 0xFF) + 0.114 * ($rgb & 0xFF));
                if ($lum < $min) $min = $lum;
                if ($lum > $max) $max = $lum;
            }
        }
        imagedestroy($im);
        return ($max - $min) < 12;   // no meaningful contrast anywhere
    }

    /**
     * Ensure a preview file exists for this page, generating one when missing.
     * Returns true when a usable file is present afterwards.
     */
    public static function ensure(int $pageId, string $title = '', bool $force = false): bool
    {
        if (!$force && self::exists($pageId)) return true;
        if (!function_exists('imagecreatetruecolor')) return false;   // no GD

        $canvas  = imagecreatetruecolor(self::W, self::H);
        $srcPath = self::sourceImage($pageId);
        $src     = $srcPath !== '' ? self::load($srcPath) : null;

        if ($src) {
            // cover-fit the source photo
            $sw = imagesx($src); $sh = imagesy($src);
            if ($sw < 1 || $sh < 1) { imagedestroy($src); imagedestroy($canvas); return false; }
            $scale = max(self::W / $sw, self::H / $sh);
            $nw = (int) ceil($sw * $scale); $nh = (int) ceil($sh * $scale);
            imagecopyresampled($canvas, $src, (int)((self::W - $nw) / 2), (int)((self::H - $nh) / 2), 0, 0, $nw, $nh, $sw, $sh);
            imagedestroy($src);

            // bottom scrim so the title stays readable over any photo
            for ($y = (int)(self::H * 0.55); $y < self::H; $y++) {
                $t = ($y - self::H * 0.55) / (self::H * 0.45);
                $a = (int) round(127 - (min(1, $t) * 105));
                $col = imagecolorallocatealpha($canvas, 8, 14, 18, max(0, $a));
                imagefilledrectangle($canvas, 0, $y, self::W, $y, $col);
            }
        } else {
            // No imagery on the page (text/colour-only layouts). Render a clean
            // typographic card in the page's own background colour rather than
            // leaving the generic placeholder logo forever.
            [$r, $g, $b] = self::pageBgColor($pageId);
            imagefilledrectangle($canvas, 0, 0, self::W, self::H, imagecolorallocate($canvas, $r, $g, $b));
        }

        // Over a photo the scrim guarantees a dark backdrop, so white always
        // reads; on a plain colour card pick whichever contrasts.
        $light = true;
        if (!$src) {
            [$r, $g, $b] = self::pageBgColor($pageId);
            $light = ((0.299 * $r + 0.587 * $g + 0.114 * $b) < 150);
        }
        self::drawTitle($canvas, trim($title), $light);

        $out = self::file($pageId);
        $dir = dirname($out);
        if (!is_dir($dir)) @mkdir($dir, 0775, true);
        $ok = @imagejpeg($canvas, $out, 82);
        imagedestroy($canvas);
        if ($ok) @chmod($out, 0644);

        return $ok && self::exists($pageId);
    }

    // ── internals ────────────────────────────────────────────────────────────

    private static function drawTitle($canvas, string $title, bool $light = true): void
    {
        if ($title === '' || !function_exists('imagettftext')) return;
        $font = '';
        foreach (self::FONTS as $f) { if (is_file($f)) { $font = $f; break; } }
        if ($font === '') return;

        $size  = 46;
        $white = $light
            ? imagecolorallocate($canvas, 255, 255, 255)
            : imagecolorallocate($canvas, 29, 35, 39);
        $words = preg_split('/\s+/', $title) ?: [];
        $lines = []; $cur = '';
        foreach ($words as $w) {
            $try = $cur === '' ? $w : $cur . ' ' . $w;
            $bb  = imagettfbbox($size, 0, $font, $try);
            if ($bb && ($bb[2] - $bb[0]) > (self::W - 120) && $cur !== '') { $lines[] = $cur; $cur = $w; }
            else $cur = $try;
        }
        if ($cur !== '') $lines[] = $cur;
        $lines = array_slice($lines, 0, 2);

        $y = self::H - 70 - (count($lines) - 1) * 58;
        foreach ($lines as $ln) { imagettftext($canvas, $size, 0, 60, $y, $white, $font, $ln); $y += 58; }
    }

    /**
     * First image belonging to this page that exists on disk. Preferred keys are
     * tried first, then ANY setting whose value looks like an image path — sites
     * store hero imagery under many keys (bg_image, screen_depth_image, cell_bg_*,
     * slide images, …) and enumerating them all is a losing game.
     */
    private static function sourceImage(int $pageId): string
    {
        $preferred = []; $other = [];
        $looksLikeImage = static fn(string $v): bool =>
            (bool) preg_match('#\.(jpe?g|png|webp|gif|avif)(\?|$)#i', $v);

        $page = \Instinctor\Services\PageService::getById($pageId);
        // NB: the thumbnail column often points at a previously generated
        // preview. Using that as the source composites the preview onto itself,
        // so a bad legacy capture would be baked in forever. Previews are
        // filtered out of every candidate below.
        if ($page && !empty($page['thumbnail'])) $preferred[] = (string) $page['thumbnail'];

        foreach (\Instinctor\Services\PageService::getElements($pageId) as $e) {
            $s = (array) ($e['settings'] ?? []);
            foreach ($s as $k => $v) {
                if (!is_string($v) || $v === '') continue;
                if (in_array($k, ['src', 'bg_image', 'bg_depth_image', 'screen_depth_image', 'image', 'image_src'], true)
                    && $looksLikeImage($v)) {
                    $preferred[] = $v;
                } elseif ($looksLikeImage($v)) {
                    $other[] = $v;
                } elseif (strpos($v, '<img') !== false
                    && preg_match('#<img[^>]+src="([^"]+)"#i', $v, $m)) {
                    $other[] = $m[1];
                }
            }
        }
        // Page-level settings can carry the hero too (screen_depth_image etc.).
        foreach ((array) \Instinctor\Services\PageService::getSettings($pageId) as $k => $v) {
            if (is_string($v) && $v !== '' && $looksLikeImage($v)) $preferred[] = $v;
        }

        foreach (array_merge($preferred, $other) as $c) {
            $c = (string) $c;
            if (strpos($c, 'impulse-previews') !== false) continue;   // never feed a preview back in
            $p = self::resolve($c);
            if ($p !== '' && strpos($p, 'impulse-previews') === false) return $p;
        }
        return '';
    }

    /** The page's own background colour, for the image-free fallback card. */
    private static function pageBgColor(int $pageId): array
    {
        $hex = '';
        $ps = (array) \Instinctor\Services\PageService::getSettings($pageId);
        if (!empty($ps['bg_color'])) $hex = (string) $ps['bg_color'];
        if ($hex === '') {
            foreach (\Instinctor\Services\PageService::getElements($pageId) as $e) {
                $c = (string) (($e['settings']['bg_color'] ?? ''));
                if ($c !== '' && $c !== 'transparent') { $hex = $c; break; }
            }
        }
        $hex = ltrim(trim($hex), '#');
        if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
        if (strlen($hex) !== 6 || !ctype_xdigit($hex)) return [29, 35, 39];   // house dark
        return [hexdec(substr($hex,0,2)), hexdec(substr($hex,2,2)), hexdec(substr($hex,4,2))];
    }

    /** Map a stored image URL/path to a real file on disk. */
    private static function resolve(string $src): string
    {
        if ($src === '' || strpos($src, 'data:') === 0) return '';
        if (is_file($src)) return $src;

        $path = parse_url($src, PHP_URL_PATH);
        if (!is_string($path) || $path === '') return '';

        // storage-relative (…/storage/uploads/…)
        $baseUrl = parse_url(Storage::baseurl(''), PHP_URL_PATH);
        if (is_string($baseUrl) && $baseUrl !== '' && strpos($path, $baseUrl) === 0) {
            $f = rtrim(Storage::basedir(''), '/') . '/' . ltrim(substr($path, strlen($baseUrl)), '/');
            if (is_file($f)) return $f;
        }

        // document-root relative — ABSPATH is the plugin dir on standalone, so
        // also try its parent (the actual webroot).
        $roots = [];
        if (!empty($_SERVER['DOCUMENT_ROOT'])) $roots[] = rtrim((string) $_SERVER['DOCUMENT_ROOT'], '/');
        if (defined('ABSPATH')) { $roots[] = rtrim(ABSPATH, '/'); $roots[] = dirname(rtrim(ABSPATH, '/')); }
        foreach ($roots as $r) {
            if ($r === '') continue;
            $f = $r . '/' . ltrim($path, '/');
            if (is_file($f)) return $f;
        }
        return '';
    }

    private static function load(string $path)
    {
        $info = @getimagesize($path);
        if (!$info) return null;
        switch ($info[2]) {
            case IMAGETYPE_JPEG: return @imagecreatefromjpeg($path);
            case IMAGETYPE_PNG:  return @imagecreatefrompng($path);
            case IMAGETYPE_GIF:  return @imagecreatefromgif($path);
            case IMAGETYPE_WEBP: return function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null;
        }
        return null;
    }
}
