<?php

declare(strict_types=1);

namespace Intervention\Image\Drivers\Gd\Analyzers;

use Intervention\Image\Analyzers\ResolutionAnalyzer as GenericResolutionAnalyzer;
use Intervention\Image\Exceptions\AnalyzerException;
use Intervention\Image\Exceptions\StreamException;
use Intervention\Image\Exceptions\InvalidArgumentException;
use Intervention\Image\Exceptions\MissingDependencyException;
use Intervention\Image\Interfaces\ImageInterface;
use Intervention\Image\Interfaces\OriginInterface;
use Intervention\Image\Interfaces\SpecializedInterface;
use Intervention\Image\Resolution;
use Intervention\Image\Traits\CanBuildStream;
use Throwable;

class ResolutionAnalyzer extends GenericResolutionAnalyzer implements SpecializedInterface
{
    use CanBuildStream;

    /**
     * {@inheritdoc}
     *
     * @see AnalyzerInterface::analyze()
     *
     * @throws InvalidArgumentException
     * @throws AnalyzerException
     */
    public function analyze(ImageInterface $image): mixed
    {
        $result = imageresolution($image->core()->native());

        if (!is_array($result)) {
            throw new AnalyzerException('Failed to read image resolution');
        }

        // GD returns 96x96 as resolution by default even if the image has no resolution.
        // This is problematic because it is impossible to tell whether the image
        // really has this resolution or whether it just corresponds to the default value.
        //
        // If GD's default resolution is returned here and the resolution is still unchanged
        // we will make an attempt to find the resolution from origin.
        if ($this->isGdDefaultResolution($result) && $image->core()->meta()->get('resolutionChanged') !== true) {
            try {
                $alternativeResoltion = $this->readResolutionFromOrigin($image->origin());
            } catch (Throwable) {
                $alternativeResoltion = [96, 96];
            }

            $result = $alternativeResoltion !== $result ? $alternativeResoltion : $result;
        }

        return new Resolution(...$result);
    }

    /**
     * @param array<int|float> $resolution
     */
    private function isGdDefaultResolution(array $resolution): bool
    {
        return intval($resolution[0] ?? 0) === 96 && intval($resolution[1] ?? 0) === 96;
    }

    /**
     * @throws AnalyzerException
     * @throws InvalidArgumentException
     * @throws StreamException
     * @return array<float>
     */
    private function readResolutionFromOrigin(OriginInterface $origin): array
    {
        $handle = self::buildStreamOrFail(file_get_contents($origin->filePath()));

        try {
            try {
                return $this->resolutionFromJfifHeader($handle);
            } catch (Throwable) {
                # code ...
            }

            try {
                return $this->resolutionFromExifHeader($handle);
            } catch (Throwable) {
                # code ...
            }

            try {
                return $this->resolutionFromPngPhys($handle);
            } catch (Throwable) {
                # code ...
            }

            throw new AnalyzerException('Unable to read resolution from path');
        } finally {
            fclose($handle);
        }
    }

    /**
     * @param resource $handle
     * @throws AnalyzerException
     * @return array<float>
     */
    private function resolutionFromJfifHeader($handle): array
    {
        // read first 20 bytes
        rewind($handle);
        $header = fread($handle, 20);

        // find the JFIF segment
        $offset = strpos($header, 'JFIF');
        if ($offset === false) {
            throw new AnalyzerException('Unable to read JFIF header');
        }

        // read bytes at known offsets relative to JFIF
        $units = ord($header[$offset + 7]);
        $x = unpack('n', substr($header, $offset + 8, 2))[1];
        $y = unpack('n', substr($header, $offset + 10, 2))[1];

        if ($units === 2) { // unit is dots per cm → convert to DPI
            return [round($x * 2.54), round($y * 2.54)];
        }

        return [$x, $y]; // unit is DPI or no unit
    }

    /**
     * @param resource $handle
     * @throws MissingDependencyException
     * @throws AnalyzerException
     * @return array<float>
     */
    private function resolutionFromExifHeader($handle): array
    {
        if (!function_exists('exif_read_data')) {
            throw new MissingDependencyException('Unable to read exif data');
        }

        rewind($handle);
        $data = @exif_read_data($handle, null, true);

        if ($data === false) {
            throw new AnalyzerException('Unable to read exif data');
        }

        if (isset($data['XResolution']) && isset($data['YResolution'])) {
            $resolution = [$data['XResolution'], $data['YResolution']];
        }

        if (isset($data['IFD0']) && isset($data['IFD0']['XResolution']) && isset($data['IFD0']['YResolution'])) {
            $resolution = [$data['IFD0']['XResolution'], $data['IFD0']['YResolution']];
        }

        if (!isset($resolution)) {
            throw new AnalyzerException('Unable to read exif data');
        }

        return array_map(function (mixed $value): int|float {
            if (strpos($value, '/') === false) {
                return $value;
            }

            $values = array_map(fn(string $value): int => intval($value), explode('/', $value));

            if ($values[1] === 0) {
                throw new AnalyzerException('Unable to read exif data, division by zero');
            }

            return $values[0] / $values[1];
        }, $resolution);
    }

    /**
     * @param resource $handle
     * @throws AnalyzerException
     * @return array<float>
     */
    private function resolutionFromPngPhys($handle): array
    {
        rewind($handle);
        $signature = fread($handle, 8);

        // no PNG content
        if ($signature !== "\x89PNG\x0D\x0A\x1A\x0A") {
            throw new AnalyzerException('Input must be PNG format');
        }

        $marker = '';

        while (!feof($handle)) {
            $marker = strlen($marker) < 4 ? $marker . fread($handle, 1) : substr($marker, 1) . fread($handle, 1);

            // find pHYs chunk
            if ($marker === 'pHYs') {
                // find length
                fseek($handle, -8, SEEK_CUR);
                $length = fread($handle, 4);
                $length = unpack('N', $length)[1];
                fseek($handle, 4, SEEK_CUR);

                // pHYs chunk must be exactly 9 bytes
                if ($length !== 9) {
                    throw new AnalyzerException('Invalid pHYs chunk length');
                }

                // read data
                $data = fread($handle, $length);

                $x = unpack('N', substr($data, 0, 4))[1];
                $y = unpack('N', substr($data, 4, 4))[1];
                $unit = ord(substr($data, 8, 1));

                // unit=1 means pixels per metre → convert to DPI
                // unit=0 means unknown unit (just a ratio) → return raw values
                if ($unit === 1) {
                    return [
                        round($x * .0254),
                        round($y * .0254),
                    ];
                }

                return [$x, $y];
            }
        }

        return [0, 0];
    }
}
