<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Qr;

/**
 * Embeds a logo image into a QR SVG document.
 *
 * Counterpart to LogoOverlay, which composites onto a PNG via GD. SVG has
 * no raster canvas to draw on, so instead this inlines the logo's own
 * bytes as a base64 data URI inside an <image> element layered on top of
 * the QR paths — no pixel decoding, so unlike LogoOverlay this doesn't
 * need ext-gd. That also means it works on the same GD-less hosts that
 * QrService::generate() already falls back to SVG for.
 *
 * Coordinates are computed in the QR's own viewBox units (QRMarkupSVG
 * sizes the viewBox to the module grid, not pixels), read back out of the
 * already-rendered markup rather than recomputed, so this stays correct
 * regardless of QR version/quiet-zone size chosen upstream.
 */
class SvgLogoOverlay
{
    private const SUPPORTED_MIME_TYPES = [
        'image/jpeg',
        'image/png',
        'image/gif',
        'image/webp',
    ];

    /**
     * Embeds the logo into the given QR SVG markup and returns the result.
     *
     * @param  string  $svg             Raw SVG markup from QRCode::render().
     * @param  string  $logoPath        Absolute filesystem path to the logo (jpg/png/gif/webp).
     * @param  integer $sizePercent     Percentage of the QR width occupied by the logo (10..30).
     * @param  boolean $whiteBackground When true, draws a white safe-area rect behind the logo.
     * @return string SVG markup with the logo embedded, or the untouched input on any failure.
     */
    public static function apply(string $svg, string $logoPath, int $sizePercent, bool $whiteBackground): string
    {
        // Quotes accepted either way even though chillerlan's own renderer
        // only ever emits double quotes — a future library version (or a
        // custom Output subclass) that switches styles shouldn't silently
        // drop the logo just because this regex was written narrower than
        // it needed to be.
        if (!preg_match('/viewBox=["\']0 0 (?<w>[\d.]+) (?<h>[\d.]+)["\']/', $svg, $viewBox)) {
            return $svg;
        }

        $info = self::readImage($logoPath);
        if ($info === null) {
            return $svg;
        }
        [$mime, $bytes, $logoSrcW, $logoSrcH] = $info;
        if ($logoSrcW <= 0) {
            return $svg;
        }

        $viewWidth  = (float) $viewBox['w'];
        $viewHeight = (float) $viewBox['h'];

        $sizePercent = max(10, min(30, $sizePercent));
        $logoWidth   = $viewWidth * $sizePercent / 100;
        $logoHeight  = $logoWidth * ($logoSrcH / $logoSrcW);
        $logoX       = ($viewWidth - $logoWidth) / 2;
        $logoY       = ($viewHeight - $logoHeight) / 2;

        $overlay = '';
        if ($whiteBackground) {
            $padding  = $logoWidth * 0.1;
            $overlay .= sprintf(
                '<rect x="%1$s" y="%2$s" width="%3$s" height="%4$s" fill="#fff"/>',
                self::num($logoX - $padding),
                self::num($logoY - $padding),
                self::num($logoWidth + $padding * 2),
                self::num($logoHeight + $padding * 2)
            );
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- data-URI encoding, not obfuscation.
        $dataUri  = 'data:' . $mime . ';base64,' . base64_encode($bytes);
        $overlay .= sprintf(
            '<image x="%1$s" y="%2$s" width="%3$s" height="%4$s" href="%5$s" xlink:href="%5$s"/>',
            self::num($logoX),
            self::num($logoY),
            self::num($logoWidth),
            self::num($logoHeight),
            $dataUri
        );

        // Chillerlan's header() only declares xmlns, not xmlns:xlink — add
        // it so `xlink:href` above validates when the .svg is opened as a
        // standalone file (some older/non-browser SVG consumers still
        // require the namespaced attribute rather than bare `href`).
        if (strpos($svg, 'xmlns:xlink') === false) {
            // A regex engine failure (e.g. a backtrack/recursion limit)
            // would make preg_replace() return null; `?? $svg` keeps this
            // fail-safe — matching every other failure path in this
            // method — instead of a null flowing into the second
            // preg_replace() below and eventually violating this
            // method's `string` return type.
            $svg = preg_replace('/<svg /', '<svg xmlns:xlink="http://www.w3.org/1999/xlink" ', $svg, 1) ?? $svg;
        }

        return preg_replace('/<\/svg>/', $overlay . '</svg>', $svg, 1) ?? $svg;
    }

    /**
     * Reads and validates the logo file.
     *
     * @param  string $path Absolute filesystem path to the logo.
     * @return array{0: string, 1: string, 2: int, 3: int}|null Tuple of [mime, raw bytes, width, height], or null if unreadable/unsupported.
     */
    private static function readImage(string $path): ?array
    {
        if (!is_readable($path)) {
            return null;
        }
        // Suppress warning: getimagesize() warns on corrupt/unrecognised files.
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Intentional: getimagesize() warns on corrupt files; the false return is handled below.
        $size = @getimagesize($path);
        if ($size === false || !in_array($size['mime'], self::SUPPORTED_MIME_TYPES, true)) {
            return null;
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local media-library file, not a remote URL.
        $bytes = file_get_contents($path);
        if ($bytes === false) {
            return null;
        }
        return [$size['mime'], $bytes, (int) $size[0], (int) $size[1]];
    }

    /**
     * Formats a computed coordinate for the SVG markup, trimming trailing
     * zeros so the output stays reasonably tidy.
     *
     * @param  float $value Coordinate or length in viewBox units.
     * @return string
     */
    private static function num(float $value): string
    {
        // %F (not %f) is required here: %f uses the current LC_NUMERIC
        // locale's decimal separator (e.g. "12,500" under a comma-decimal
        // locale), which would emit invalid SVG coordinates. %F always
        // uses a period regardless of locale.
        $formatted = rtrim(rtrim(sprintf('%.3F', $value), '0'), '.');
        return $formatted === '' ? '0' : $formatted;
    }
}
