<?php

declare(strict_types=1);

namespace Lab;

final class Photos
{
    public function __construct(private array $config) {}

    public function saveUpload(string $sid, array $file): array
    {
        $error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($error !== UPLOAD_ERR_OK) {
            throw new \RuntimeException(match ($error) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Το αρχείο είναι πολύ μεγάλο για μεταφόρτωση.',
                UPLOAD_ERR_PARTIAL => 'Η μεταφόρτωση κόπηκε στη μέση. Ξαναπροσπάθησε.',
                UPLOAD_ERR_NO_FILE => 'Δεν επιλέχθηκε αρχείο.',
                default => 'Η μεταφόρτωση απέτυχε.',
            });
        }
        $tmp = (string) ($file['tmp_name'] ?? '');
        if ($tmp === '' || ! is_uploaded_file($tmp)) {
            throw new \RuntimeException('Μη έγκυρο αρχείο.');
        }
        $maxBytes = ((int) ($this->config['limits']['max_file_mb'] ?? 40)) * 1024 * 1024;
        $size = (int) ($file['size'] ?? 0);
        if ($size <= 0 || $size > $maxBytes) {
            throw new \RuntimeException('Το αρχείο είναι πολύ μεγάλο.');
        }

        $info = @getimagesize($tmp);
        if (! is_array($info) || empty($info[0]) || empty($info[1])) {
            throw new \RuntimeException('Δεχόμαστε μόνο JPEG, PNG ή WebP.');
        }
        $mime = (string) ($info['mime'] ?? '');
        $ext = match ($mime) {
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/webp' => 'webp',
            default => null,
        };
        if ($ext === null) {
            throw new \RuntimeException('Δεχόμαστε μόνο JPEG, PNG ή WebP.');
        }

        $id = bin2hex(random_bytes(8));
        $dir = rtrim((string) $this->config['uploads_dir'], '/') . '/' . $sid;
        if (! is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        $stored = $id . '.' . $ext;
        $dest = $dir . '/' . $stored;
        if (! move_uploaded_file($tmp, $dest)) {
            throw new \RuntimeException('Δεν αποθηκεύτηκε το αρχείο.');
        }

        $original = (string) ($file['name'] ?? ($id . '.' . $ext));
        $original = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $original) ?: $stored;
        $info = $this->bakeUpright($dest, $info);

        return $this->meta($id, $original, $stored, $dest, $info, $mime);
    }

    public function pathFor(array $photo): string
    {
        if (! empty($photo['path']) && is_file((string) $photo['path'])) {
            return (string) $photo['path'];
        }
        $sid = Cart::sid();
        $candidate = rtrim((string) $this->config['uploads_dir'], '/') . '/' . $sid . '/' . $photo['stored_name'];
        if (is_file($candidate)) {
            return $candidate;
        }

        throw new \RuntimeException('Η φωτογραφία δεν βρέθηκε.');
    }

    public function outputThumb(array $photo): void
    {
        $this->sendJpeg($this->ensureThumb($photo));
    }

    public function outputPreview(array $photo): void
    {
        $this->sendJpeg($this->ensurePreview($photo));
    }

    public function outputOriginal(array $photo): void
    {
        $path = $this->pathFor($photo);
        $mime = mime_content_type($path) ?: 'image/jpeg';
        header('Content-Type: ' . $mime);
        header('Cache-Control: private, max-age=86400');
        header('Content-Length: ' . (string) filesize($path));
        readfile($path);
        exit;
    }

    public function exportPrint(string $source, array $edit, array $product, string $destJpg): void
    {
        $img = $this->load($source);
        $origW = imagesx($img);
        $origH = imagesy($img);
        $rotate = ((int) ($edit['rotate'] ?? 0) % 360 + 360) % 360;
        $straighten = max(-15, min(15, (float) ($edit['straighten'] ?? 0)));
        $angle = $rotate + $straighten;
        if (abs($angle) > 0.05) {
            $bg = imagecolorallocate($img, 255, 255, 255);
            $rotated = imagerotate($img, -$angle, $bg);
            imagedestroy($img);
            $img = $rotated;
        }

        $srcW = imagesx($img);
        $srcH = imagesy($img);
        $paperOri = (string) ($edit['paper_ori'] ?? $edit['paperOri'] ?? 'auto');
        [$paperW, $paperH] = self::paperCm($product, $origW, $origH, $paperOri);
        $paperRatio = $paperW / $paperH;
        $fit = ($edit['fit'] ?? 'fill') === 'fit' ? 'fit' : 'fill';

        if ($fit === 'fill') {
            $crop = $edit['crop'] ?? ['x' => 0, 'y' => 0, 'w' => 1, 'h' => 1];
            $cx = (int) round(((float) $crop['x']) * $srcW);
            $cy = (int) round(((float) $crop['y']) * $srcH);
            $cw = (int) round(((float) $crop['w']) * $srcW);
            $ch = (int) round(((float) $crop['h']) * $srcH);
            $cw = max(8, min($srcW - $cx, $cw));
            $ch = max(8, min($srcH - $cy, $ch));
            $cropRatio = $cw / $ch;
            if ($cropRatio > $paperRatio) {
                $newW = (int) round($ch * $paperRatio);
                $cx += (int) round(($cw - $newW) / 2);
                $cw = max(8, $newW);
            } else {
                $newH = (int) round($cw / $paperRatio);
                $cy += (int) round(($ch - $newH) / 2);
                $ch = max(8, $newH);
            }
            $outW = $cw;
            $outH = $ch;
            $canvas = imagecreatetruecolor($outW, $outH);
            imagecopy($canvas, $img, 0, 0, $cx, $cy, $cw, $ch);
        } else {
            $srcRatio = $srcW / $srcH;
            if ($srcRatio > $paperRatio) {
                $outW = $srcW;
                $outH = (int) round($srcW / $paperRatio);
            } else {
                $outH = $srcH;
                $outW = (int) round($srcH * $paperRatio);
            }
            $canvas = imagecreatetruecolor($outW, $outH);
            $white = imagecolorallocate($canvas, 255, 255, 255);
            imagefilledrectangle($canvas, 0, 0, $outW, $outH, $white);
            $dx = (int) round(($outW - $srcW) / 2);
            $dy = (int) round(($outH - $srcH) / 2);
            imagecopy($canvas, $img, $dx, $dy, 0, 0, $srcW, $srcH);
        }
        imagedestroy($img);
        $this->applyLook($canvas, $edit);
        $this->drawCaption($canvas, (string) ($edit['caption'] ?? ''));
        $border = (string) ($edit['order_border'] ?? $edit['border'] ?? 'none');
        if ($border === 'white') {
            $framed = $this->addWhiteBorder($canvas);
            imagedestroy($canvas);
            $canvas = $framed;
        }
        imagejpeg($canvas, $destJpg, 92);
        imagedestroy($canvas);
    }

    /**
     * Crop is stored on the unrotated photo (the yellow frame on screen).
     * Print rotates first, so map that frame onto the rotated bitmap.
     *
     * @param array{x?:float,y?:float,w?:float,h?:float} $crop
     * @return array{x:float,y:float,w:float,h:float}
     */
    public static function cropInRotatedSpace(array $crop, int $rotate, int $origW, int $origH): array
    {
        $x = (float) ($crop['x'] ?? 0);
        $y = (float) ($crop['y'] ?? 0);
        $w = (float) ($crop['w'] ?? 1);
        $h = (float) ($crop['h'] ?? 1);
        $q = (($rotate % 360) + 360) % 360;
        if ($q === 0 || $q === 180 || $origW < 8 || $origH < 8) {
            return self::clampCropBox($x, $y, $w, $h);
        }
        $aabbW = $origH;
        $aabbH = $origW;
        $aabbL = ($origW - $aabbW) / 2.0;
        $aabbT = ($origH - $aabbH) / 2.0;
        $bx = $x * $origW;
        $by = $y * $origH;
        $bw = $w * $origW;
        $bh = $h * $origH;

        return self::clampCropBox(
            ($bx - $aabbL) / max(1.0, $aabbW),
            ($by - $aabbT) / max(1.0, $aabbH),
            $bw / max(1.0, $aabbW),
            $bh / max(1.0, $aabbH)
        );
    }

    /** @return array{x:float,y:float,w:float,h:float} */
    private static function clampCropBox(float $x, float $y, float $w, float $h): array
    {
        $w = max(0.05, min(1, $w));
        $h = max(0.05, min(1, $h));
        $x = max(0, min(1 - $w, $x));
        $y = max(0, min(1 - $h, $y));

        return ['x' => $x, 'y' => $y, 'w' => $w, 'h' => $h];
    }

    /** Same paper size, flipped to match photo landscape/portrait. */
    public static function autoOrientsPaper(array $product): bool
    {
        $sku = strtolower((string) ($product['sku'] ?? ''));
        if ($sku === '') {
            return true;
        }
        foreach (['id35', 'id9', 'passport', 'notebook', 'cal', 'a4'] as $skip) {
            if ($sku === $skip || str_starts_with($sku, $skip)) {
                return false;
            }
        }

        return true;
    }

    /** @return array{0:float,1:float} */
    public static function paperCm(array $product, int $imgW, int $imgH, string $ori = 'auto'): array
    {
        $w = max(0.1, (float) ($product['width_cm'] ?? 10));
        $h = max(0.1, (float) ($product['height_cm'] ?? 15));
        if (! self::autoOrientsPaper($product) || $imgW < 8 || $imgH < 8) {
            return [$w, $h];
        }
        $short = min($w, $h);
        $long = max($w, $h);
        $ori = strtolower(trim($ori));
        if ($ori === 'landscape') {
            return [$long, $short];
        }
        if ($ori === 'portrait') {
            return [$short, $long];
        }
        if ($imgW > $imgH * 1.05) {
            return [$long, $short];
        }
        if ($imgH > $imgW * 1.05) {
            return [$short, $long];
        }

        return [$w, $h];
    }

    public function importFromPath(string $sid, string $source): array
    {
        $real = @realpath($source);
        if ($real === false && is_file($source)) {
            $real = $source;
        }
        if ($real === false || ! is_file($real)) {
            throw new \RuntimeException('Το αρχείο δεν βρέθηκε.');
        }
        $maxBytes = ((int) ($this->config['limits']['max_file_mb'] ?? 40)) * 1024 * 1024;
        $size = (int) filesize($real);
        if ($size <= 0 || $size > $maxBytes) {
            throw new \RuntimeException('Το αρχείο είναι πολύ μεγάλο.');
        }
        $info = @getimagesize($real);
        if (! is_array($info) || empty($info[0]) || empty($info[1])) {
            throw new \RuntimeException('Δεχόμαστε μόνο JPEG, PNG ή WebP.');
        }
        $mime = (string) ($info['mime'] ?? '');
        $ext = match ($mime) {
            'image/jpeg' => 'jpg',
            'image/png' => 'png',
            'image/webp' => 'webp',
            default => null,
        };
        if ($ext === null) {
            throw new \RuntimeException('Δεχόμαστε μόνο JPEG, PNG ή WebP.');
        }
        $id = bin2hex(random_bytes(8));
        $dir = rtrim((string) $this->config['uploads_dir'], '/') . '/' . $sid;
        if (! is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        $stored = $id . '.' . $ext;
        $dest = $dir . '/' . $stored;
        if (! copy($real, $dest)) {
            throw new \RuntimeException('Δεν αντιγράφηκε το αρχείο.');
        }
        $original = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', basename($real)) ?: $stored;
        $info = $this->bakeUpright($dest, $info);

        return $this->meta($id, $original, $stored, $dest, $info, $mime);
    }

    public function copyStored(string $sid, array $photo): array
    {
        $src = $this->pathFor($photo);
        $ext = strtolower((string) pathinfo((string) $photo['stored_name'], PATHINFO_EXTENSION)) ?: 'jpg';
        $id = bin2hex(random_bytes(8));
        $dir = rtrim((string) $this->config['uploads_dir'], '/') . '/' . $sid;
        $stored = $id . '.' . $ext;
        $dest = $dir . '/' . $stored;
        copy($src, $dest);
        $oldThumb = dirname($src) . '/' . $photo['id'] . '.t.jpg';
        $newThumb = $dir . '/' . $id . '.t.jpg';
        if (is_file($oldThumb)) {
            @copy($oldThumb, $newThumb);
        }

        return [
            'id' => $id,
            'original_name' => 'copy-' . ($photo['original_name'] ?? $stored),
            'stored_name' => $stored,
            'path' => $dest,
            'width' => (int) $photo['width'],
            'height' => (int) $photo['height'],
            'bytes' => (int) filesize($dest),
            'mime' => (string) ($photo['mime'] ?? 'image/jpeg'),
        ];
    }

    /** @param list<array<string, mixed>> $photos */
    public function composeCollage(string $sid, array $photos): array
    {
        $photos = array_slice($photos, 0, 4);
        if (count($photos) < 2) {
            throw new \RuntimeException('Για κολάζ διάλεξε τουλάχιστον 2 φωτογραφίες.');
        }
        $cols = 2;
        $rows = count($photos) > 2 ? 2 : 1;
        $cellW = 900;
        $cellH = $rows === 1 ? 1350 : 675;
        $canvas = imagecreatetruecolor($cellW * $cols, $cellH * $rows);
        $white = imagecolorallocate($canvas, 255, 255, 255);
        imagefilledrectangle($canvas, 0, 0, $cellW * $cols, $cellH * $rows, $white);
        foreach ($photos as $i => $photo) {
            $img = $this->load($this->bestSource($photo));
            $col = $i % $cols;
            $row = intdiv($i, $cols);
            $this->coverInto($img, $canvas, $col * $cellW, $row * $cellH, $cellW, $cellH);
            imagedestroy($img);
        }

        return $this->storeCanvas($sid, $canvas, 'kolaz.jpg');
    }

    /** @param list<array<string, mixed>> $photos */
    public function composeIndex(string $sid, array $photos): array
    {
        if ($photos === []) {
            throw new \RuntimeException('Δεν υπάρχουν φωτογραφίες για ευρετήριο.');
        }
        $photos = array_slice($photos, 0, 24);
        $n = count($photos);
        $cols = $n > 12 ? 4 : ($n > 6 ? 3 : 2);
        $rows = (int) ceil($n / $cols);
        $outW = 1800;
        $outH = 2700;
        $pad = 24;
        $cellW = (int) floor(($outW - $pad * ($cols + 1)) / $cols);
        $cellH = (int) floor(($outH - $pad * ($rows + 1)) / $rows);
        $canvas = imagecreatetruecolor($outW, $outH);
        $white = imagecolorallocate($canvas, 255, 255, 255);
        imagefilledrectangle($canvas, 0, 0, $outW, $outH, $white);
        foreach ($photos as $i => $photo) {
            $img = $this->load($this->bestSource($photo));
            $col = $i % $cols;
            $row = intdiv($i, $cols);
            $x = $pad + $col * ($cellW + $pad);
            $y = $pad + $row * ($cellH + $pad);
            $thumbH = max(40, $cellH - 36);
            $this->coverInto($img, $canvas, $x, $y, $cellW, $thumbH);
            imagedestroy($img);
            $this->drawCaption($canvas, (string) ($photo['original_name'] ?? ''), $x, $y + $thumbH, $cellW, 32);
        }

        return $this->storeCanvas($sid, $canvas, 'eyretirio.jpg');
    }

    /**
     * 4 ID portraits (3.5×4.5) on 9×13 cm sheets.
     *
     * @param list<array<string, mixed>> $photos
     * @return list<array<string, mixed>>
     */
    public function composeIdSheets(string $sid, array $photos): array
    {
        if ($photos === []) {
            throw new \RuntimeException('Διάλεξε τουλάχιστον μία φωτογραφία για ταυτότητα.');
        }
        $dpi = 300;
        $paperW = max(400, (int) round(9 / 2.54 * $dpi));
        $paperH = max(400, (int) round(13 / 2.54 * $dpi));
        $cellW = max(80, (int) round(3.5 / 2.54 * $dpi));
        $cellH = max(80, (int) round(4.5 / 2.54 * $dpi));
        $gap = max(8, (int) round(0.22 / 2.54 * $dpi));
        $gridW = $cellW * 2 + $gap;
        $gridH = $cellH * 2 + $gap;
        $ox = (int) max(0, ($paperW - $gridW) / 2);
        $oy = (int) max(0, ($paperH - $gridH) / 2);
        $product = ['width_cm' => 3.5, 'height_cm' => 4.5];
        $out = [];
        foreach (array_chunk($photos, 4) as $i => $chunk) {
            $slots = $chunk;
            $n = 0;
            while (count($slots) < 4) {
                $slots[] = $chunk[$n % count($chunk)];
                $n++;
            }
            $canvas = imagecreatetruecolor($paperW, $paperH);
            $white = imagecolorallocate($canvas, 255, 255, 255);
            imagefilledrectangle($canvas, 0, 0, $paperW, $paperH, $white);
            foreach ($slots as $s => $photo) {
                $tmp = sys_get_temp_dir() . '/lab-id-' . bin2hex(random_bytes(4)) . '.jpg';
                $this->exportPrint($this->pathFor($photo), $this->editFromPhoto($photo), $product, $tmp);
                $cell = $this->load($tmp);
                @unlink($tmp);
                $col = $s % 2;
                $row = intdiv($s, 2);
                $dx = $ox + $col * ($cellW + $gap);
                $dy = $oy + $row * ($cellH + $gap);
                imagecopyresampled($canvas, $cell, $dx, $dy, 0, 0, $cellW, $cellH, imagesx($cell), imagesy($cell));
                imagedestroy($cell);
            }
            $out[] = $this->storeCanvas($sid, $canvas, 'tautotites-9x13-' . ($i + 1) . '.jpg');
        }

        return $out;
    }

    /** @param array<string, mixed> $photo */
    private function editFromPhoto(array $photo): array
    {
        return [
            'fit' => 'fill',
            'crop' => is_array($photo['crop'] ?? null) ? $photo['crop'] : ['x' => 0, 'y' => 0, 'w' => 1, 'h' => 1],
            'rotate' => (int) ($photo['rotate'] ?? 0),
            'straighten' => (float) ($photo['straighten'] ?? 0),
            'flip' => (int) ($photo['flip'] ?? 0),
            'brightness' => (int) ($photo['brightness'] ?? 0),
            'contrast' => (int) ($photo['contrast'] ?? 0),
            'saturation' => (int) ($photo['saturation'] ?? 0),
            'midtones' => (int) ($photo['midtones'] ?? 0),
            'wbR' => (float) ($photo['wbR'] ?? 1),
            'wbG' => (float) ($photo['wbG'] ?? 1),
            'wbB' => (float) ($photo['wbB'] ?? 1),
            'filter' => (string) ($photo['filter'] ?? 'none'),
            'caption' => '',
            'border' => 'none',
        ];
    }

    /**
     * @param list<array<string, mixed>> $photos
     * @return list<array<string, mixed>>
     */
    public function composeCalendar(string $sid, array $photos, string $style = 'wall'): array
    {
        if ($photos === []) {
            throw new \RuntimeException('Διάλεξε τουλάχιστον μία φωτογραφία για το ημερολόγιο.');
        }
        $style = in_array($style, ['wall', 'months', 'classic'], true) ? $style : 'wall';
        if ($style === 'wall') {
            return [$this->storeCanvas($sid, $this->renderYearPoster($photos, 2027), 'imerologio-2027-toixos.jpg')];
        }
        $out = [];
        $months = ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'];
        for ($m = 1; $m <= 12; $m++) {
            $photo = $photos[($m - 1) % count($photos)];
            $name = sprintf('imerologio-2027-%s-%02d.jpg', $style, $m);
            $page = $style === 'classic'
                ? $this->renderClassicPage($photo, 2027, $m, $months[$m - 1])
                : $this->renderMonthPage($photo, 2027, $m, $months[$m - 1]);
            $out[] = $this->storeCanvas($sid, $page, $name);
        }

        return $out;
    }

    /** @param list<array<string, mixed>> $photos */
    private function renderYearPoster(array $photos, int $year)
    {
        $w = 2480;
        $h = 3508;
        $im = imagecreatetruecolor($w, $h);
        $bg = imagecolorallocate($im, 255, 247, 238);
        $gold = imagecolorallocate($im, 201, 122, 0);
        $white = imagecolorallocate($im, 255, 255, 255);
        $bar = imagecolorallocate($im, 67, 24, 111);
        imagefilledrectangle($im, 0, 0, $w, $h, $bg);
        $bannerH = 780;
        imagefilledrectangle($im, 0, 0, $w, $bannerH, $white);
        $src = $this->load($this->bestSource($photos[0]));
        $this->coverInto($src, $im, 0, 0, $w, $bannerH, $photos[0]);
        imagedestroy($src);
        imagefilledrectangle($im, 0, $bannerH - 150, $w, $bannerH, $bar);
        $this->drawCalText($im, 92, intdiv($w, 2), $bannerH - 70, $white, 'Ημερολόγιο ' . $year, 'center');
        $this->drawCalText($im, 28, intdiv($w, 2), $bannerH - 24, $white, 'Πανόραμα · εκτύπωση A4 Ricoh', 'center');
        $pad = 48;
        $gap = 28;
        $gridW = $w - $pad * 2;
        $gridH = $h - $bannerH - $pad - 40;
        $cellW = (int) floor(($gridW - $gap * 2) / 3);
        $cellH = (int) floor(($gridH - $gap * 3) / 4);
        $months = ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'];
        for ($m = 1; $m <= 12; $m++) {
            $col = ($m - 1) % 3;
            $row = intdiv($m - 1, 3);
            $x = $pad + $col * ($cellW + $gap);
            $y = $bannerH + $pad + $row * ($cellH + $gap);
            imagefilledrectangle($im, $x, $y, $x + $cellW, $y + $cellH, $white);
            imagerectangle($im, $x, $y, $x + $cellW, $y + $cellH, $gold);
            $this->drawMonthGrid($im, $year, $m, $months[$m - 1], $x + 12, $y + 10, $cellW - 24, $cellH - 20, true);
        }

        return $im;
    }

    /** @param array<string, mixed> $photo */
    private function renderMonthPage(array $photo, int $year, int $month, string $monthName)
    {
        $w = 2480;
        $h = 3508;
        $im = imagecreatetruecolor($w, $h);
        $bg = imagecolorallocate($im, 255, 247, 238);
        $ink = imagecolorallocate($im, 67, 24, 111);
        imagefilledrectangle($im, 0, 0, $w, $h, $bg);
        $photoH = 1680;
        $src = $this->load($this->bestSource($photo));
        $this->coverInto($src, $im, 0, 0, $w, $photoH, $photo);
        imagedestroy($src);
        $this->drawCalText($im, 64, 80, $photoH + 90, $ink, $monthName . ' ' . $year);
        $this->drawCalText($im, 26, $w - 80, $photoH + 80, $ink, 'Πανόραμα · A4 Ricoh', 'right');
        $this->drawMonthGrid($im, $year, $month, '', 80, $photoH + 120, $w - 160, $h - $photoH - 200, false);

        return $im;
    }

    /** @param array<string, mixed> $photo */
    private function renderClassicPage(array $photo, int $year, int $month, string $monthName)
    {
        $w = 2480;
        $h = 3508;
        $im = imagecreatetruecolor($w, $h);
        $navy = imagecolorallocate($im, 43, 18, 72);
        $cream = imagecolorallocate($im, 255, 247, 238);
        $gold = imagecolorallocate($im, 201, 148, 48);
        $ink = imagecolorallocate($im, 67, 24, 111);
        imagefilledrectangle($im, 0, 0, $w, $h, $navy);
        imagefilledrectangle($im, 70, 70, $w - 70, $h - 70, $cream);
        $this->drawCalText($im, 36, intdiv($w, 2), 160, $gold, 'Πανόραμα · ' . $year, 'center');
        $this->drawCalText($im, 72, intdiv($w, 2), 250, $ink, $monthName, 'center');
        $frameX = 160;
        $frameY = 300;
        $frameW = $w - 320;
        $frameH = 1680;
        imagefilledrectangle($im, $frameX - 18, $frameY - 18, $frameX + $frameW + 18, $frameY + $frameH + 18, $gold);
        imagefilledrectangle($im, $frameX, $frameY, $frameX + $frameW, $frameY + $frameH, $cream);
        $src = $this->load($this->bestSource($photo));
        $this->coverInto($src, $im, $frameX + 16, $frameY + 16, $frameW - 32, $frameH - 32, $photo);
        imagedestroy($src);
        $this->drawMonthGrid($im, $year, $month, '', 160, $frameY + $frameH + 60, $w - 320, $h - ($frameY + $frameH) - 200, false);

        return $im;
    }

    private function drawMonthGrid($im, int $year, int $month, string $title, int $x, int $y, int $w, int $h, bool $compact): void
    {
        $ink = imagecolorallocate($im, 67, 24, 111);
        $muted = imagecolorallocate($im, 140, 90, 160);
        $rose = imagecolorallocate($im, 225, 29, 116);
        $line = imagecolorallocate($im, 255, 180, 212);
        $days = ['Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σα', 'Κυ'];
        $top = $y;
        if ($title !== '') {
            $this->drawCalText($im, $compact ? 22 : 36, $x, $y + ($compact ? 24 : 40), $ink, $title);
            $top += $compact ? 36 : 56;
        }
        $cellW = (int) floor($w / 7);
        $headH = $compact ? 28 : 44;
        foreach ($days as $i => $d) {
            $color = $i >= 5 ? $rose : $muted;
            $this->drawCalText($im, $compact ? 16 : 24, $x + $i * $cellW + intdiv($cellW, 2), $top + $headH - 8, $color, $d, 'center');
        }
        $top += $headH;
        $start = (int) date('N', strtotime(sprintf('%04d-%02d-01', $year, $month)));
        $dim = (int) date('t', strtotime(sprintf('%04d-%02d-01', $year, $month)));
        $rows = (int) ceil(($start - 1 + $dim) / 7);
        $cellH = max(28, (int) floor(($h - ($top - $y)) / max(1, $rows)));
        $holidays = $this->holidays2027();
        $day = 1;
        for ($row = 0; $row < $rows; $row++) {
            for ($col = 0; $col < 7; $col++) {
                $cellX = $x + $col * $cellW;
                $cellY = $top + $row * $cellH;
                imageline($im, $cellX, $cellY, $cellX + $cellW, $cellY, $line);
                $idx = $row * 7 + $col + 1;
                if ($idx < $start || $day > $dim) {
                    continue;
                }
                $key = sprintf('%04d-%02d-%02d', $year, $month, $day);
                $isHoliday = isset($holidays[$key]) || $col >= 5;
                $color = $isHoliday ? $rose : $ink;
                $this->drawCalText($im, $compact ? 18 : 32, $cellX + intdiv($cellW, 2), $cellY + ($compact ? 22 : 36), $color, (string) $day, 'center');
                $day++;
            }
        }
    }

    /** @return array<string, string> */
    private function holidays2027(): array
    {
        return [
            '2027-01-01' => 'Πρωτοχρονιά',
            '2027-01-06' => 'Θεοφάνεια',
            '2027-03-15' => 'Καθαρά Δευτέρα',
            '2027-03-25' => '25η Μαρτίου',
            '2027-04-30' => 'Μεγάλη Παρασκευή',
            '2027-05-01' => 'Πρωτομαγιά',
            '2027-05-02' => 'Πάσχα',
            '2027-05-03' => 'Δευτέρα Πάσχα',
            '2027-06-21' => 'Αγίου Πνεύματος',
            '2027-08-15' => 'Κοίμηση',
            '2027-10-28' => '28η Οκτωβρίου',
            '2027-12-25' => 'Χριστούγεννα',
            '2027-12-26' => 'Σύναξη Θεοτόκου',
        ];
    }

    private function drawCalText($im, int $size, int $x, int $y, int $color, string $text, string $align = 'left'): void
    {
        $font = $this->fontPath();
        if ($font && function_exists('imagettftext')) {
            $box = imagettfbbox($size, 0, $font, $text);
            $tw = (int) ($box[2] - $box[0]);
            if ($align === 'center') {
                $x -= (int) round($tw / 2);
            } elseif ($align === 'right') {
                $x -= $tw;
            }
            imagettftext($im, $size, 0, $x, $y, $color, $font, $text);

            return;
        }
        $px = $align === 'center' ? $x - 20 : ($align === 'right' ? $x - 40 : $x);
        imagestring($im, 5, max(0, $px), max(0, $y - 12), $text, $color);
    }

    private function bestSource(array $photo): string
    {
        $path = $this->pathFor($photo);
        $preview = dirname($path) . '/' . $photo['id'] . '.p.jpg';

        return is_file($preview) ? $preview : $path;
    }

    private function coverInto($src, $dest, int $dx, int $dy, int $dw, int $dh, array $photo = []): void
    {
        $sw = imagesx($src);
        $sh = imagesy($src);
        $ox = 0;
        $oy = 0;
        $ow = $sw;
        $oh = $sh;
        $crop = $photo['crop'] ?? null;
        if (is_array($crop)) {
            $ox = (int) round(((float) ($crop['x'] ?? 0)) * $sw);
            $oy = (int) round(((float) ($crop['y'] ?? 0)) * $sh);
            $ow = (int) round(((float) ($crop['w'] ?? 1)) * $sw);
            $oh = (int) round(((float) ($crop['h'] ?? 1)) * $sh);
            $ox = max(0, min($sw - 8, $ox));
            $oy = max(0, min($sh - 8, $oy));
            $ow = max(8, min($sw - $ox, $ow));
            $oh = max(8, min($sh - $oy, $oh));
        }
        $sRatio = $ow / max(1, $oh);
        $dRatio = $dw / max(1, $dh);
        if ($sRatio > $dRatio) {
            $cw = (int) round($oh * $dRatio);
            $cx = $ox + (int) round(($ow - $cw) / 2);
            $cy = $oy;
            $ch = $oh;
        } else {
            $ch = (int) round($ow / $dRatio);
            $cy = $oy + (int) round(($oh - $ch) / 2);
            $cx = $ox;
            $cw = $ow;
        }
        imagecopyresampled($dest, $src, $dx, $dy, max(0, $cx), max(0, $cy), $dw, $dh, max(1, $cw), max(1, $ch));
    }

    private function storeCanvas(string $sid, $canvas, string $name): array
    {
        $id = bin2hex(random_bytes(8));
        $dir = rtrim((string) $this->config['uploads_dir'], '/') . '/' . $sid;
        if (! is_dir($dir)) {
            mkdir($dir, 0775, true);
        }
        $stored = $id . '.jpg';
        $dest = $dir . '/' . $stored;
        imagejpeg($canvas, $dest, 90);
        $w = imagesx($canvas);
        $h = imagesy($canvas);
        imagedestroy($canvas);
        $photo = [
            'id' => $id,
            'original_name' => $name,
            'stored_name' => $stored,
            'path' => $dest,
            'width' => $w,
            'height' => $h,
            'bytes' => (int) filesize($dest),
            'mime' => 'image/jpeg',
        ];
        $this->ensureDerivatives($photo);

        return $photo;
    }

    private function fontPath(): ?string
    {
        foreach ([
            'C:\\Windows\\Fonts\\arial.ttf',
            'C:\\Windows\\Fonts\\segoeui.ttf',
            '/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
            '/System/Library/Fonts/Supplemental/Arial.ttf',
            '/Library/Fonts/Arial.ttf',
            '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
        ] as $path) {
            if (is_file($path)) {
                return $path;
            }
        }

        return null;
    }

    private function drawCaption($img, string $text, ?int $x = null, ?int $y = null, ?int $boxW = null, int $boxH = 48): void
    {
        $text = trim($text);
        if ($text === '') {
            return;
        }
        $w = imagesx($img);
        $h = imagesy($img);
        if ($x === null) {
            $boxW = $w;
            $boxH = max(36, (int) round($h * 0.07));
            $x = 0;
            $y = $h - $boxH;
        }
        $boxW = $boxW ?? $w;
        imagesavealpha($img, true);
        $overlay = imagecolorallocatealpha($img, 0, 0, 0, 70);
        imagefilledrectangle($img, $x, $y, $x + $boxW, $y + $boxH, $overlay);
        $white = imagecolorallocate($img, 255, 255, 255);
        $font = $this->fontPath();
        if ($font && function_exists('imagettftext')) {
            $size = max(12, (int) round($boxH * 0.42));
            imagettftext($img, $size, 0, $x + 10, $y + (int) round($boxH * 0.72), $white, $font, $text);
        } else {
            imagestring($img, 5, $x + 8, $y + max(4, intdiv($boxH, 3)), $text, $white);
        }
    }

    private function applyLook($canvas, array $edit): void
    {
        $wr = (float) ($edit['wbR'] ?? 1);
        $wg = (float) ($edit['wbG'] ?? 1);
        $wb = (float) ($edit['wbB'] ?? 1);
        if (abs($wr - 1) > 0.01 || abs($wg - 1) > 0.01 || abs($wb - 1) > 0.01) {
            $this->applyWhiteBalance($canvas, $wr, $wg, $wb);
        }
        $brightness = (int) ($edit['brightness'] ?? 0);
        $contrast = (int) ($edit['contrast'] ?? 0);
        $saturation = (int) ($edit['saturation'] ?? 0);
        $midtones = (int) ($edit['midtones'] ?? 0);
        if ($brightness !== 0) {
            imagefilter($canvas, IMG_FILTER_BRIGHTNESS, (int) round($brightness * 2.2));
        }
        if ($midtones !== 0) {
            $exp = 2 ** (-$midtones / 40);
            imagegammacorrect($canvas, 1.0, max(0.25, min(4.0, $exp)));
        }
        if ($contrast !== 0) {
            imagefilter($canvas, IMG_FILTER_CONTRAST, -$contrast);
        }
        if ($saturation <= -80) {
            imagefilter($canvas, IMG_FILTER_GRAYSCALE);
        } elseif ($saturation !== 0) {
            $this->applySaturation($canvas, $saturation);
        }
        $filter = (string) ($edit['filter'] ?? 'none');
        if ($filter === 'bw') {
            imagefilter($canvas, IMG_FILTER_GRAYSCALE);
        } elseif ($filter === 'sepia') {
            imagefilter($canvas, IMG_FILTER_GRAYSCALE);
            imagefilter($canvas, IMG_FILTER_COLORIZE, 38, 18, -12);
        } elseif ($filter === 'vivid') {
            imagefilter($canvas, IMG_FILTER_CONTRAST, -18);
            imagefilter($canvas, IMG_FILTER_BRIGHTNESS, 6);
        }
        if (! empty($edit['sharpen'])) {
            $matrix = [[0, -1, 0], [-1, 5, -1], [0, -1, 0]];
            imageconvolution($canvas, $matrix, 1, 0);
        }
        if (! empty($edit['flip'])) {
            imageflip($canvas, IMG_FLIP_HORIZONTAL);
        }
    }

    private function applyWhiteBalance($im, float $wr, float $wg, float $wb): void
    {
        $wr = max(0.55, min(1.55, $wr));
        $wg = max(0.55, min(1.55, $wg));
        $wb = max(0.55, min(1.55, $wb));
        $w = imagesx($im);
        $h = imagesy($im);
        for ($y = 0; $y < $h; $y++) {
            for ($x = 0; $x < $w; $x++) {
                $rgb = imagecolorat($im, $x, $y);
                $r = (int) round((($rgb >> 16) & 255) * $wr);
                $g = (int) round((($rgb >> 8) & 255) * $wg);
                $b = (int) round(($rgb & 255) * $wb);
                $r = $r < 0 ? 0 : ($r > 255 ? 255 : $r);
                $g = $g < 0 ? 0 : ($g > 255 ? 255 : $g);
                $b = $b < 0 ? 0 : ($b > 255 ? 255 : $b);
                imagesetpixel($im, $x, $y, ($r << 16) | ($g << 8) | $b);
            }
        }
    }

    private function applySaturation($im, int $amount): void
    {
        $factor = 1 + $amount / 100;
        $w = imagesx($im);
        $h = imagesy($im);
        for ($y = 0; $y < $h; $y++) {
            for ($x = 0; $x < $w; $x++) {
                $rgb = imagecolorat($im, $x, $y);
                $r = ($rgb >> 16) & 255;
                $g = ($rgb >> 8) & 255;
                $b = $rgb & 255;
                $gray = 0.299 * $r + 0.587 * $g + 0.114 * $b;
                $nr = (int) round($gray + ($r - $gray) * $factor);
                $ng = (int) round($gray + ($g - $gray) * $factor);
                $nb = (int) round($gray + ($b - $gray) * $factor);
                $nr = $nr < 0 ? 0 : ($nr > 255 ? 255 : $nr);
                $ng = $ng < 0 ? 0 : ($ng > 255 ? 255 : $ng);
                $nb = $nb < 0 ? 0 : ($nb > 255 ? 255 : $nb);
                imagesetpixel($im, $x, $y, ($nr << 16) | ($ng << 8) | $nb);
            }
        }
    }

    private function addWhiteBorder($img)
    {
        $w = imagesx($img);
        $h = imagesy($img);
        $pad = max(8, (int) round(min($w, $h) * 0.04));
        $out = imagecreatetruecolor($w + $pad * 2, $h + $pad * 2);
        $white = imagecolorallocate($out, 255, 255, 255);
        imagefilledrectangle($out, 0, 0, $w + $pad * 2, $h + $pad * 2, $white);
        imagecopy($out, $img, $pad, $pad, 0, 0, $w, $h);

        return $out;
    }

    private function meta(string $id, string $original, string $stored, string $dest, array $info, string $mime): array
    {
        return [
            'id' => $id,
            'original_name' => $original,
            'stored_name' => $stored,
            'path' => $dest,
            'width' => (int) $info[0],
            'height' => (int) $info[1],
            'bytes' => (int) filesize($dest),
            'mime' => $mime,
        ];
    }

    /** Rewrite JPEG pixels so EXIF orientation is baked in and width/height match what you see. */
    private function bakeUpright(string $path, array $info): array
    {
        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
        if (! in_array($ext, ['jpg', 'jpeg'], true)) {
            return $info;
        }
        if ($this->exifOrientation($path) <= 1) {
            return $info;
        }
        $img = $this->load($path);
        $ok = imagejpeg($img, $path, 92);
        $w = imagesx($img);
        $h = imagesy($img);
        imagedestroy($img);
        if (! $ok) {
            return $info;
        }
        $info[0] = $w;
        $info[1] = $h;

        return $info;
    }

    private function exifOrientation(string $path): int
    {
        if (! function_exists('exif_read_data')) {
            return 1;
        }
        $exif = @exif_read_data($path);

        return (int) ($exif['Orientation'] ?? 1);
    }

    private function tryExifThumb(string $source, string $dest): bool
    {
        try {
            if (is_file($dest) && filesize($dest) > 200) {
                return true;
            }
            if ($this->exifOrientation($source) > 1) {
                return false;
            }
            if (! function_exists('exif_thumbnail')) {
                return false;
            }
            $w = 0;
            $h = 0;
            $type = 0;
            $thumb = @exif_thumbnail($source, $w, $h, $type);
            if ($thumb === false || strlen($thumb) < 200) {
                return false;
            }
            if (file_put_contents($dest, $thumb) === false) {
                return false;
            }

            return is_file($dest) && filesize($dest) > 200;
        } catch (\Throwable) {
            return false;
        }
    }

    public function ensureThumb(array $photo): string
    {
        $source = $this->pathFor($photo);
        $thumb = dirname($source) . '/' . $photo['id'] . '.t.jpg';
        if (is_file($thumb) && filesize($thumb) > 200 && $this->thumbOrientationOk($source, $thumb)) {
            return $thumb;
        }
        if (is_file($thumb)) {
            @unlink($thumb);
        }
        $img = $this->load($source);
        $this->saveSizedFrom($img, $thumb, (int) ($this->config['limits']['thumb_edge'] ?? 240), 62);
        imagedestroy($img);

        return $thumb;
    }

    private function thumbOrientationOk(string $source, string $thumb): bool
    {
        $orient = $this->exifOrientation($source);
        if (! in_array($orient, [5, 6, 7, 8], true)) {
            return true;
        }
        $t = @getimagesize($thumb);
        $s = @getimagesize($source);
        if (! is_array($t) || ! is_array($s)) {
            return false;
        }
        $srcW = (int) $s[0];
        $srcH = (int) $s[1];
        [$srcW, $srcH] = [$srcH, $srcW];
        $srcPortrait = $srcH > $srcW;
        $thumbPortrait = ((int) $t[1]) > ((int) $t[0]);

        return $srcPortrait === $thumbPortrait;
    }

    public function ensurePreview(array $photo): string
    {
        $source = $this->pathFor($photo);
        $preview = dirname($source) . '/' . $photo['id'] . '.p.jpg';
        if (is_file($preview) && filesize($preview) > 200) {
            return $preview;
        }
        $img = $this->load($source);
        $this->saveSizedFrom($img, $preview, (int) ($this->config['limits']['preview_edge'] ?? 1400), 76);
        imagedestroy($img);

        return $preview;
    }

    /** @return array{thumb:string,preview:string} */
    public function ensureDerivatives(array $photo): array
    {
        return [
            'thumb' => $this->ensureThumb($photo),
            'preview' => $this->ensurePreview($photo),
        ];
    }

    private function saveSizedFrom($img, string $dest, int $edge, int $quality): void
    {
        $srcW = imagesx($img);
        $srcH = imagesy($img);
        $scale = min(1, $edge / max($srcW, $srcH));
        if ($scale >= 1) {
            imagejpeg($img, $dest, $quality);

            return;
        }
        $w = max(1, (int) round($srcW * $scale));
        $h = max(1, (int) round($srcH * $scale));
        $out = imagecreatetruecolor($w, $h);
        if ($edge <= 280) {
            imagecopyresized($out, $img, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
        } else {
            imagecopyresampled($out, $img, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
        }
        imagejpeg($out, $dest, $quality);
        imagedestroy($out);
    }

    private function sendJpeg(string $path): void
    {
        header('Content-Type: image/jpeg');
        header('Cache-Control: private, max-age=86400');
        header('Content-Length: ' . (string) filesize($path));
        readfile($path);
        exit;
    }

    private function load(string $path)
    {
        $ext = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
        $img = match ($ext) {
            'jpg', 'jpeg' => @imagecreatefromjpeg($path),
            'png' => @imagecreatefrompng($path),
            'webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : false,
            default => false,
        };
        if ($img === false) {
            $data = file_get_contents($path);
            if ($data === false) {
                throw new \RuntimeException('Δεν ανοίγει η φωτογραφία.');
            }
            $img = @imagecreatefromstring($data);
        }
        if ($img === false) {
            throw new \RuntimeException('Μη υποστηριζόμενη εικόνα.');
        }
        if (! preg_match('/\.(t|p)\.jpg$/i', $path)) {
            $img = $this->exifUpright($img, $path);
        }

        return $img;
    }

    private function exifUpright($img, string $path)
    {
        if (! function_exists('exif_read_data')) {
            return $img;
        }
        $exif = @exif_read_data($path);
        $orient = (int) ($exif['Orientation'] ?? 1);
        if ($orient <= 1) {
            return $img;
        }

        return match ($orient) {
            3 => imagerotate($img, 180, 0) ?: $img,
            6 => imagerotate($img, -90, 0) ?: $img,
            8 => imagerotate($img, 90, 0) ?: $img,
            2 => $this->flipKeep($img),
            default => $img,
        };
    }

    private function flipKeep($img)
    {
        imageflip($img, IMG_FLIP_HORIZONTAL);

        return $img;
    }
}
