<?php
/**
 * Class MaskPattern
 *
 * @created      19.01.2021
 * @author       ZXing Authors
 * @author       Smiley <smiley@chillerlan.net>
 * @copyright    2021 Smiley
 * @license      Apache-2.0
 */
declare(strict_types=1);

namespace chillerlan\QRCode\Common;

use chillerlan\QRCode\QRCodeException;
use chillerlan\QRCode\Data\QRMatrix;
use Closure;
use function abs, array_column, array_search, intdiv, min;

/**
 * ISO/IEC 18004:2000 Section 8.8.1
 * ISO/IEC 18004:2000 Section 8.8.2 - Evaluation of masking results
 *
 * @see http://www.thonky.com/qr-code-tutorial/data-masking
 * @see https://github.com/zxing/zxing/blob/e9e2bd280bcaeabd59d0f955798384fe6c018a6c/core/src/main/java/com/google/zxing/qrcode/encoder/MaskUtil.java
 */
final class MaskPattern{

	/**
	 * @see \chillerlan\QRCode\QROptionsTrait::$maskPattern
	 *
	 * @var int
	 */
	public const AUTO = -1;

	public const PATTERN_000 = 0b000;
	public const PATTERN_001 = 0b001;
	public const PATTERN_010 = 0b010;
	public const PATTERN_011 = 0b011;
	public const PATTERN_100 = 0b100;
	public const PATTERN_101 = 0b101;
	public const PATTERN_110 = 0b110;
	public const PATTERN_111 = 0b111;

	/**
	 * @var int[]
	 */
	public const PATTERNS = [
		self::PATTERN_000,
		self::PATTERN_001,
		self::PATTERN_010,
		self::PATTERN_011,
		self::PATTERN_100,
		self::PATTERN_101,
		self::PATTERN_110,
		self::PATTERN_111,
	];

	/*
	 * Penalty scores
	 *
	 * ISO/IEC 18004:2000 Section 8.8.1 - Table 24
	 */
	private const PENALTY_N1 = 3;
	private const PENALTY_N2 = 3;
	private const PENALTY_N3 = 40;
	private const PENALTY_N4 = 10;

	/**
	 * The current mask pattern value (0-7)
	 */
	private int $currentPattern;

	/**
	 * MaskPattern constructor.
	 *
	 * @throws \chillerlan\QRCode\QRCodeException
	 */
	public function __construct(int $maskPattern){

		if(($maskPattern & 0b111) !== $maskPattern){
			throw new QRCodeException('invalid mask pattern');
		}

		$this->currentPattern = $maskPattern;
	}

	/**
	 * Returns the current mask pattern
	 *
	 * @deprecated 6.0.1 This method will be removed. In v7, use the property "MaskPattern::$currentPattern" instead.
	 */
	public function getPattern():int{
		return $this->currentPattern;
	}

	/**
	 * Returns a closure that applies the mask for the chosen mask pattern.
	 *
	 * Note that the diagram in section 6.8.1 is misleading since it indicates that $i is column position
	 * and $j is row position. In fact, as the text says, $i is row position and $j is column position.
	 *
	 * @see https://www.thonky.com/qr-code-tutorial/mask-patterns
	 * @see https://github.com/zxing/zxing/blob/e9e2bd280bcaeabd59d0f955798384fe6c018a6c/core/src/main/java/com/google/zxing/qrcode/decoder/DataMask.java#L32-L117
	 */
	public function getMask():Closure{
		// $x = column (width), $y = row (height)
		return [
			self::PATTERN_000 => fn(int $x, int $y):bool => (($x + $y) % 2) === 0,
			self::PATTERN_001 => fn(int $x, int $y):bool => ($y % 2) === 0,
			self::PATTERN_010 => fn(int $x, int $y):bool => ($x % 3) === 0,
			self::PATTERN_011 => fn(int $x, int $y):bool => (($x + $y) % 3) === 0,
			self::PATTERN_100 => fn(int $x, int $y):bool => ((intdiv($y, 2) + intdiv($x, 3)) % 2) === 0,
			self::PATTERN_101 => fn(int $x, int $y):bool => (($x * $y) % 6) === 0,
			self::PATTERN_110 => fn(int $x, int $y):bool => (($x * $y) % 6) < 3,
			self::PATTERN_111 => fn(int $x, int $y):bool => (($x + $y + (($x * $y) % 3)) % 2) === 0,
		][$this->currentPattern];
	}

	/**
	 * Evaluates the matrix of the given data interface and returns a new mask pattern instance for the best result
	 */
	public static function getBestPattern(QRMatrix $QRMatrix):self{
		$penalties = [];
		$size      = $QRMatrix->getSize();

		foreach(self::PATTERNS as $pattern){
			$mp      = new self($pattern);
			$matrix  = (clone $QRMatrix)->setFormatInfo($mp)->mask($mp)->getBooleanMatrix();
			$penalty = 0;

			for($level = 1; $level <= 4; $level++){
				$penalty += self::{'testRule'.$level}($matrix, $size, $size);
			}

			$penalties[$pattern] = (int)$penalty;
		}

		return new self(array_search(min($penalties), $penalties, true));
	}

	/**
	 * Apply mask penalty rule 1 and return the penalty. Find repetitive cells with the same color and
	 * give penalty to them. Example: 00000 or 11111.
	 *
	 * @param bool[][] $matrix
	 */
	public static function testRule1(array $matrix, int $height, int $width):int{
		$penalty = 0;

		// horizontal
		foreach($matrix as $row){
			$penalty += self::applyRule1($row);
		}

		// vertical
		for($x = 0; $x < $width; $x++){
			$penalty += self::applyRule1(array_column($matrix, $x));
		}

		return $penalty;
	}

	/**
	 * @param bool[] $rc
	 */
	private static function applyRule1(array $rc):int{
		$penalty         = 0;
		$numSameBitCells = 0;
		$prevBit         = null;

		foreach($rc as $val){

			if($val === $prevBit){
				$numSameBitCells++;
			}
			else{

				if($numSameBitCells >= 5){
					$penalty += (self::PENALTY_N1 + $numSameBitCells - 5);
				}

				$numSameBitCells = 1;  // Include the cell itself.
				$prevBit         = $val;
			}
		}

		if($numSameBitCells >= 5){
			$penalty += (self::PENALTY_N1 + $numSameBitCells - 5);
		}

		return $penalty;
	}

	/**
	 * Apply mask penalty rule 2 and return the penalty. Find 2x2 blocks with the same color and give
	 * penalty to them. This is actually equivalent to the spec's rule, which is to find MxN blocks and give a
	 * penalty proportional to (M-1)x(N-1), because this is the number of 2x2 blocks inside such a block.
	 *
	 * @param bool[][] $matrix
	 */
	public static function testRule2(array $matrix, int $height, int $width):int{
		$penalty = 0;

		foreach($matrix as $y => $row){

			if($y > ($height - 2)){
				break;
			}

			foreach($row as $x => $val){

				if($x > ($width - 2)){
					break;
				}

				if(
					   $val === $row[($x + 1)]
					&& $val === $matrix[($y + 1)][$x]
					&& $val === $matrix[($y + 1)][($x + 1)]
				){
					$penalty++;
				}
			}
		}

		return (self::PENALTY_N2 * $penalty);
	}

	/**
	 * Apply mask penalty rule 3 and return the penalty. Find consecutive runs of 1:1:3:1:1:4
	 * starting with black, or 4:1:1:3:1:1 starting with white, and give penalty to them.  If we
	 * find patterns like 000010111010000, we give penalty once.
	 *
	 * @param bool[][] $matrix
	 */
	public static function testRule3(array $matrix, int $height, int $width):int{
		$penalties = 0;

		foreach($matrix as $y => $row){
			foreach($row as $x => $val){

				if(
					($x + 6) < $width
					&&  $val
					&& !$row[($x + 1)]
					&&  $row[($x + 2)]
					&&  $row[($x + 3)]
					&&  $row[($x + 4)]
					&& !$row[($x + 5)]
					&&  $row[($x + 6)]
					&& (
						   self::isWhiteHorizontal($row, $width, ($x - 4), $x)
						|| self::isWhiteHorizontal($row, $width, ($x + 7), ($x + 11))
					)
				){
					$penalties++;
				}

				if(
					($y + 6) < $height
					&&  $val
					&& !$matrix[($y + 1)][$x]
					&&  $matrix[($y + 2)][$x]
					&&  $matrix[($y + 3)][$x]
					&&  $matrix[($y + 4)][$x]
					&& !$matrix[($y + 5)][$x]
					&&  $matrix[($y + 6)][$x]
					&& (
						   self::isWhiteVertical($matrix, $height, $x, ($y - 4), $y)
						|| self::isWhiteVertical($matrix, $height, $x, ($y + 7), ($y + 11))
					)
				){
					$penalties++;
				}

			}
		}

		return ($penalties * self::PENALTY_N3);
	}

	/**
	 * @param bool[] $row
	 */
	private static function isWhiteHorizontal(array $row, int $width, int $from, int $to):bool{

		if($from < 0 || $width < $to){
			return false;
		}

		for($x = $from; $x < $to; $x++){
			if($row[$x]){
				return false;
			}
		}

		return true;
	}

	/**
	 * @param bool[][] $matrix
	 */
	private static function isWhiteVertical(array $matrix, int $height, int $x, int $from, int $to):bool{

		if($from < 0 || $height < $to){
			return false;
		}

		for($y = $from; $y < $to; $y++){
			if($matrix[$y][$x] === true){
				return false;
			}
		}

		return true;
	}

	/**
	 * Apply mask penalty rule 4 and return the penalty. Calculate the ratio of dark cells and give
	 * penalty if the ratio is far from 50%. It gives 10 penalty for 5% distance.
	 *
	 * @param bool[][] $matrix
	 */
	public static function testRule4(array $matrix, int $height, int $width):int{
		$darkCells  = 0;
		$totalCells = ($height * $width);

		foreach($matrix as $row){
			foreach($row as $val){
				if($val === true){
					$darkCells++;
				}
			}
		}

		return (intdiv((abs($darkCells * 2 - $totalCells) * 10), $totalCells) * self::PENALTY_N4);
	}

}
