<?php
/**
 * @link https://craftcms.com/
 * @copyright Copyright (c) Pixel & Tonic, Inc.
 * @license https://craftcms.github.io/license/
 */

namespace craft\helpers;

use Craft;
use craft\errors\ImageException;
use craft\image\Svg;

/**
 * Class Image
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since 3.0
 */
class Image
{
    // Constants
    // =========================================================================

    const EXIF_IFD0_ROTATE_180 = 3;
    const EXIF_IFD0_ROTATE_90 = 6;
    const EXIF_IFD0_ROTATE_270 = 8;

    // Public Methods
    // =========================================================================

    /**
     * Calculates a missing target dimension for an image.
     *
     * @param int|float|null $targetWidth
     * @param int|float|null $targetHeight
     * @param int|float $sourceWidth
     * @param int|float $sourceHeight
     * @return int[] Array of the width and height.
     */
    public static function calculateMissingDimension($targetWidth, $targetHeight, $sourceWidth, $sourceHeight): array
    {
        $factor = $sourceWidth / $sourceHeight;

        if (empty($targetHeight)) {
            $targetHeight = ceil($targetWidth / $factor);
        } else if (empty($targetWidth)) {
            $targetWidth = ceil($targetHeight * $factor);
        }

        return [(int)$targetWidth, (int)$targetHeight];
    }

    /**
     * Returns whether an image extension is considered manipulatable.
     *
     * @param string $extension
     * @return bool
     */
    public static function canManipulateAsImage(string $extension): bool
    {
        $formats = Craft::$app->getImages()->getSupportedImageFormats();

        $alwaysManipulatable = ['svg'];
        $neverManipulatable = ['pdf', 'json', 'html', 'htm'];

        $formats = array_merge($formats, $alwaysManipulatable);
        $formats = array_diff($formats, $neverManipulatable);

        return in_array(strtolower($extension), $formats);
    }

    /**
     * Returns a list of web safe image formats.
     *
     * @return string[]
     */
    public static function webSafeFormats(): array
    {
        return ['jpg', 'jpeg', 'gif', 'png', 'svg', 'webp'];
    }

    /**
     * Returns any info that’s embedded in a given PNG file.
     *
     * Adapted from https://github.com/ktomk/Miscellaneous/tree/master/get_png_imageinfo.
     *
     * @param string $file The path to the PNG file.
     * @author Tom Klingenberg <lastflood.net>
     * @license Apache 2.0
     * @version 0.1.0
     * @link http://www.libpng.org/pub/png/spec/iso/index-object.html#11IHDR
     * @return array|bool Info embedded in the PNG file, or `false` if it wasn’t found.
     */
    public static function pngImageInfo(string $file)
    {
        if (empty($file)) {
            return false;
        }

        $info = unpack(
            'A8sig/Nchunksize/A4chunktype/Nwidth/Nheight/Cbit-depth/Ccolor/Ccompression/Cfilter/Cinterface',
            file_get_contents($file, 0, null, 0, 29)
        );

        if (empty($info)) {
            return false;
        }

        $sig = array_shift($info);

        if ($sig != "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A" && $sig != "\x89\x50\x4E\x47\x0D\x0A\x1A") {
            // The file doesn't have a PNG signature
            return false;
        }

        if (array_shift($info) != 13) {
            // The IHDR chunk has the wrong length
            return false;
        }

        if (array_shift($info) !== 'IHDR') {
            // A non-IHDR chunk singals invalid data
            return false;
        }

        $color = $info['color'];

        $type = [
            0 => 'Greyscale',
            2 => 'Truecolour',
            3 => 'Indexed-colour',
            4 => 'Greyscale with alpha',
            6 => 'Truecolor with alpha'
        ];

        if (empty($type[$color])) {
            // Invalid color value
            return false;
        }

        $info['color-type'] = $type[$color];
        $samples = ((($color % 4) % 3) ? 3 : 1) + ($color > 3);
        $info['channels'] = $samples;

        return $info;
    }

    /**
     * Returns whether an image can have EXIF information embedded.
     *
     * @param string $filePath the file path to check.
     * @return bool
     */
    public static function canHaveExifData(string $filePath): bool
    {
        $extension = pathinfo($filePath, PATHINFO_EXTENSION);

        return in_array(StringHelper::toLowerCase($extension), ['jpg', 'jpeg', 'tiff'], true);
    }

    /**
     * Clean an image provided by path from all malicious code and the like.
     *
     * @param string $imagePath
     */
    public static function cleanImageByPath(string $imagePath)
    {
        $extension = pathinfo($imagePath, PATHINFO_EXTENSION);

        if (static::canManipulateAsImage($extension)) {
            Craft::$app->getImages()->cleanImage($imagePath);
        }
    }

    /**
     * Returns the size of an image based on its file path.
     *
     * @param string $filePath The path to the image
     * @return array [width, height]
     */
    public static function imageSize(string $filePath): array
    {
        try {
            if (FileHelper::isSvg($filePath)) {
                $svg = file_get_contents($filePath);
                return static::parseSvgSize($svg);
            }

            $image = Craft::$app->getImages()->loadImage($filePath);
            return [$image->getWidth(), $image->getHeight()];
        } catch (\Throwable $exception) {
            return [0, 0];
        }
    }

    /**
     * Determines image dimensions by a stream pointing to the start of the image.
     *
     * @param resource $stream
     * @return array|false
     * @throws \TypeError
     */
    public static function imageSizeByStream($stream)
    {
        if (!is_resource($stream)) {
            throw new \TypeError('Argument passed should be a resource.');
        }

        $dimensions = [];

        // PNG 8 byte signature 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A
        // GIF 6 byte signature 0x47 0x49 0x46 0x38 0x39|0x37 0x61
        // JPG 2 byte signature 0xFF 0xD8

        // It's much easier to work with a HEX string here, because of variable signature lengths
        $signature = StringHelper::toUpperCase(bin2hex(stream_get_contents($stream, 2)));

        try {
            switch ($signature) {
                // Must be JPG
                case 'FFD8':
                    // List of JPEG frame types we know how to extract size from
                    $validFrames = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF];

                    while (true) {
                        // Read JPEG frame info.
                        $frameInfo = unpack('Cmarker/Ctype/nlength', stream_get_contents($stream, 4));

                        if ($frameInfo['marker'] !== 0xFF) {
                            throw new ImageException('Unrecognized JPG file structure.');
                        }

                        // Ran out of file so something must be wrong.
                        if (!$frameInfo['length']) {
                            break;
                        }

                        if (in_array($frameInfo['type'], $validFrames, true)) {
                            // Dud.
                            stream_get_contents($stream, 1);

                            // Load dimensions
                            $data = unpack('nheight/nwidth', stream_get_contents($stream, 4));
                            $dimensions = [$data['width'], $data['height']];
                            break;
                        }

                        // Dud.
                        stream_get_contents($stream, $frameInfo['length'] - 2);
                    }
                    break;
                // Probably GIF
                case '4749':
                    $signature .= bin2hex(stream_get_contents($stream, 4));

                    // Make sure it's GIF
                    if (!in_array($signature, ['474946383961', '474946383761'], true)) {
                        throw new ImageException('Unrecognized image signature.');
                    }

                    // Unpack next 4 bytes as two unsigned integers with little endian byte order and call it a day
                    $data = unpack('v2', stream_get_contents($stream, 4));
                    $dimensions = array_values($data);
                    break;
                // Maybe PNG
                case '8950':
                    $signature .= StringHelper::toUpperCase(bin2hex(stream_get_contents($stream, 6)));

                    // Make sure it's PNG
                    if ($signature !== '89504E470D0A1A0A') {
                        throw new ImageException('Unrecognized image signature.');
                    }

                    // Dud.
                    stream_get_contents($stream, 4);

                    // IHDR chunk MUST be first
                    $ihdr = bin2hex(stream_get_contents($stream, 4));
                    if ($ihdr !== '49484452') {
                        throw new ImageException('Unrecognized PNG file structure.');
                    }

                    // Unpack next 8 bytes as two unsigned long integers with big endian byte order and call it a day
                    $data = unpack('N2', stream_get_contents($stream, 8));
                    $dimensions = array_values($data);

                    break;
                default:
                    return false;
            }
        } catch (ImageException $exception) {
            Craft::info($exception->getMessage(), __METHOD__);
        }

        return $dimensions;
    }

    /**
     * Parses SVG data and determines its size (normalized to pixels).
     *
     * @param string $svg The SVG data
     * @return array [width, height]
     */
    public static function parseSvgSize(string $svg): array
    {
        if (
            preg_match(Svg::SVG_WIDTH_RE, $svg, $widthMatch) &&
            preg_match(Svg::SVG_HEIGHT_RE, $svg, $heightMatch) &&
            ($matchedWidth = (float)$widthMatch[2]) &&
            ($matchedHeight = (float)$heightMatch[2])
        ) {
            $width = floor(
                $matchedWidth * self::_getSizeUnitMultiplier($widthMatch[3])
            );
            $height = floor(
                $matchedHeight * self::_getSizeUnitMultiplier($heightMatch[3])
            );
        } else if (preg_match(Svg::SVG_VIEWBOX_RE, $svg, $viewboxMatch)) {
            $width = floor($viewboxMatch[3]);
            $height = floor($viewboxMatch[4]);
        } else {
            // Just pretend it's 100x100
            $width = 100;
            $height = 100;
        }

        return [$width, $height];
    }

    // Private Methods
    // =========================================================================

    /**
     * Returns the multiplier that should be used to convert an image size unit to pixels.
     *
     * @param string $unit
     * @return float The multiplier
     */
    private static function _getSizeUnitMultiplier(string $unit): float
    {
        $ppi = 72;

        switch ($unit) {
            case 'px':
                return 1;
            case 'in':
                return $ppi;
            case 'pt':
                return $ppi / 72;
            case 'pc':
                return $ppi / 6;
            case 'cm':
                return $ppi / 2.54;
            case 'mm':
                return $ppi / 25.4;
            case 'em':
                return 16;
            case 'ex':
                return 10;
            default:
                return 1;
        }
    }
}
