<?php

/**
 * @package    Grav\Common\Page
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common\Page\Medium;

use BadFunctionCallException;
use Grav\Common\Data\Blueprint;
use Grav\Common\Media\Interfaces\ImageManipulateInterface;
use Grav\Common\Media\Interfaces\ImageMediaInterface;
use Grav\Common\Media\Interfaces\MediaLinkInterface;
use Grav\Common\Media\Traits\ImageLoadingTrait;
use Grav\Common\Media\Traits\ImageDecodingTrait;
use Grav\Common\Media\Traits\ImageFetchPriorityTrait;
use Grav\Common\Media\Traits\ImageMediaTrait;
use Grav\Common\Utils;
use Gregwar\Image\Image;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
use function func_get_args;
use function in_array;

/**
 * Class ImageMedium
 * @package Grav\Common\Page\Medium
 */
class ImageMedium extends Medium implements ImageMediaInterface, ImageManipulateInterface
{
    use ImageMediaTrait;
    use ImageLoadingTrait;
    use ImageDecodingTrait;
    use ImageFetchPriorityTrait;

    /**
     * @var mixed|string
     */
    private $saved_image_path;

    /**
     * Construct.
     *
     * @param array $items
     * @param Blueprint|null $blueprint
     */
    public function __construct($items = [], ?Blueprint $blueprint = null)
    {
        parent::__construct($items, $blueprint);

        $config = $this->getGrav()['config'];

        $this->thumbnailTypes = ['page', 'media', 'default'];
        $this->default_quality = $config->get('system.images.default_image_quality', 85);
        $this->def('debug', $config->get('system.images.debug'));

        $path = $this->get('filepath');
        if (!$path || !file_exists($path) || !filesize($path)) {
            return;
        }

        $this->set('thumbnails.media', $path);

        if (!($this->offsetExists('width') && $this->offsetExists('height') && $this->offsetExists('mime'))) {
            // Only the file name says this is an image; short or damaged content raises a notice or a warning.
            $image_info = @getimagesize($path);
            if ($image_info) {
                $this->def('width', (int) $image_info[0]);
                $this->def('height', (int) $image_info[1]);
                $this->def('mime', $image_info['mime']);
            }
        }

        $this->reset();

        if ($config->get('system.images.cache_all', false)) {
            $this->cache();
        }
    }

    /**
     * @return array
     */
    public function getMeta(): array
    {
        return [
            'width' => $this->width,
            'height' => $this->height,
        ] + parent::getMeta();
    }

    /**
     * Also unset the image on destruct.
     */
    #[\ReturnTypeWillChange]
    public function __destruct()
    {
        $this->image = null;
    }

    /**
     * Also clone image.
     */
    #[\ReturnTypeWillChange]
    public function __clone()
    {
        if ($this->image) {
            $this->image = clone $this->image;
        }

        parent::__clone();
    }

    /**
     * Reset image.
     *
     * @return $this
     */
    public function reset()
    {
        parent::reset();

        // A reset medium is the original again. The default filters applied
        // below may queue changes of their own.
        $this->transformed = false;

        if ($this->image) {
            $this->image();
            $this->medium_querystring = [];
            $this->filter();
            $this->clearAlternatives();
        }

        $this->format = 'guess';
        $this->quality = $this->default_quality;
        $this->queued_size = null;

        $this->debug_watermarked = false;
        $this->watermarked = null;

        $config = $this->getGrav()['config'];
        // Set CLS configuration
        $this->auto_sizes = $config->get('system.images.cls.auto_sizes', false);
        $this->aspect_ratio = $config->get('system.images.cls.aspect_ratio', false);
        $this->retina_scale = $config->get('system.images.cls.retina_scale', 1);

        // Lets a plugin queue actions on every image once it is back to the original,
        // for example from the image's own metadata.
        $grav = $this->getGrav();
        if ($grav['events']->hasListeners('onImageMediumReset')) {
            $grav->fireEvent('onImageMediumReset', new Event(['medium' => $this]));
        }

        return $this;
    }

    /**
     * Add meta file for the medium.
     *
     * @param string $filepath
     * @return $this
     */
    public function addMetaFile($filepath)
    {
        parent::addMetaFile($filepath);

        // Apply filters in meta file
        $this->reset();

        return $this;
    }

    /**
     * Return PATH to image.
     *
     * @param bool $reset
     * @return string path to image
     */
    public function path($reset = true)
    {
        $output = $this->saveImage();

        if ($reset) {
            $this->reset();
        }

        return $output;
    }

    /**
     * Return URL to image.
     *
     * @param bool $reset
     * @param bool $include_host Prepend the scheme and host, as `page.url(true)` does
     * @return string
     */
    public function url($reset = true, $include_host = false)
    {
        $grav = $this->getGrav();

        // Serving the unmodified original: nothing is queued that changes its
        // pixels, format or quality. Honor a `url` override here, and only here,
        // so the original can be routed through a proxy while resized / cropped
        // derivatives keep serving straight from `images/`. Mirrors
        // MediaFileTrait::url().
        //
        // This asks what is queued rather than whether an image object is open.
        // reset() reopens the image once any action has run on the medium, so
        // testing for the object dropped the override for every later use of
        // the same file in the request. getgrav/grav#4298.
        $url = $this->transformed ? null : $this->get('url');
        if ($url) {
            // The original on disk, for auto_sizes to measure.
            $this->saved_image_path = $this->get('filepath');

            if ($reset) {
                $this->reset();
            }

            return $this->withHost((string)$url, $include_host);
        }

        /** @var UniformResourceLocator $locator */
        $locator = $grav['locator'];
        $image_path = (string)($locator->findResource('cache://images', true) ?: $locator->findResource('cache://images', true, true));
        $saved_image_path = $this->saved_image_path = $this->saveImage();

        $output = preg_replace('|^' . preg_quote(GRAV_ROOT, '|') . '|', '', $saved_image_path) ?: $saved_image_path;

        if ($locator->isStream($output)) {
            $output = (string)($locator->findResource($output, false) ?: $locator->findResource($output, false, true));
        }

        if (Utils::startsWith($output, $image_path)) {
            $image_dir = $locator->findResource('cache://images', false);
            $output = '/' . $image_dir . preg_replace('|^' . preg_quote($image_path, '|') . '|', '', $output);
        }

        if ($reset) {
            $this->reset();
        }

        return $this->withHost(trim($grav['base_url'] . '/' . $this->urlQuerystring($output), '\\'), $include_host);
    }

    /**
     * Return srcset string for this Medium and its alternatives.
     *
     * @param bool $reset
     * @return string
     */
    public function srcset($reset = true)
    {
        if (empty($this->alternatives)) {
            if ($reset) {
                $this->reset();
            }

            return '';
        }

        $srcset = [];
        foreach ($this->alternatives as $ratio => $medium) {
            $srcset[] = $medium->url($reset) . ' ' . $medium->get('width') . 'w';
        }
        $srcset[] = str_replace(' ', '%20', $this->url($reset)) . ' ' . $this->get('width') . 'w';

        return implode(', ', $srcset);
    }

    /**
     * Parsedown element for source display mode
     *
     * @param  array $attributes
     * @param  bool $reset
     * @return array
     */
    public function sourceParsedownElement(array $attributes, $reset = true)
    {
        empty($attributes['src']) && $attributes['src'] = $this->url(false);

        $srcset = $this->srcset($reset);
        if ($srcset) {
            empty($attributes['srcset']) && $attributes['srcset'] = $srcset;
            $attributes['sizes'] = $this->sizes();
        }

        if ($this->saved_image_path && $this->auto_sizes) {
            if (!array_key_exists('height', $this->attributes) && !array_key_exists('width', $this->attributes) && ($info = @getimagesize($this->saved_image_path))) {
                $width = (int)$info[0];
                $height = (int)$info[1];

                $scaling_factor = $this->retina_scale > 0 ? $this->retina_scale : 1;
                $attributes['width'] = (int)($width / $scaling_factor);
                $attributes['height'] = (int)($height / $scaling_factor);

                if ($this->aspect_ratio) {
                    $style = ($attributes['style'] ?? ' ') . "--aspect-ratio: $width/$height;";
                    $attributes['style'] = trim($style);
                }
            }
        }

        return ['name' => 'img', 'attributes' => $attributes];
    }

    /**
     * Turn the current Medium into a Link
     *
     * @param  bool $reset
     * @param  array  $attributes
     * @return MediaLinkInterface
     */
    public function link($reset = true, array $attributes = [])
    {
        $attributes['href'] = $this->url(false);
        $srcset = $this->srcset(false);
        if ($srcset) {
            $attributes['data-srcset'] = $srcset;
        }

        return parent::link($reset, $attributes);
    }

    /**
     * Turn the current Medium into a Link with lightbox enabled
     *
     * @param  int  $width
     * @param  int  $height
     * @param  bool $reset
     * @return MediaLinkInterface
     */
    public function lightbox($width = null, $height = null, $reset = true)
    {
        if ($this->mode !== 'source') {
            $this->display('source');
        }

        if ($width && $height) {
            $this->__call('cropResize', [(int) $width, (int) $height]);
        }

        return parent::lightbox($width, $height, $reset);
    }

    /**
     * @param string $enabled
     * @return $this
     */
    public function autoSizes($enabled = 'true')
    {
        $this->auto_sizes = $enabled === 'true' ?: false;

        return $this;
    }

    /**
     * @param string $enabled
     * @return $this
     */
    public function aspectRatio($enabled = 'true')
    {
        $this->aspect_ratio = $enabled === 'true' ?: false;

        return $this;
    }

    /**
     * @param int $scale
     * @return $this
     */
    public function retinaScale($scale = 1)
    {
        $this->retina_scale = (int)$scale;

        return $this;
    }

    /**
     * @param string|null $image
     * @param string|null $position
     * @param int|float|null $scale
     * @return $this
     */
    public function watermark($image = null, $position = null, $scale = null)
    {
        $grav = $this->getGrav();

        $locator = $grav['locator'];
        $config = $grav['config'];

        // `?watermark`, `?watermark=1` and a call with no image all use the
        // operator-configured watermark.
        $file = (string) ($image ?? '');
        if ($file === '' || $file === '1') {
            // No editor-supplied value: use the operator-configured (trusted) watermark.
            $file = $config->get('system.images.watermark.image');
            $watermark = $file ? $locator->findResource($file) : false;
        } else {
            // Editor-authored watermark from page content, composited into a
            // publicly cached derivative. Take it from a media folder, the same as
            // a stream image in markdown: a plain path resolves against the
            // install root, and a stream such as `user://` or `tmp://` reaches
            // accounts, configuration and site data. Anything else is ignored.
            $watermark = GlobalMedia::locate($file) ?? false;
        }

        if ($watermark === false) {
            return $this;
        }
        $watermark = ImageFile::open($watermark);

        $scale = ($scale === null || $scale === '') ? $config->get('system.images.watermark.scale', 100) : $scale;
        // "bottom-right", or one half of it with the other taken from config.
        $position = explode('-', (string) $position, 2);
        $position = ($position[0] ?: $config->get('system.images.watermark.position_y', 'center'))
            . '-' . ($position[1] ?? $config->get('system.images.watermark.position_x', 'center'));

        // Sized and placed when the image is processed, against the image as it
        // is by then, so a resize or crop before it is taken into account.
        // getgrav/grav#4322.
        $this->queueWatermark([$watermark, $position, (float) $scale / 100]);

        return $this;
    }

    /**
     * Handle this commonly used variant
     *
     * @return $this
     */
    public function cropZoom()
    {
        $this->__call('zoomCrop', func_get_args());

        return $this;
    }

    /**
     * Add a frame to image
     *
     * @return $this
     */
    public function addFrame(int $border = 10, string $color = '0x000000')
    {
      if($border > 0 && preg_match('/^0x[a-f0-9]{6}$/i', $color)) { // $border must be an integer and bigger than 0; $color must be formatted as an HEX value (0x??????).
        // `watermark_all` stamps the framed image once, when it is saved below,
        // not the image inside the frame as well. The image is opened first
        // because opening it reads `watermark_all` again.
        $stampFrame = false;
        if ($this->getGrav()['config']->get('system.images.watermark.watermark_all', false)) {
            $this->cache();
            $stampFrame = !$this->watermarked;
            $this->watermark = false;
        }
        $image = ImageFile::open($this->path());
        $this->watermark = $stampFrame;
      }
      else {
        return $this;
      }

      $dst_width = (int) ($image->width()+2*$border);
      $dst_height = (int) ($image->height()+2*$border);

      $frame = ImageFile::create($dst_width, $dst_height);

      $frame->__call('fill', [$color]);

      $this->image = $frame;

      $this->__call('merge', [$image, $border, $border]);

      $this->saveImage();

      return $this;

    }

    /**
     * Forward the call to the image processing method.
     *
     * @param string $method
     * @param mixed $args
     * @return $this|mixed
     */
    #[\ReturnTypeWillChange]
    public function __call($method, $args)
    {
        if (!in_array($method, static::$magic_actions, true)) {
            return parent::__call($method, $args);
        }

        // Always initialize image.
        if (!$this->image) {
            $this->image();
        }

        $this->transformed = true;

        try {
            $this->image->{$method}(...$args);

            /** @var ImageMediaInterface $medium */
            foreach ($this->alternatives as $key => $medium) {
                $args_copy = $args;

                // regular image: resize 400x400 -> 200x200
                // --> @2x: resize 800x800->400x400
                if (isset(static::$magic_resize_actions[$method])) {
                    foreach (static::$magic_resize_actions[$method] as $param) {
                        if (isset($args_copy[$param])) {
                            $args_copy[$param] *= $medium->get('ratio');
                        }
                    }

                    // Check the canvas after scaling the arguments for this alternative.
                    if (!$this->alternativeResizeFits($medium, $method, $args, $args_copy)) {
                        unset($this->alternatives[$key]);
                        continue;
                    }
                }

                // Do the same call for alternative media.
                $medium->__call($method, $args_copy);
            }
        } catch (BadFunctionCallException) {
        }

        return $this;
    }

    /**
     * Check the canvas and track dimensions through alternative resize actions.
     *
     * @param ImageMediaInterface $medium The alternative
     * @param string $method One of $magic_resize_actions
     * @param array $args The arguments as given for this image
     * @param array $scaled The arguments multiplied by the alternative's ratio
     * @return bool
     */
    protected function alternativeResizeFits(ImageMediaInterface $medium, string $method, array $args, array $scaled): bool
    {
        foreach (array_slice(static::$magic_resize_actions[$method], -2) as $param) {
            if (!isset($scaled[$param])) {
                continue;
            }
            if (is_numeric($args[$param]) && $args[$param] >= 1 && $scaled[$param] < 1) {
                return false;
            }
            // Whole pixels, as urlResizeCanvas() reads them.
            $scaled[$param] = (string) (int) ceil((float) $scaled[$param]);
        }

        $max_pixels = (int) $this->getGrav()['config']->get('system.images.max_pixels', 25000000);
        if ($max_pixels <= 0 || !$medium instanceof self) {
            return true;
        }

        [$width, $height] = $medium->queued_size ?? [(int) $medium->get('width'), (int) $medium->get('height')];
        $canvas = static::urlResizeCanvas($method, $scaled, $width, $height);
        if ($canvas === null) {
            return false;
        }

        [$pixels, $w, $h] = $canvas;
        if ($method === 'cropResize' || ($method === 'resize' && empty($scaled[3]) && empty($scaled[4]))) {
            // Neither enlarges: the result stays within the image as it is.
            $w = min($w, $width);
            $h = min($h, $height);
            $pixels = $w * $h;
        }
        if ($pixels > $max_pixels) {
            $this->getGrav()['log']->warning(sprintf(
                'Refusing to %s image alternative above system.images.max_pixels: %s (%d pixels at ratio %s of %s)',
                $method, $medium->get('filepath'), $pixels, $medium->get('ratio'), $this->get('filepath')
            ));

            return false;
        }

        $medium->queued_size = [(int) $w, (int) $h];

        return true;
    }
}
