<?php

namespace ProudNerds\Assets;

use Psr\Log\LoggerInterface;
use Spatie\ImageOptimizer\OptimizerChain;
use Spatie\ImageOptimizer\OptimizerChainFactory;

class ImageOptimizer
{
    /**
     * For debugging purposes, you can enable logging all output from
     * Spatie\ImageOptimizer\OptimizerChain to the error log
     */
    private const ENABLE_LOGGING = false;

    /**
     * For debugging purposes, you can enable output to {wp_uploads_dir}/optimized
     */
    private const OUTPUT_TO_DIR = false;

    /**
     * The optimizer chain
     *
     * @var OptimizerChain
     */
    private OptimizerChain $optimizerChain;

    /**
     * Missing binaries
     */
    private array $missingBinaries = [];

    /**
     * Missing functions
     */
    private array $missingFunctions = [];

    /**
     * ImageOptimizer constructor.
     */
    private function __construct()
    {
        // Check if required functions are available
        $this->checkRequiredFunctions();

        // Show notice on upload pages
        add_action('admin_notices', [$this, 'showOptimizerNotice']);

        // Abort if required functions are not available
        if (!empty($this->missingFunctions)) {
            return;
        }

        // Add filter to file upload
        add_filter('wp_handle_upload', [$this, 'optimizeImage'], 1);
        // Allow SVG uploads
        add_filter('upload_mimes', [$this, 'allowSvgUploads']);
    }

    /**
     * Initialize the class
     */
    public static function init(): static
    {
        return new static();
    }

    /**
     * Show a notice if the required binaries are not installed
     *
     * @return void
     */
    public function showOptimizerNotice(): void
    {
        // Check if shell_exec is available, if not show an error
        if (!empty($this->missingFunctions)) {
            $message = '<div class="notice notice-error">';
            $message .= '<p>' . __('Afbeeldingen kunnen niet worden geoptimaliseerd omdat de volgende functies niet beschikbaar zijn:', 'proudnerds') . '</p>';
            $message .= '<ul>';

            foreach ($this->missingFunctions as $function) {
                $message .= sprintf('<li>- <code>%s</code></li>', $function);
            }

            $message .= '</ul>';
            $message .= '<p>' . __('Neem contact op met de serverbeheerder om de benodigde functies in te schakelen.', 'proudnerds') . '</p>';
            $message .= '</div>';

            echo $message;

            return;
        }

        // Get the current page
        global $pagenow;

        // The pages where we want to show the notice
        $pages = [
            'media-new.php',
            'upload.php',
        ];

        // Only show notice on media upload pages
        if (!in_array($pagenow, $pages)) {
            return;
        }

        // Get the optimizer chain to trigger the missing binaries check
        $this->getOptimizerChain();

        // If there are no missing binaries, return
        if (empty($this->missingBinaries)) {
            return;
        }

        $message = '<div class="notice notice-error">';
        if ($this->archIsArm()) {
            $message .= '<p>' . __('Het lijkt erop dat je ARM architectuur gebruikt. Hiervoor zijn niet alle binaries beschikbaar. Installeer ze direct op de server om optimalisatie te laten werken.', 'proudnerds') . '</p>';
        }
        $message .= '<p>' . __('Sommige afbeeldingen worden niet geoptimaliseerd omdat de benodigde binaries niet beschikbaar zijn:', 'proudnerds') . '</p>';
        $message .= '<ul>';

        // Show missing binaries
        foreach ($this->missingBinaries as $binary) {
            $mimeTypes = $this->getOptimizerMimetypes($binary);
            $message .= sprintf('<li>- <code>%s</code> (%s)</li>', $binary, implode(', ', $mimeTypes));
        }

        $message .= '</ul>';
        $message .= '<p>' . sprintf(
            __('Neem contact op met de serverbeheerder om de benodigde binaries te installeren, of bekijk de %s.', 'proudnerds'),
            '<a href="https://github.com/spatie/image-optimizer?tab=readme-ov-file#installation " target="_blank">' . __('installatie instructies', 'proudnerds') . '</a>'
        ) . '</p>';
        $message .= '</div>';

        echo $message;
    }

    /**
     * Allow SVG uploads
     *
     * @param array $mimes Mime types
     * @return array
     */
    public function allowSvgUploads(array $mimes)
    {
        $mimes['svg'] = 'image/svg+xml'; // NOSONAR
        $mimes['svgz'] = 'image/svg+xml';

        return $mimes;
    }

    /**
     * Optimize image on upload
     *
     * @param array $file File array (keys: file: path, url: url, type: mimetype)
     * @return array
     */
    public function optimizeImage(array $file): array // NOSONAR
    {
        // Only optimize selected file types
        if (!in_array($file['type'], $this->getMimeTypes())) {
            return $file;
        }

        // Get the optimizer chain
        $optimizerChain = $this->getOptimizerChain();

        // Enable logging if set to true
        if (static::ENABLE_LOGGING) {
            $optimizerChain->useLogger($this->getLogger());
        }

        // If the file is an SVG, and we don't have SVGO installed, we can't optimize it so show an error
        if ($file['type'] === 'image/svg+xml' && in_array('svgo', $this->missingBinaries)) {
            return [
                'error' => __('Voor de veiligheid kunnen SVG bestanden niet worden geüpload omdat SVGO niet is geïnstalleerd op de server. Installeer SVGO om dit mogelijk te maken.', 'proudnerds'),
            ];
        }

        try {
            $optimizerChain->optimize($file['file'], $this->getOutputPath($file['file']));
        } catch (\Exception $e) {
            error_log($e->getMessage());

            return [
                'error' => sprintf(__('Er is een fout opgetreden tijdens het optimaliseren van het bestand: %s', 'proudnerds'), $e->getMessage()),
            ];
        }

        return $file;
    }

    /**
     * Get all possible mimetypes for the optimizers
     *
     * @return array
     */
    private function getMimeTypes(): array
    {
        $mapping = $this->optimizerMapping();

        $mimetypes = [];

        foreach ($mapping as $types) {
            $mimetypes = array_merge($mimetypes, $types);
        }

        return $mimetypes;
    }

    /**
     * Get the mimetypes for the optimizer by it's binary name
     *
     * @param string $binary
     * @return array
     */
    private function getOptimizerMimetypes(string $binary): array
    {
        $mapping = $this->optimizerMapping();

        return $mapping[$binary] ?? [];
    }

    /**
     * Get the optimizer/mimetype mapping
     *
     * @return array
     */
    private function optimizerMapping(): array
    {
        return [
            'jpegoptim' => [
                'image/jpeg',
                'image/jpg',
            ],
            'jpegtran' => [
                'image/jpeg',
                'image/jpg',
            ],
            'pngquant' => [
                'image/png',
            ],
            'optipng' => [
                'image/png',
            ],
            'gifsicle' => [
                'image/gif',
            ],
            'svgo' => [
                'image/svg+xml',
            ],
            'cwebp' => [
                'image/webp',
            ],
            /**
             * AVIF is disabled because the avifenc binary does not seem to work on Debian because of libjpeg.so.8 not being available,
             * this library is used by avifenc to optimize AVIF images.
             *
             * @see https://unix.stackexchange.com/questions/687633/why-is-libjpeg-so-8-missing-from-debian
             *
             * 'avifenc' => [
             *    'image/avif',
             * ],
             */
        ];
    }

    /**
     * Get the output directory
     *
     * @return string|null
     */
    private function getOutputPath($filePath = ''): ?string
    {
        if (!static::OUTPUT_TO_DIR) {
            return null;
        }

        $uploadDir = wp_upload_dir();
        $outputDir = $uploadDir['basedir'] . '/optimized';

        if (!is_dir($outputDir)) {
            mkdir($outputDir, 0755, true);
        }

        return $outputDir . \DIRECTORY_SEPARATOR . basename($filePath);
    }

    /**
     * Check if required functions are available
     *
     * @return bool
     */
    private function checkRequiredFunctions(): bool
    {
        $required = [
            'shell_exec',
            'proc_open',
            'proc_close',
        ];

        foreach ($required as $function) {
            if (!is_callable($function) || false !== stripos(ini_get('disable_functions'), $function)) {
                $this->missingFunctions[] = $function;
            }
        }

        return empty($this->missingFunctions);
    }

    /**
     * Get an anonymous logger class which just logs everything to the error log
     *
     * @return LoggerInterface
     */
    private function getLogger(): LoggerInterface
    {
        return new class() implements LoggerInterface {
            public function emergency($message, array $context = []): void
            {
                error_log($message);
            }

            public function alert($message, array $context = []): void
            {
                error_log($message);
            }

            public function critical($message, array $context = []): void
            {
                error_log($message);
            }

            public function error($message, array $context = []): void
            {
                error_log($message);
            }

            public function warning($message, array $context = []): void
            {
                error_log($message);
            }

            public function notice($message, array $context = []): void
            {
                error_log($message);
            }

            public function info($message, array $context = []): void
            {
                error_log($message);
            }

            public function debug($message, array $context = []): void
            {
                error_log($message);
            }

            public function log($level, $message, array $context = []): void
            {
                error_log($message);
            }
        };
    }

    /**
     * Check if the CPU architecture is ARM
     *
     * @return bool
     */
    private function archIsArm(): bool
    {
        return php_uname('m') === 'aarch64';
    }

    /**
     * Get the optimizer chain
     *
     * @return OptimizerChain
     */
    private function getOptimizerChain(): OptimizerChain
    {
        // Return the optimizer chain if it's already set
        if (isset($this->optimizerChain)) {
            return $this->optimizerChain;
        }

        $optimizers = OptimizerChainFactory::create();

        // Set the local binary directory
        $localBinDir = '';

        // Only use local binaries on non-ARM architectures
        if (!$this->archIsArm()) {
            $localBinDir = \get_stylesheet_directory() . '/bin';
        }

        /** @var \Spatie\ImageOptimizer\BaseOptimizer $optimizer */
        foreach ($optimizers->getOptimizers() as $optimizer) {
            // Check if the binary is locally available
            $binary = $localBinDir . \DIRECTORY_SEPARATOR . $optimizer->binaryName();

            if (\file_exists($binary)) {
                // chmod +x the binary
                \chmod($binary, 0755);

                // Set the binary path
                $optimizer->setBinaryPath($localBinDir);
            }
        }

        // Set the optimizer chain
        $this->setMissingBinaries($optimizers);

        // Set the optimizer chain
        return $optimizers;
    }

    /**
     * Set missing binaries by checking if the binaries are available on the command line
     *
     * @param OptimizerChain $optimizers
     * @return void
     */
    private function setMissingBinaries(OptimizerChain $optimizers): void
    {
        $missingBinaries = [];

        // If shell_exec is not available, we can't do this check
        if (in_array('shell_exec', $this->missingFunctions)) {
            $this->missingBinaries = $missingBinaries;
            return;
        }

        /** @var \Spatie\ImageOptimizer\BaseOptimizer $optimizer */
        foreach ($optimizers->getOptimizers() as $optimizer) {
            $bin = $optimizer->binaryName();
            $binPath = $optimizer->binaryPath . $bin;

            // If the current binary is not in the mapping, don't check if it's available
            if (!array_key_exists($bin, $this->optimizerMapping())) {
                continue;
            }

            if (!shell_exec('command -v ' . $binPath)) {
                $missingBinaries[] = $bin;
            }
        }

        // If there are no missing binaries, return
        if (empty($missingBinaries)) {
            return;
        }

        // Set missing binaries
        $this->missingBinaries = $missingBinaries;
    }
}
