<?php
/**
 * @author Victor Dubiniuk <dubiniuk@owncloud.com>
 *
 * @copyright Copyright (c) 2018, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */

/**
 * Class for bitmap image conversion
 */

namespace OC\Image;

class BmpToResource {
	public const MAGIC = 19778; // ASCII BM
	public const BITMAP_HEADER_SIZE_BYTES = 14;

	public const DIB_BITMAPINFOHEADER_SIZE_BYTES = 40;

	public const COMPRESSION_BI_RGB = 0;
	public const COMPRESSION_BI_BITFIELDS = 3;

	/** @var \SplFileObject $file */
	private $file;

	/** @var array $header */
	private $header = [];

	/** @var array $palette */
	private $palette = [];

	/** @var string[][] $pixelArray */
	private $pixelArray;

	private \GdImage $resource;

	/** @var array $bytesPerDepth */
	private $bytesPerDepth = [
		1 => 1,
		4 => 1,
		8 => 1,
		16 => 2,
		24 => 3,
		32 => 3,
	];

	/**
	 * BmpToResource constructor.
	 *
	 * @param string $fileName
	 */
	public function __construct($fileName) {
		$this->file = new \SplFileObject($fileName, 'rb');
	}

	/**
	 * @throws \Exception
	 */
	public function toResource(): \GdImage {
		try {
			$this->header = $this->readBitmapHeader();
			$this->header += $this->readDibHeader();
			if ($this->header['compression'] === self::COMPRESSION_BI_BITFIELDS) {
				$this->header += $this->readBitMasks();
			}
			// Color Table is mandatory for color depths <= 8 bits
			if ($this->header['bits'] <= 8) {
				$this->palette = $this->readColorTable($this->header['colors']);
			}

			$this->pixelArray = $this->readPixelArray();

			// create gd image
			$resource = \imagecreatetruecolor($this->header['width'], $this->header['height']);
			if ($resource === false) {
				throw new \RuntimeException('imagecreatetruecolor failed for file ' . $this->getFilename() . '" with dimensions ' . $this->header['width'] . 'x' . $this->header['height']);
			}
			$this->resource = $resource;

			$this->pixelArrayToImage();
		} catch (\Exception $e) {
			$this->file = null;
			throw $e;
		}

		$this->file = null;
		return $this->resource;
	}

	/**
	 * @return array
	 */
	public function getHeader() {
		return $this->header;
	}

	/**
	 * https://en.wikipedia.org/wiki/BMP_file_format#Bitmap_file_header
	 * @return array
	 */
	private function readBitmapHeader() {
		$bitmapHeaderArray = @\unpack('vtype/Vfilesize/Vreserved/Voffset', $this->readFile(self::BITMAP_HEADER_SIZE_BYTES));
		if (!isset($bitmapHeaderArray['type']) || $bitmapHeaderArray['type'] !== self::MAGIC) {
			throw new \DomainException('No valid bitmap signature found in ' . $this->getFilename());
		}
		return [
			'filesize' => $bitmapHeaderArray['filesize'],
			'offset' =>  $bitmapHeaderArray['offset'],
		];
	}

	/**
	 * https://en.wikipedia.org/wiki/BMP_file_format#DIB_header_.28bitmap_information_header.29
	 * @return array
	 */
	private function readDibHeader() {
		$dibHeaderSizeArray = @\unpack('Vheadersize', $this->readFile(4));
		if (!isset($dibHeaderSizeArray['headersize']) || $dibHeaderSizeArray['headersize'] < self::DIB_BITMAPINFOHEADER_SIZE_BYTES) {
			throw new \UnexpectedValueException('Unsupported DIB header version in ' . $this->getFilename());
		}
		$rawDibHeader = $this->readFile($dibHeaderSizeArray['headersize'] - 4);
		$dibHeader = @\unpack('Vwidth/Vheight/vplanes/vbits/Vcompression/Vimagesize/Vxres/Vyres/Vcolors/Vimportant', $rawDibHeader);

		// fixup colors
		$dibHeader['colors'] = $dibHeader['colors'] === 0 ? \pow(2, $dibHeader['bits']) : $dibHeader['colors'];

		// fixup imagesize - it can be zero
		if ($dibHeader['imagesize'] < 1) {
			$dibHeader['imagesize'] = $this->fixImageSize($dibHeader);
		}

		if ($dibHeader['imagesize'] < 1) {
			throw new \UnexpectedValueException('Can not obtain image size of ' . $this->getFilename());
		}

		$validBitDepth = \array_keys($this->bytesPerDepth);
		if (!\in_array($dibHeader['bits'], $validBitDepth, true)) {
			throw new \UnexpectedValueException('Bit Depth ' . $dibHeader['bits'] . ' in ' . $this->getFilename() . ' is not supported');
		}

		return $dibHeader;
	}
	
	private function fixImageSize($header) {
		// No compression - calculate it in our own
		if ($header['compression'] === self::COMPRESSION_BI_RGB) {
			$bytesPerRow = (int)(\floor(($header['bits'] * $header['width'] + 31) / 32) * 4);
			$imageSize = $bytesPerRow * \abs($header['height']);
		} else {
			$imageSize = $this->file->getSize() - $this->header['offset'];
		}
		
		return $imageSize;
	}

	/**
	 * @return array
	 */
	private function readBitMasks() {
		return @\unpack('VrMask/VgMask/VbMask', $this->readFile(12));
	}

	/**
	 * Read a color table
	 * http://www.dragonwins.com/domains/getteched/bmp/bmpfileformat.htm#The%20Color%20Table
	 * four bytes are ordered as follows:
	 * [ZERO][RED][GREEN][BLUE] Little Endian
	 * @param int $colors
	 * @return array
	 */
	private function readColorTable($colors) {
		$palette = @\unpack('V' . $colors, $this->readFile($colors * 4));
		return \array_values($palette);
	}

	/**
	 * @return string[][]
	 */
	private function readPixelArray() {
		// there is a gap possible after the header
		$this->file->fseek($this->header['offset'], SEEK_SET);
		$pixelString = $this->readFile($this->header['imagesize']);

		$bytesPerRow = (int)(\floor(($this->header['bits'] * $this->header['width'] + 31) / 32) * 4);
		$plainPixelArray = \str_split($pixelString, $bytesPerRow);

		// Positive height: Bottom row first.
		// Negative height: Upper row first
		$plainPixelArray = ($this->header['height']<0) ? \array_reverse($plainPixelArray) : $plainPixelArray;

		$bytesPerColumn = $this->bytesPerDepth[$this->header['bits']];
		$pixelArray = [];
		foreach ($plainPixelArray as $pixelRow) {
			$pixelArray[] = \str_split($pixelRow, $bytesPerColumn);
		}

		return $pixelArray;
	}

	private function pixelArrayToImage(): \GdImage {
		$x = 0;
		$y = 0;
		foreach ($this->pixelArray as $pixelRow) {
			foreach ($pixelRow as $column) {
				$colors = $this->getColors($column);
				foreach ($colors as $color) {
					\imagesetpixel($this->resource, $x, $y, $color);
					$x++;
					if ($x>=$this->header['width']) {
						$x=0;
						break(2);
					}
				}
			}
			$y++;
			if ($y >= \abs($this->header['height'])) {
				break;
			}
		}
		return $this->resource;
	}

	/**
	 * Get a color(s) of the current pixel(s)
	 * @param string $raw
	 * @return array
	 */
	private function getColors($raw) {
		$extra = \chr(0); // used to complement an argument to word or double word
		$colors = [];
		if (\in_array($this->header['bits'], [32, 24], true)) {
			$colors = @\unpack('V', $raw . $extra);
		} elseif ($this->header['bits'] === 16) {
			$colors = @\unpack('v', $raw);
			if (!isset($this->header['rMask']) || $this->header['rMask'] != 0xf800) {
				$colors[1] = (($colors[1] & 0x7c00) >> 7) * 65536 + (($colors[1] & 0x03e0) >> 2) * 256 + (($colors[1] & 0x001f) << 3); // 555
			} else {
				$colors[1] = (($colors[1] & 0xf800) >> 8) * 65536 + (($colors[1] & 0x07e0) >> 3) * 256 + (($colors[1] & 0x001f) << 3); // 565
			}
		} elseif (\in_array($this->header['bits'], [8, 4, 1])) {
			$colors = \array_map(
				function ($i) {
					return $this->palette[ \bindec($i) ];
				},
				$this->splitByteIntoArray($raw, $this->header['bits'])
			);
		}

		return \array_values($colors);
	}

	/**
	 * Split a byte into array of its binary digits
	 * @param string $byte a single char
	 * @param int $bitsPerPart how many bits should be in one part
	 * @return array
	 */
	private function splitByteIntoArray($byte, $bitsPerPart) {
		$code = \ord($byte);
		$stringOfBits = \str_pad(\decbin($code), 8, "0", \STR_PAD_LEFT);
		return \str_split($stringOfBits, $bitsPerPart);
	}

	/**
	 * @param string $bytesToRead
	 * @return string
	 */
	protected function readFile($bytesToRead) {
		$data = $this->file->fread($bytesToRead);
		if ($data === false) {
			throw new \LengthException('Unexpected end of file. ' . $this->getFilename());
		}
		return $data;
	}

	/**
	 * @codeCoverageIgnore
	 * @return string
	 */
	protected function getFilename() {
		return $this->file->getFilename();
	}
}
