<?php
/**
 * Iterate over elements in a specific priority.
 *
 * $pl = new ElggPriorityList();
 * $pl->add('Element 0');
 * $pl->add('Element 10', 10);
 * $pl->add('Element -10', -10);
 *
 * foreach ($pl as $priority => $element) {
 *	var_dump("$priority => $element");
 * }
 *
 * Yields:
 * -10 => Element -10
 * 0 => Element 0
 * 10 => Element 10
 *
 * Collisions on priority are handled by inserting the element at or as close to the
 * requested priority as possible:
 *
 * $pl = new ElggPriorityList();
 * $pl->add('Element 5', 5);
 * $pl->add('Colliding element 5', 5);
 * $pl->add('Another colliding element 5', 5);
 *
 * foreach ($pl as $priority => $element) {
 *	var_dump("$priority => $element");
 * }
 *
 * Yields:
 *	5 => 'Element 5',
 *	6 => 'Colliding element 5',
 *	7 => 'Another colliding element 5'
 *
 * You can do priority lookups by element:
 *
 * $pl = new ElggPriorityList();
 * $pl->add('Element 0');
 * $pl->add('Element -5', -5);
 * $pl->add('Element 10', 10);
 * $pl->add('Element -10', -10);
 *
 * $priority = $pl->getPriority('Element -5');
 * 
 * Or element lookups by priority.
 * $element = $pl->getElement(-5);
 *
 * To remove elements, pass the element.
 * $pl->remove('Element -10');
 *
 * To check if an element exists:
 * $pl->contains('Element -5');
 *
 * To move an element:
 * $pl->move('Element -5', -3);
 *
 * ElggPriorityList only tracks priority. No checking is done in ElggPriorityList for duplicates or
 * updating. If you need to track this use objects and an external map:
 *
 * function elgg_register_something($id, $display_name, $location, $priority = 500) {
 *	// $id => $element.
 *	static $map = array();
 *	static $list;
 *
 *	if (!$list) {
 *		$list = new ElggPriorityList();
 *	}
 *
 *	// update if already registered.
 *	if (isset($map[$id])) {
 *		$element = $map[$id];
 *		// move it first because we have to pass the original element.
 *		if (!$list->move($element, $priority)) {
 *			return false;
 *		}
 *		$element->display_name = $display_name;
 *		$element->location = $location;
 *	} else {
 *		$element = new stdClass();
 *		$element->display_name = $display_name;
 *		$element->location = $location;
 *		if (!$list->add($element, $priority)) {
 *			return false;
 *		}
 *		$map[$id] = $element;
 *	}
 *
 *	return true;
 * }
 *
 * @package Elgg.Core
 * @subpackage Helpers
 */
class ElggPriorityList
	implements Iterator, Countable {

	/**
	 * The list of elements
	 *
	 * @var array
	 */
	private $elements = array();

	/**
	 * Create a new priority list.
	 *
	 * @param array $elements An optional array of priorities => element
	 */
	public function __construct(array $elements = array()) {
		if ($elements) {
			foreach ($elements as $priority => $element) {
				$this->add($element, $priority);
			}
		}
	}

	/**
	 * Adds an element to the list.
	 *
	 * @warning This returns the priority at which the element was added, which can be 0. Use
	 *          !== false to check for success.
	 *
	 * @param mixed $element  The element to add to the list.
	 * @param mixed $priority Priority to add the element. In priority collisions, the original element
	 *                        maintains its priority and the new element is to the next available
	 *                        slot, taking into consideration all previously registered elements.
	 *                        Negative elements are accepted.
	 * @return int            The priority of the added element.
	 */
	public function add($element, $priority = null, $exact = false) {
		if ($priority !== null && !is_numeric($priority)) {
			return false;
		} else {
			$priority = $this->getNextPriority($priority);
		}

		$this->elements[$priority] = $element;
		$this->sorted = false;
		return $priority;
	}

	/**
	 * Removes an element from the list.
	 *
	 * @warning The element must have the same attributes / values. If using $strict, it must have
	 *          the same types. array(10) will fail in strict against array('10') (str vs int).
	 *
	 * @param type $element
	 * @return bool
	 */
	public function remove($element, $strict = false) {
		$index = array_search($element, $this->elements, $strict);
		if ($index !== false) {
			unset($this->elements[$index]);
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Move an existing element to a new priority.
	 *
	 * @param mixed  $current_priority
	 * @param int    $new_priority
	 *
	 * @return int The new priority.
	 */
	public function move($element, $new_priority, $strict = false) {
		$new_priority = (int) $new_priority;
		
		$current_priority = $this->getPriority($element, $strict);
		if ($current_priority === false) {
			return false;
		}

		if ($current_priority == $new_priority) {
			return true;
		}

		// move the actual element so strict operations still work
		$element = $this->getElement($current_priority);
		unset($this->elements[$current_priority]);
		return $this->add($element, $new_priority);
	}

	/**
	 * Returns the elements
	 *
	 * @return array
	 */
	public function getElements() {
		$this->sortIfUnsorted();
		return $this->elements;
	}

	/**
	 * Sort the elements optionally by a callback function.
	 *
	 * If no user function is provided the elements are sorted by priority registered.
	 *
	 * The callback function should accept the array of elements as the first argument and should
	 * return a sorted array.
	 *
	 * This function can be called multiple times.
	 *
	 * @param type $callback
	 * @return bool
	 */
	public function sort($callback = null) {
		if (!$callback) {
			ksort($this->elements, SORT_NUMERIC);
		} else {
			$sorted = call_user_func($callback, $this->elements);

			if (!$sorted) {
				return false;
			}

			$this->elements = $sorted;
		}
		
		$this->sorted = true;
		return true;
	}

	/**
	 * Sort the elements if they haven't been sorted yet.
	 *
	 * @return bool
	 */
	private function sortIfUnsorted() {
		if (!$this->sorted) {
			return $this->sort();
		}
	}

	/**
	 * Returns the next priority available.
	 *
	 * @param int $near Make the priority as close to $near as possible.
	 * @return int
	 */
	public function getNextPriority($near = 0) {
		$near = (int) $near;
		
		while (array_key_exists($near, $this->elements)) {
			$near++;
		}

		return $near;
	}

	/**
	 * Returns the priority of an element if it exists in the list.
	 * 
	 * @warning This can return 0 if the element's priority is 0.
	 *
	 * @param mixed $element The element to check for.
	 * @param bool  $strict  Use strict checking?
	 * @return mixed False if the element doesn't exists, the priority if it does.
	 */
	public function getPriority($element, $strict = false) {
		return array_search($element, $this->elements, $strict);
	}

	/**
	 * Returns the element at $priority.
	 *
	 * @param int $priority
	 * @return mixed The element or false on fail.
	 */
	public function getElement($priority) {
		return (isset($this->elements[$priority])) ? $this->elements[$priority] : false;
	}

	/**
	 * Returns if the list contains $element.
	 *
	 * @param mixed $element The element to check.
	 * @param bool  $strict  Use strict checking?
	 * @return bool
	 */
	public function contains($element, $strict = false) {
		return $this->getPriority($element, $strict) !== false;
	}

	
	/**********************
	 * Interface methods *
	 **********************/

	/**
	 * Iterator
	 */

	/**
	 * PHP Iterator Interface
	 *
	 * @see Iterator::rewind()
	 * @return void
	 */
	public function rewind() {
		$this->sortIfUnsorted();
		return rewind($this->elements);
	}

	/**
	 * PHP Iterator Interface
	 *
	 * @see Iterator::current()
	 * @return mixed
	 */
	public function current() {
		$this->sortIfUnsorted();
		return current($this->elements);
	}

	/**
	 * PHP Iterator Interface
	 *
	 * @see Iterator::key()
	 * @return int
	 */
	public function key() {
		$this->sortIfUnsorted();
		return key($this->elements);
	}

	/**
	 * PHP Iterator Interface
	 *
	 * @see Iterator::next()
	 * @return mixed
	 */
	public function next() {
		$this->sortIfUnsorted();
		return next($this->elements);
	}

	/**
	 * PHP Iterator Interface
	 *
	 * @see Iterator::valid()
	 * @return bool
	 */
	public function valid() {
		$this->sortIfUnsorted();
		$key = key($this->elements);
		return ($key !== NULL && $key !== FALSE);
	}

	// Countable
	public function count() {
		return count($this->elements);
	}
}