<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Qr;

use PrettyLinks\chillerlan\QRCode\Common\EccLevel;
use PrettyLinks\chillerlan\QRCode\Output\QROutputInterface;
use PrettyLinks\chillerlan\QRCode\QRCode;
use PrettyLinks\chillerlan\QRCode\QROptions;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). No caching: these tables are the source
// of truth for click/redirect data and must read-through. "meta_key"/"meta_value" here
// refer to our own prli_link_metas table, not wp_postmeta.

/**
 * QR code generation for pretty links.
 *
 * URL contract:
 *   /{slug}/gen_qr_png?download=<nonce>  (PNG route/download contract preserved from 3.x)
 *   /{slug}/gen_qr_svg?download=<nonce>  (SVG had no download variant in 3.x or early 4.x; added for parity)
 *
 * Dispatched from the Redirect Engine via the `prli_handle_special_route`
 * filter before the normal redirect runs.
 */
class QrService
{
    /**
     * `prli_handle_special_route` filter callback.
     *
     * @param  boolean              $handled    Whether a prior handler already claimed the route.
     * @param  string               $route      Special route segment from the request.
     * @param  array<string, mixed> $link       Link row being served.
     * @param  string               $requestUri Raw request URI.
     * @return boolean
     */
    public function filter(bool $handled, string $route, array $link, string $requestUri): bool
    {
        if ($handled) {
            return $handled;
        }
        if ($route !== 'gen_qr_png' && $route !== 'gen_qr_svg') {
            return false;
        }
        $proOptions = (array) (get_option('prlipro_options', []) ?: []);
        if (empty($proOptions['generate_qr_codes'])) {
            return false;
        }
        if ($route === 'gen_qr_png') {
            $this->emitPng($link);
            exit;
        }
        $this->emitSvg($link);
        exit;
    }

    /**
     * Emit a PNG QR code for the given link.
     *
     * Inline access (no `download` param) is intentionally unauthenticated —
     * QR images are public assets meant to be embedded in pages and scanned
     * by visitors. The URL slug is the access key; sequential ID enumeration
     * is not possible. The `download` nonce prevents Cross-Site-Download
     * abuse where a forged link tricks the browser into saving a file.
     *
     * @param  array<string, mixed> $link Link row being served.
     * @return void
     */
    private function emitPng(array $link): void
    {
        $isDownload = $this->isDownloadRequest();

        $linkId = (int) ($link['id'] ?? 0);
        $data   = $this->prettyUrl($link);
        $output = $this->generate($data, 'png', $this->hasConfiguredLogo($linkId));

        // The generate() call above silently falls back to SVG markup
        // when ext-gd is missing (see canRenderPng()). Mirror that
        // fallback here so the logo compositor, Content-Type, and
        // download filename all agree with what was actually rendered
        // instead of labeling SVG bytes as a `.png` download.
        if ($this->canRenderPng()) {
            $output      = $this->maybeApplyLogo($output, $linkId);
            $contentType = 'image/png';
            $extension   = 'png';
        } else {
            $output      = $this->maybeApplySvgLogo($output, $linkId);
            $contentType = 'image/svg+xml';
            $extension   = 'svg';
        }

        nocache_headers();
        if (!headers_sent()) {
            header('Content-Type: ' . $contentType);
            if ($isDownload) {
                $slug = (string) ($link['slug'] ?? 'pretty-link');
                header('Content-Disposition: attachment; filename="' . $slug . '.' . $extension . '"');
            }
        }
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw image output; escaping would corrupt the data.
        echo $output;
    }

    /**
     * Whether this request is asking for a forced download
     * (`?download=<nonce>`). Dies with a 403 if the param is present but
     * fails nonce verification, so callers can treat a `true` return as
     * "safe to send Content-Disposition: attachment". Shared by emitPng()
     * and emitSvg() so the Cross-Site-Download protection can't drift
     * between formats.
     *
     * @return boolean True when `?download=` was present and passed nonce verification.
     */
    private function isDownloadRequest(): bool
    {
        if (!isset($_GET['download'])) {
            return false;
        }
        $nonce = sanitize_text_field(wp_unslash((string) $_GET['download']));
        if (!wp_verify_nonce($nonce, 'prli-generate-qr-code')) {
            wp_die(esc_html__('Invalid request', 'pretty-link-pro'), esc_html__('Pretty Links', 'pretty-link-pro'), ['response' => 403]);
        }
        return true;
    }

    /**
     * Composites the configured logo onto the QR PNG when one is set.
     *
     * @param  string  $png    Raw QR PNG bytes.
     * @param  integer $linkId Link ID whose per-link/global logo options apply.
     * @return string
     */
    private function maybeApplyLogo(string $png, int $linkId): string
    {
        $overlay = $this->resolveLogoOverlay($linkId);
        if ($overlay === null) {
            return $png;
        }

        return LogoOverlay::apply($png, $overlay['path'], $overlay['sizePercent'], $overlay['whiteBackground']);
    }

    /**
     * Whether a logo is configured for this link (per-link meta or global
     * Pro option). Checked before generate() so it can reserve enough QR
     * version headroom for the overlay that maybeApplyLogo()/
     * maybeApplySvgLogo() will apply afterward — see the versionMin
     * comment on generate() for why that matters.
     *
     * @param  integer $linkId Link ID, 0 when there's no specific link (e.g. the admin preview).
     * @return boolean
     */
    private function hasConfiguredLogo(int $linkId): bool
    {
        $proOptions = (array) (get_option('prlipro_options', []) ?: []);
        return (int) $this->resolveOption($linkId, 'qr_logo_attachment_id', $proOptions, 0) > 0;
    }

    /**
     * Embeds the configured logo into the QR SVG when one is set. Same
     * resolution rules as maybeApplyLogo() (per-link meta overrides the
     * global Pro option), just routed to the SVG-specific overlay since
     * GD compositing doesn't apply to markup.
     *
     * @param  string  $svg    Raw QR SVG markup.
     * @param  integer $linkId Link ID whose per-link/global logo options apply.
     * @return string
     */
    private function maybeApplySvgLogo(string $svg, int $linkId): string
    {
        $overlay = $this->resolveLogoOverlay($linkId);
        if ($overlay === null) {
            return $svg;
        }

        return SvgLogoOverlay::apply($svg, $overlay['path'], $overlay['sizePercent'], $overlay['whiteBackground']);
    }

    /**
     * Resolves the effective logo-overlay parameters for a link — per-link
     * meta overriding the global Pro option, same precedence as every
     * other QR setting. Shared by the PNG (GD) and SVG (markup) overlay
     * paths so attachment/size/background resolution can't drift between
     * them; only the actual compositing (LogoOverlay vs SvgLogoOverlay)
     * differs per format.
     *
     * @param  integer $linkId Link ID whose per-link/global logo options apply.
     * @return array{path: string, sizePercent: integer, whiteBackground: boolean}|null Null when no logo is configured, or the configured attachment is missing/unreadable.
     */
    private function resolveLogoOverlay(int $linkId): ?array
    {
        $proOptions = (array) (get_option('prlipro_options', []) ?: []);

        $attachmentId = (int) $this->resolveOption($linkId, 'qr_logo_attachment_id', $proOptions, 0);
        if ($attachmentId <= 0) {
            return null;
        }

        $logoPath = get_attached_file($attachmentId);
        if (!is_string($logoPath) || $logoPath === '' || !is_readable($logoPath)) {
            return null;
        }

        return [
            'path'            => $logoPath,
            'sizePercent'     => (int) $this->resolveOption($linkId, 'qr_logo_size_percent', $proOptions, 20),
            'whiteBackground' => (bool) $this->resolveOption($linkId, 'qr_logo_white_background', $proOptions, true),
        ];
    }

    /**
     * Render a preview PNG for arbitrary URL + overlay params. Used by the
     * admin options panel to let admins see their logo/size/bg choices
     * applied to a sample QR before saving. Bypasses the `generate_qr_codes`
     * master toggle — admins need to preview before enabling.
     *
     * @param  string  $data             URL to encode in the preview QR.
     * @param  integer $logoAttachmentId Attachment ID of the logo, 0 for none.
     * @param  integer $sizePercent      Logo size as a percentage of QR width.
     * @param  boolean $whiteBackground  When true, paints a white safe-area behind the logo.
     * @return void
     */
    public function emitPreview(string $data, int $logoAttachmentId, int $sizePercent, bool $whiteBackground): void
    {
        $canRenderPng = $this->canRenderPng();
        $output       = $this->generate($data, 'png', $logoAttachmentId > 0);

        if ($logoAttachmentId > 0) {
            $logoPath = get_attached_file($logoAttachmentId);
            if (is_string($logoPath) && $logoPath !== '' && is_readable($logoPath)) {
                // Same GD fallback as generate(); route to the matching
                // compositor instead of feeding SVG markup into the
                // GD-based LogoOverlay when ext-gd is missing.
                $output = $canRenderPng
                    ? LogoOverlay::apply($output, $logoPath, $sizePercent, $whiteBackground)
                    : SvgLogoOverlay::apply($output, $logoPath, $sizePercent, $whiteBackground);
            }
        }

        nocache_headers();
        if (!headers_sent()) {
            header('Content-Type: ' . ($canRenderPng ? 'image/png' : 'image/svg+xml'));
        }
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw image output; escaping would corrupt the data.
        echo $output;
    }

    /**
     * Resolves an option value, preferring per-link meta over the global Pro option.
     *
     * @param  integer              $linkId     Link ID for the per-link meta lookup, 0 to skip.
     * @param  string               $key        Option/meta key to resolve.
     * @param  array<string, mixed> $proOptions Global Pro options array.
     * @param  mixed                $default    Fallback value when neither source has a value.
     * @return mixed
     */
    private function resolveOption(int $linkId, string $key, array $proOptions, $default)
    {
        if ($linkId > 0) {
            $perLink = $this->linkMeta($linkId, $key);
            if ($perLink !== null && $perLink !== '') {
                return $perLink;
            }
        }
        return array_key_exists($key, $proOptions) ? $proOptions[$key] : $default;
    }

    /**
     * Reads a single per-link meta value.
     *
     * @param  integer $linkId Link ID to read the meta from.
     * @param  string  $key    Meta key to read.
     * @return string|null
     */
    private function linkMeta(int $linkId, string $key): ?string
    {
        global $wpdb;
        $value = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT meta_value FROM {$wpdb->prefix}prli_link_metas
                 WHERE link_id = %d AND meta_key = %s LIMIT 1",
                $linkId,
                $key
            )
        );
        return is_string($value) ? $value : null;
    }

    /**
     * Emits an SVG QR code for the given link.
     *
     * Supports the same `?download=<nonce>` forced-download contract as
     * emitPng() — added for parity after the link-form QR tab grew a
     * "Download SVG" button that, without this, relied solely on the HTML
     * `download` attribute (unreliable across browsers for cross-origin
     * requests and for the `image/svg+xml` MIME type specifically).
     *
     * @param  array<string, mixed> $link Link row being served.
     * @return void
     */
    private function emitSvg(array $link): void
    {
        $isDownload = $this->isDownloadRequest();

        $linkId = (int) ($link['id'] ?? 0);
        $data   = $this->prettyUrl($link);
        $svg    = $this->generate($data, 'svg', $this->hasConfiguredLogo($linkId));
        $svg    = $this->maybeApplySvgLogo($svg, $linkId);

        nocache_headers();
        if (!headers_sent()) {
            header('Content-Type: image/svg+xml');
            if ($isDownload) {
                $slug = (string) ($link['slug'] ?? 'pretty-link');
                header('Content-Disposition: attachment; filename="' . $slug . '.svg"');
            }
        }
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw SVG document output; escaping would break the XML.
        echo $svg;
    }

    /**
     * Builds the public pretty URL encoded into the QR code.
     *
     * @param  array<string, mixed> $link Link row being served.
     * @return string
     */
    private function prettyUrl(array $link): string
    {
        // Stored slug is the full URL path — prefix was baked in at link
        // creation time.
        return \PrettyLinks\Helpers\LinkUrl::build((string) ($link['slug'] ?? ''));
    }

    /**
     * Generate the raw QR image bytes (PNG or SVG markup).
     *
     * Uses chillerlan/php-qrcode (PHP 7.4+, MIT). High ECC level so a
     * centered logo can cover up to ~30% of modules without breaking
     * scannability. PNG output requires ext-gd; if it's missing we fall
     * back to SVG so the request still succeeds with a valid image.
     *
     * @param  string  $data                URL or payload to encode.
     * @param  string  $format              Desired output format ('png' or 'svg').
     * @param  boolean $reserveSpaceForLogo Whether a logo overlay will be applied after this call.
     * @return string
     */
    private function generate(string $data, string $format, bool $reserveSpaceForLogo = false): string
    {
        $wantsPng = $format === 'png';

        $options = new QROptions([
            'eccLevel'      => EccLevel::H,
            'outputType'    => $wantsPng && $this->canRenderPng()
                ? QROutputInterface::GDIMAGE_PNG
                : QROutputInterface::MARKUP_SVG,
            // Raw bytes / markup. Default is base64 data-URI which would
            // require the controller to decode before echoing.
            'outputBase64'  => false,
            // 10px per module ≈ 370–500px square depending on URL length.
            'scale'         => 10,
            // Quiet zone in modules. QR spec recommends 4; we keep that.
            'quietzoneSize' => 4,
            // On short URLs the auto-picked QR version has few enough
            // error-correction codewords that a max-size logo (30%) plus
            // its white-background padding can exceed what ECC level H
            // can recover — the erasure lands disproportionately inside
            // one or two Reed-Solomon blocks even though it looks modest
            // as a fraction of the whole grid. Forcing at least version 6
            // adds enough headroom that this reliably decodes regardless
            // of link length. Empirically verified across several 1-3
            // character slugs and logo styles (opaque/noisy/photographic)
            // at max size + white background — the worst-case combination.
            'versionMin'    => $reserveSpaceForLogo ? 6 : 1,
        ]);

        return (new QRCode($options))->render($data);
    }

    /**
     * Whether a 'png' format request will actually render as PNG. False
     * when ext-gd is missing, in which case generate() silently falls
     * back to SVG markup instead. Single source of truth so emitPng()
     * and emitPreview() can mirror generate()'s fallback — matching the
     * response headers, logo compositor, and download filename to what
     * was actually rendered instead of mislabeling SVG bytes as a PNG.
     *
     * @return boolean
     */
    private function canRenderPng(): bool
    {
        return extension_loaded('gd');
    }
}
