<?php
/*****************************************************************************
 * PhotoMosaic.php
 * Contains the photo mosaic model, an iterator to create tiled photo galleries.
 *
 * Kabuki CMS (C) 2013-2015, Aaron van Geffen
 *****************************************************************************/

class PhotoMosaic
{
	private $descending;
	private AssetIterator $iterator;
	private $layouts;
	private $processedImages = 0;
	private $queue = [];

	const IMAGE_MASK_ALL = Image::TYPE_PORTRAIT | Image::TYPE_LANDSCAPE | Image::TYPE_PANORAMA;
	const NUM_DAYS_CUTOFF = 7;
	const NUM_BATCH_PHOTOS = 6;

	public function __construct(AssetIterator $iterator)
	{
		$this->iterator = $iterator;
		$this->layouts = $this->availableLayouts();
		$this->descending = $iterator->isDescending();
	}

	public function __destruct()
	{
		$this->iterator->clean();
	}

	private function availableLayouts()
	{
		static $layouts = [
			// Single panorama
			'panorama' => [Image::TYPE_PANORAMA],

			// A whopping six landscapes?
			'sixLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
								Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],

			// Big-small juxtapositions
			'sidePortrait' => [Image::TYPE_PORTRAIT, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE,
													 Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
			'sideLandscape' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],

			// Single row of three
			'threeLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
			'threePortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],

			// Dual layouts
			'dualLandscapes' => [Image::TYPE_LANDSCAPE, Image::TYPE_LANDSCAPE],
			'dualPortraits' => [Image::TYPE_PORTRAIT, Image::TYPE_PORTRAIT],
			'dualMixed' => [Image::TYPE_LANDSCAPE, Image::TYPE_PORTRAIT],

			// Fallback layouts
			'singleLandscape' => [Image::TYPE_LANDSCAPE],
			'singlePortrait' => [Image::TYPE_PORTRAIT],
		];

		return $layouts;
	}

	private static function daysApart(DateTime $a, DateTime $b)
	{
		return $a->diff($b)->days;
	}

	private function fetchImage($desired_type = self::IMAGE_MASK_ALL, ?DateTime $refDate = null)
	{
		// First, check if we have what we're looking for in the queue.
		foreach ($this->queue as $i => $image)
		{
			// Give up on the queue once the dates are too far apart
			if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
			{
				break;
			}

			// Image has to match the desired type and be taken within a week of the reference image.
			if (self::matchTypeMask($image, $desired_type))
			{
				unset($this->queue[$i]);
				return $image;
			}
		}

		// Check whatever's next up!
		while (($asset = $this->iterator->next()) && ($image = $asset->getImage()))
		{
			// Give up on the recordset once dates are too far apart
			if (isset($refDate) && abs(self::daysApart($image->getDateCaptured(), $refDate)) > self::NUM_DAYS_CUTOFF)
			{
				$this->pushToQueue($image);
				break;
			}

			// Image has to match the desired type and be taken within a week of the reference image.
			if (self::matchTypeMask($image, $desired_type))
			{
				return $image;
			}
			else
			{
				$this->pushToQueue($image);
			}
		}

		return false;
	}

	public function fetchImages($num, $refDate = null, $spec = self::IMAGE_MASK_ALL)
	{
		$refDate = null;
		$prevImage = true;
		$images = [];

		for ($i = 0; $i < $num || !$prevImage; $i++)
		{
			$image = $this->fetchImage($spec, $refDate);
			if ($image !== false)
			{
				$images[] = $image;
				$refDate = $image->getDateCaptured();
				$prevImage = $image;
			}
		}

		return $images;
	}

	public function getRow()
	{
		$requiredImages = array_map('count', $this->layouts);
		$currentImages = $this->fetchImages(self::NUM_BATCH_PHOTOS);
		$selectedLayout = null;

		if (empty($currentImages))
		{
			// Ensure we have no images left in the iterator before giving up
			assert($this->processedImages === $this->iterator->num());
			return false;
		}

		// Assign fitness score for each layout
		$fitnessScores = $this->getScoresByLayout($currentImages);
		$scoresByLayout = array_map(fn($el) => $el[0], $fitnessScores);

		// Select the best-fitting layout
		$bestLayouts = array_keys($scoresByLayout, max($scoresByLayout));
		$bestLayout = $bestLayouts[0];
		$layoutImages = $fitnessScores[$bestLayout][1];

		// Push any unused back into the queue
		if (count($layoutImages) < count($currentImages))
		{
			$diff = array_udiff($currentImages, $layoutImages, function($a, $b) {
				return $a->getId() <=> $b->getId();
			});
			array_map([$this, 'pushToQueue'], $diff);
		}

		// Finally, allow tweaking image order through display priority
		usort($layoutImages, [$this, 'orderPhotosByPriority']);

		// Done! Return the result
		$this->processedImages += count($layoutImages);
		return [$layoutImages, $bestLayout];
	}

	public function getScoreForRow(array $images, array $specs)
	{
		assert(count($images) === count($specs));

		$score = 0;
		foreach ($images as $i => $image)
		{
			if (self::matchTypeMask($image, $specs[$i]))
				$score += 1;
			else
				$score -= 10;
		}

		return $score;
	}

	public function getScoresByLayout(array $candidateImages)
	{
		$fitnessScores = [];
		foreach ($this->layouts as $layout => $requiredImageTypes)
		{
			// If we don't have enough candidate images for this layout, skip it
			if (count($candidateImages) < count($requiredImageTypes))
				continue;

			$imageSelection = [];
			$remainingImages = $candidateImages;

			// Try to satisfy the layout spec using the images available
			foreach ($requiredImageTypes as $spec)
			{
				foreach ($remainingImages as $i => $candidate)
				{
					// Satisfied spec from selection?
					if (self::matchTypeMask($candidate, $spec))
					{
						$imageSelection[] = $candidate;
						unset($remainingImages[$i]);
						continue 2;
					}
				}

				// Unable to satisfy spec from selection
				break;
			}

			// Have we satisfied the spec? Great, assign a score
			if (count($imageSelection) === count($requiredImageTypes))
			{
				$score = $this->getScoreForRow($imageSelection, $requiredImageTypes);
				$fitnessScores[$layout] = [$score, $imageSelection];

				// Perfect score? Bail out early
				if ($score === count($requiredImageTypes))
					break;
			}
		}

		return $fitnessScores;
	}

	private static function matchTypeMask(Image $image, $type_mask)
	{
		return $image->getType() & $type_mask;
	}

	private static function orderPhotosByPriority(Image $a, Image $b)
	{
		// Leave images of different types as-is
		if ($a->isLandscape() !== $b->isLandscape())
			return 0;

		// Otherwise, show images of highest priority first
		$priority_diff = $a->getPriority() - $b->getPriority();
		return -$priority_diff;
	}

	private function orderQueueByDate()
	{
		usort($this->queue, function($a, $b) {
			$score = $a->getDateCaptured() <=> $b->getDateCaptured();
			return $score * ($this->descending ? -1 : 1);
		});
	}

	private function pushToQueue(Image $image)
	{
		$this->queue[] = $image;
		$this->orderQueueByDate();
	}
}