<?php

declare(strict_types=1);

namespace Image;

use BadMethodCallException;
use GdFont;
use GdImage;
use Image\Exception\MyGdImageException;
use InvalidArgumentException;

/**
 * @brief Class to use GD functions in an object-oriented way.
 *
 * Each GD function imageXYZ($resource, ...) is mapped to $this->XYZ(...)
 * through __call() as $resource is a property of GDImage
 *
 * @see https://www.php.net/manual/fr/ref.image.php
 *
 * @author Jérôme Cutrona
 *
 * @method static          GdFont|false loadfont(string $filename)
 * @method bool            setstyle(array $style)
 * @method bool            istruecolor()
 * @method bool            truecolortopalette(bool $dither, int $num_colors)
 * @method bool            palettetotruecolor()
 * @method bool            colormatch(MyGdImage $image2)
 * @method bool            setthickness(int $thickness)
 * @method bool            filledellipse(int $center_x, int $center_y, int $width, int $height, int $color)
 * @method bool            filledarc(int $center_x, int $center_y, int $width, int $height, int $start_angle, int $end_angle, int $color, int $style)
 * @method bool            alphablending(bool $enable)
 * @method bool            savealpha(bool $enable)
 * @method bool            layereffect(int $effect)
 * @method int|false       colorallocatealpha(int $red, int $green, int $blue, int $alpha)
 * @method int             colorresolvealpha(int $red, int $green, int $blue, int $alpha)
 * @method int             colorclosestalpha(int $red, int $green, int $blue, int $alpha)
 * @method int             colorexactalpha(int $red, int $green, int $blue, int $alpha)
 * @method bool            copyresampled(MyGdImage $src_image, int $dst_x, int $dst_y, int $src_x, int $src_y, int $dst_width, int $dst_height, int $src_width, int $src_height)
 * @method MyGdImage|false rotate(float $angle, int $background_color, bool $ignore_transparent = false)
 * @method bool            settile(MyGdImage $tile)
 * @method bool            setbrush(MyGdImage $brush)
 * @method static          int types()
 * @method bool            xbm(?string $filename, ?int $foreground_color = null)
 * @method bool            avif($file = null, int $quality = -1, int $speed = -1)
 * @method bool            gif($file = null)
 * @method bool            png($file = null, int $quality = -1, int $filters = -1)
 * @method bool            webp($file = null, int $quality = -1)
 * @method bool            jpeg($file = null, int $quality = -1)
 * @method bool            wbmp($file = null, ?int $foreground_color = null)
 * @method bool            gd(?string $file = null)
 * @method bool            gd2(?string $file = null, int $chunk_size, int $mode)
 * @method bool            bmp($file = null, bool $compressed = true)
 * @method bool            destroy()
 * @method int|false       colorallocate(int $red, int $green, int $blue)
 * @method void            palettecopy(MyGdImage $src)
 * @method int|false       colorat(int $x, int $y)
 * @method int             colorclosest(int $red, int $green, int $blue)
 * @method int             colorclosesthwb(int $red, int $green, int $blue)
 * @method bool            colordeallocate(int $color)
 * @method int             colorresolve(int $red, int $green, int $blue)
 * @method int             colorexact(int $red, int $green, int $blue)
 * @method ?bool           colorset(int $color, int $red, int $green, int $blue, int $alpha = 0)
 * @method array           colorsforindex(int $color)
 * @method bool            gammacorrect(float $input_gamma, float $output_gamma)
 * @method bool            setpixel(int $x, int $y, int $color)
 * @method bool            line(int $x1, int $y1, int $x2, int $y2, int $color)
 * @method bool            dashedline(int $x1, int $y1, int $x2, int $y2, int $color)
 * @method bool            rectangle(int $x1, int $y1, int $x2, int $y2, int $color)
 * @method bool            filledrectangle(int $x1, int $y1, int $x2, int $y2, int $color)
 * @method bool            arc(int $center_x, int $center_y, int $width, int $height, int $start_angle, int $end_angle, int $color)
 * @method bool            ellipse(int $center_x, int $center_y, int $width, int $height, int $color)
 * @method bool            filltoborder(int $x, int $y, int $border_color, int $color)
 * @method bool            fill(int $x, int $y, int $color)
 * @method int             colorstotal()
 * @method int             colortransparent(?int $color = null)
 * @method bool            interlace(?bool $enable = null)
 * @method bool            polygon(array $points, int $num_points_or_color, ?int $color = null)
 * @method bool            openpolygon(array $points, int $num_points_or_color, ?int $color = null)
 * @method bool            filledpolygon(array $points, int $num_points_or_color, ?int $color = null)
 * @method static          int fontwidth(GdFont|int $font)
 * @method static          int fontheight(GdFont|int $font)
 * @method bool            char(GdFont|int $font, int $x, int $y, string $char, int $color)
 * @method bool            charup(GdFont|int $font, int $x, int $y, string $char, int $color)
 * @method bool            string(GdFont|int $font, int $x, int $y, string $string, int $color)
 * @method bool            stringup(GdFont|int $font, int $x, int $y, string $string, int $color)
 * @method bool            copy(MyGdImage $src_image, int $dst_x, int $dst_y, int $src_x, int $src_y, int $src_width, int $src_height)
 * @method bool            copymerge(MyGdImage $src_image, int $dst_x, int $dst_y, int $src_x, int $src_y, int $src_width, int $src_height, int $pct)
 * @method bool            copymergegray(MyGdImage $src_image, int $dst_x, int $dst_y, int $src_x, int $src_y, int $src_width, int $src_height, int $pct)
 * @method bool            copyresized(MyGdImage $src_image, int $dst_x, int $dst_y, int $src_x, int $src_y, int $dst_width, int $dst_height, int $src_width, int $src_height)
 * @method int             sx()
 * @method int             sy()
 * @method bool            setclip(int $x1, int $y1, int $x2, int $y2)
 * @method array           getclip()
 * @method static          array|false ftbbox(float $size, float $angle, string $font_filename, string $string, array $options = [])
 * @method array|false     fttext(float $size, float $angle, int $x, int $y, int $color, string $font_filename, string $text, array $options = [])
 * @method static          array|false ttfbbox(float $size, float $angle, string $font_filename, string $string, array $options = [])
 * @method array|false     ttftext(float $size, float $angle, int $x, int $y, int $color, string $font_filename, string $text, array $options = [])
 * @method bool            filter(int $filter, $args)
 * @method bool            convolution(array $matrix, float $divisor, float $offset)
 * @method bool            flip(int $mode)
 * @method bool            antialias(bool $enable)
 * @method MyGdImage|false crop(array $rectangle)
 * @method MyGdImage|false cropauto(int $mode = IMG_CROP_DEFAULT, float $threshold = 0.5, int $color = -1)
 * @method MyGdImage|false scale(int $width, int $height = -1, int $mode = IMG_BILINEAR_FIXED)
 * @method MyGdImage|false affine(array $affine, ?array $clip = null)
 * @method static          array|false affinematrixget(int $type, $options)
 * @method static          array|false affinematrixconcat(array $matrix1, array $matrix2)
 * @method int             getinterpolation()
 * @method bool            setinterpolation(int $method = IMG_BILINEAR_FIXED)
 * @method array|bool      resolution(?int $resolution_x = null, ?int $resolution_y = null)
 */
class MyGdImage
{
    public const GD = 'gd';
    public const GD2PART = 'gd2part';
    public const GD2 = 'gd2';
    public const GIF = 'gif';
    public const JPEG = 'jpeg';
    public const PNG = 'png';
    public const WBMP = 'wbmp';
    public const XBM = 'xbm';
    public const XPM = 'xpm';

    /**
     * Factory known types.
     */
    private static array $factoryTypes = [
        self::GD,
        self::GD2PART,
        self::GD2,
        self::GIF,
        self::JPEG,
        self::PNG,
        self::WBMP,
        self::XBM,
        self::XPM,
    ];

    /**
     * Image resource.
     */
    private ?GdImage $gdImage = null;

    /**
     * Private constructor.
     */
    private function __construct(GdImage|false $gdImage)
    {
        $this->gdImage = $gdImage;
    }

    /**
     * Destructor.
     */
    public function __destruct()
    {
        if (!is_null($this->gdImage)) {
            imagedestroy($this->gdImage);
        }
    }

    /**
     * Factory to create a MyGdImage instance from width and height parameters.
     *
     * @param int  $width     Image width
     * @param int  $height    Image height
     * @param bool $trueColor creates a true color image if true and a palette based image otherwise
     *
     * @return MyGdImage
     *
     * @throws MyGdImageException when image creation fails
     */
    public static function createFromSize(int $width, int $height, bool $trueColor = true): self
    {
        $gdImage = $trueColor ? @imagecreatetruecolor($width, $height) : @imagecreate($width, $height);
        if (false === $gdImage) {
            throw new MyGdImageException('Failed to create GD resource');
        }

        return new self($gdImage);
    }

    /**
     * Factory to create a MyGdImage instance from filename and filetype paramters.
     *
     * @param string $filename name of the file
     * @param string $filetype type of the file (must be an element of self::$_factory_types)
     *
     * @return MyGdImage
     *
     * @throws MyGdImageException when image creation fails
     */
    public static function createFromFile(string $filename, string $filetype): self
    {
        if (!is_file($filename)) {
            throw new MyGdImageException("{$filename}: no such file");
        }
        if (!in_array($filetype, self::$factoryTypes)) {
            throw new MyGdImageException("unknown filetype '{$filetype}'");
        }
        $functionName = "imageCreateFrom{$filetype}";
        if (($gdImage = @$functionName($filename)) === false) {
            throw new MyGdImageException("unable to load file '{$filename}'");
        }

        return new self($gdImage);
    }

    /**
     * Factory to create a MyGdImage instance from filename and filetype parameters.
     *
     * @return MyGdImage
     *
     * @throws MyGdImageException when image creation fails
     */
    public static function createFromString(string $data): self
    {
        if (($gdImage = imagecreatefromstring($data)) === false) {
            throw new MyGdImageException('unable to load data');
        }

        return new self($gdImage);
    }

    /**
     * @return mixed|MyGdImage
     */
    public function __call(string $methodName, array $methodArguments)
    {
        $gdFunction = "image{$methodName}";
        if (!function_exists($gdFunction)) {
            throw new BadMethodCallException('Unknown method call: '.get_class($this)."::{$methodName}");
        }
        // Prevent direct call of imageCreateFrom...
        if (mb_eregi('^imageCreateFrom', $gdFunction)) {
            throw new BadMethodCallException('Forbidden method call '.get_class($this)."::{$methodName}");
        }
        // Special case of copy functions
        if (mb_eregi('^(copy|colorMatch)', $methodName)) {
            // First parameter of the method should be an instance of the class
            if (!isset($methodArguments[0]) || !$methodArguments[0] instanceof self) {
                $type = is_object($methodArguments[0]) ? get_class($methodArguments[0]) : gettype($methodArguments[0]);
                throw new InvalidArgumentException("First parameter of '".get_class($this)."::{$methodName}' should be an instance of ".get_class($this).', '.$type.' given');
            }
            // Preparing argument for GD function call
            $methodArguments[0] = $methodArguments[0]->gdImage;
        }
        // Avoid function which first parameter is not an image resource
        if (!mb_eregi('^(imageFont|imageFtBbox|imageGrab|imageLoadFont|imagePs|imageTypes)', $gdFunction)) {
            // First parameter should be the image resource
            array_unshift($methodArguments, $this->gdImage);
        }
        // Call GD function
        $returnValue = @call_user_func_array($gdFunction, $methodArguments);
        if (null === $returnValue) {
            throw new BadMethodCallException('Error in '.get_class($this)."::{$methodName}");
        }
        if (is_a($returnValue, GdImage::class)) {
            return new self($returnValue);
        } else {
            return $returnValue;
        }
    }

    /**
     * Retrieve information about the currently installed GD library.
     *
     * @return array returns an associative array
     */
    public static function info(): array
    {
        return gd_info();
    }

    /**
     * Get the size of an image.
     *
     * @param string $filename  this parameter specifies the file you wish to retrieve information about
     * @param array  $imageInfo This optional parameter allows you to extract some extended information from the image file
     *
     * @return array an array with up to 7 elements. Not all image types will include the channels and bits elements. Index 0 and 1 contains respectively the width and the height of the image.
     */
    public static function getImageSize(string $filename, array &$imageInfo = []): array
    {
        return @getimagesize($filename, $imageInfo);
    }

    /**
     * Trap "inaccessible static methods" to invoke GD functions, if available.
     * If a method named 'colorAllocate' is trapped, it will try to invoke 'imageColorAllocate' function.
     *
     * @param string $methodName      name of the "inaccessible static method"
     * @param array  $methodArguments array of the arguments of the "inaccessible static method"
     *
     * @return mixed
     *
     * @throws BadMethodCallException
     */
    public static function __callStatic(string $methodName, array $methodArguments)
    {
        $gdFunction = !function_exists($methodName) ? "image{$methodName}" : $methodName;
        if (!function_exists($gdFunction) || mb_eregi('^imageCreateFrom', $gdFunction)) {
            throw new BadMethodCallException('Call to unknown static method '.get_class()."::{$methodName}");
        }
        $returnValue = call_user_func_array($gdFunction, $methodArguments);
        if (null === $returnValue) {
            throw new BadMethodCallException('Error in '.get_class()."::{$methodName}");
        }

        return $returnValue;
    }

    /**
     * Clone.
     *
     * @throws MyGdImageException when resource creation fails
     */
    public function __clone()
    {
        if (!imageistruecolor($this->gdImage)) {
            if (($resource = @imagecreate(imagesx($this->gdImage), imagesy($this->gdImage))) === false) {
                throw new MyGdImageException('unable to clone MyGdImage');
            }
            imagepalettecopy($resource, $this->gdImage);
        } else {
            if (($resource = @imagecreatetruecolor(imagesx($this->gdImage), imagesy($this->gdImage))) === false) {
                throw new MyGdImageException('unable to clone MyGdImage');
            }
        }
        imagecopy($resource, $this->gdImage, 0, 0, 0, 0, imagesx($this->gdImage), imagesy($this->gdImage));
        $this->gdImage = $resource;
    }

    /**
     * @throws MyGdImageException when output buffer capture fails
     */
    protected static function captureOutputBuffer(callable $callback): string
    {
        ob_start();
        $callback();
        $capturedOutputBuffer = ob_get_clean();
        if (false === $capturedOutputBuffer) {
            throw new MyGdImageException('failed to capture output buffer');
        }

        return $capturedOutputBuffer;
    }

    /**
     * @throws MyGdImageException when output buffer capture fails
     */
    public function getJpeg(int $quality = -1): string
    {
        return self::captureOutputBuffer(fn () => $this->jpeg(null, $quality));
    }

    /**
     * @throws MyGdImageException when output buffer capture fails
     */
    public function getPng(int $quality = -1, int $filters = -1): string
    {
        return self::captureOutputBuffer(fn () => $this->png(null, $quality, $filters));
    }

    /**
     * @throws MyGdImageException when output buffer capture fails
     */
    public function getGif(): string
    {
        return self::captureOutputBuffer(fn () => $this->gif(null));
    }
}