<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CoreBundle\Export;

/**
 * Best-effort transform of an mPDF print-sheet template into HTML that renders
 * acceptably inside a browser preview (shadow root). It:
 *  - substitutes the mPDF {PAGENO}/{nbpg}/{DATE …} tags,
 *  - converts mm lengths (CSS and width/height attributes) to px,
 *  - converts cmyk() colours to rgb() (via CmykToRgb),
 *  - inlines local <img> sources as data URIs (mPDF resolves them from the
 *    project dir; the browser cannot),
 *  - lifts the mPDF page header/footer out of the content flow and positions
 *    them inside the @page margins of a simulated sheet,
 *  - visualises CSS page breaks (page-break-after: always) with a dashed rule.
 */
final class PrintSheetPreview
{
    private const MM_TO_PX = 96 / 25.4;

    public function __construct(private readonly string $projectDir)
    {
    }

    public function transform(string $html, ?\DateTimeInterface $date = null): string
    {
        $date ??= new \DateTimeImmutable();

        $pages = max(1, $this->countPages($html));
        $html = $this->replaceTags($html, $pages, $date);
        $html = $this->convertUnits($html);
        $html = CmykToRgb::convert($html);
        // Insert page breaks before inlining images: the base64 data URIs would
        // otherwise blow the PCRE backtrack limit on the tag-matching regex.
        $html = $this->renderPageBreaks($html);
        $html = $this->inlineImages($html);

        return $this->layout($html);
    }

    /**
     * @return list<string> class names whose CSS rule declares page-break-after: always
     */
    private function pageBreakClasses(string $html): array
    {
        $classes = [];
        if (preg_match_all('/([^{}]+)\{([^{}]*)\}/', $html, $rules, \PREG_SET_ORDER)) {
            foreach ($rules as $rule) {
                if (!preg_match('/(?:page-break-after\s*:\s*always|break-after\s*:\s*page)/i', $rule[2])) {
                    continue;
                }
                if (preg_match_all('/\.([\w-]+)/', $rule[1], $sel)) {
                    foreach ($sel[1] as $class) {
                        $classes[$class] = true;
                    }
                }
            }
        }

        return array_keys($classes);
    }

    private function countPages(string $html): int
    {
        $count = 0;
        foreach ($this->pageBreakClasses($html) as $class) {
            $count += preg_match_all('/\bclass="[^"]*\b'.preg_quote($class, '/').'\b[^"]*"/i', $html);
        }
        $count += preg_match_all('/style="[^"]*page-break-after\s*:\s*always[^"]*"/i', $html);

        return $count;
    }

    private function replaceTags(string $html, int $pages, \DateTimeInterface $date): string
    {
        $html = str_replace(['{nbpg}', '{PAGENO}'], [(string) $pages, '1'], $html);

        return preg_replace_callback('/\{DATE\s+([^}]+)\}/', static fn (array $m): string => $date->format(trim($m[1])), $html) ?? $html;
    }

    private function convertUnits(string $html): string
    {
        // Presentational HTML attributes only accept bare pixels: width="116mm" -> width="438".
        $html = preg_replace_callback('/\b(width|height)\s*=\s*"(-?[\d.]+)mm"/i', static fn (array $m): string => $m[1].'="'.(int) round((float) $m[2] * self::MM_TO_PX).'"', $html) ?? $html;

        // CSS lengths (style="" and <style> blocks): 30mm -> 113.39px.
        return preg_replace_callback('/(-?\d*\.?\d+)mm\b/', static fn (array $m): string => round((float) $m[1] * self::MM_TO_PX, 2).'px', $html) ?? $html;
    }

    private function inlineImages(string $html): string
    {
        $base = realpath($this->projectDir);
        if (false === $base) {
            return $html;
        }

        return preg_replace_callback('/(<img\b[^>]*\bsrc=")([^"]+)(")/i', static function (array $m) use ($base): string {
            $src = $m[2];
            if (str_starts_with($src, 'data:') || preg_match('#^(https?:)?//#i', $src)) {
                return $m[0];
            }

            $real = realpath($base.'/'.ltrim($src, '/'));
            if (false === $real || !str_starts_with($real, $base.\DIRECTORY_SEPARATOR) || !is_file($real)) {
                return $m[0];
            }

            $data = @file_get_contents($real);
            if (false === $data) {
                return $m[0];
            }

            $mime = match (strtolower(pathinfo($real, \PATHINFO_EXTENSION))) {
                'jpg', 'jpeg' => 'image/jpeg',
                'png' => 'image/png',
                'gif' => 'image/gif',
                'svg' => 'image/svg+xml',
                'webp' => 'image/webp',
                default => 'application/octet-stream',
            };

            return $m[1].'data:'.$mime.';base64,'.base64_encode($data).$m[3];
        }, $html) ?? $html;
    }

    private function renderPageBreaks(string $html): string
    {
        foreach ($this->pageBreakClasses($html) as $class) {
            $first = true;
            $html = preg_replace_callback('/<\w+(?=[^>]*\bclass="[^"]*\b'.preg_quote($class, '/').'\b)[^>]*>/i', static function (array $m) use (&$first): string {
                if ($first) {
                    $first = false;

                    return $m[0];
                }

                return '<hr class="ps-break"/>'.$m[0];
            }, $html) ?? $html;
        }

        return $html;
    }

    private function layout(string $html): string
    {
        [$pw, $ph] = $this->pageSize($html);
        [$mt, $mr, $mb, $ml] = $this->pageMargins($html);

        $header = $this->extractTag($html, 'htmlpageheader');
        $footer = $this->extractTag($html, 'htmlpagefooter');
        // Drop any further header/footer definitions left in the flow.
        $html = preg_replace('#<htmlpage(header|footer)\b[^>]*>.*?</htmlpage\1>#is', '', $html) ?? $html;

        $css = <<<CSS
            .ps-viewport { display: flex; justify-content: center; }
            .ps-sheet { position: relative; box-sizing: border-box; width: {$pw}px; min-height: {$ph}px; padding: {$mt}px {$mr}px {$mb}px {$ml}px; background: #fff; color: #1c1a16; box-shadow: 0 1px 3px rgba(20,18,14,.12), 0 8px 24px -10px rgba(20,18,14,.22); }
            .ps-sheet * { box-sizing: border-box; }
            .ps-header { position: absolute; top: 0; left: {$ml}px; right: {$mr}px; height: {$mt}px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
            .ps-header img { max-height: {$mt}px; width: auto; }
            .ps-footer { position: absolute; bottom: 0; left: {$ml}px; right: {$mr}px; }
            .ps-content { position: relative; }
            .ps-break { border: 0; border-top: 2px dashed #c9c4bc; margin: 22px 0; }
            CSS;

        return '<style>'.$css.'</style>'
            .'<div class="ps-viewport"><div class="ps-sheet">'
            .(null !== $header ? '<div class="ps-header">'.$header.'</div>' : '')
            .'<div class="ps-content">'.$html.'</div>'
            .(null !== $footer ? '<div class="ps-footer">'.$footer.'</div>' : '')
            .'</div></div>';
    }

    /**
     * @return array{0: int, 1: int} sheet width/height in px (defaults A6-ish width, auto height)
     */
    private function pageSize(string $html): array
    {
        if (preg_match('/sheet-size\s*:\s*([\d.]+)px\s+([\d.]+)px/i', $html, $m)) {
            return [(int) round((float) $m[1]), (int) round((float) $m[2])];
        }

        return [420, 0];
    }

    /**
     * @return array{0: int, 1: int, 2: int, 3: int} top/right/bottom/left margins in px
     */
    private function pageMargins(string $html): array
    {
        if (preg_match('/@page\s*\{([^}]*)\}/i', $html, $block)
            && preg_match('/(?<![\w-])margin\s*:\s*([^;]+);/i', $block[1], $m)) {
            $parts = preg_split('/\s+/', trim($m[1])) ?: [];
            $px = array_map(static fn (string $v): int => (int) round((float) $v), $parts);

            return match (\count($px)) {
                1 => [$px[0], $px[0], $px[0], $px[0]],
                2 => [$px[0], $px[1], $px[0], $px[1]],
                3 => [$px[0], $px[1], $px[2], $px[1]],
                default => [$px[0] ?? 0, $px[1] ?? 0, $px[2] ?? 0, $px[3] ?? 0],
            };
        }

        return [0, 0, 0, 0];
    }

    private function extractTag(string &$html, string $tag): ?string
    {
        if (!preg_match('#<'.$tag.'\b[^>]*>(.*?)</'.$tag.'>#is', $html, $m)) {
            return null;
        }

        $html = preg_replace('#<'.$tag.'\b[^>]*>.*?</'.$tag.'>#is', '', $html, 1) ?? $html;

        return $m[1];
    }
}
