<?php namespace ProcessWire;

/**
 * ProcessWire WireSaveableItems
 *
 * Wire Data Access Object, provides reusable capability for loading, saving, creating, deleting, 
 * and finding items of descending class-defined types. 
 * 
 * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
 * https://processwire.com
 * 
 * @method WireArray load(WireArray $items, $selectors = null)
 * @method bool save(Saveable $item)
 * @method bool delete(Saveable $item)
 * @method WireArray find($selectors)
 * @method void saveReady(Saveable $item) #pw-hooker
 * @method void deleteReady(Saveable $item) #pw-hooker
 * @method void cloneReady(Saveable $item, Saveable $copy) #pw-hooker
 * @method array saved(Saveable $item, array $changes = array()) #pw-hooker
 * @method void added(Saveable $item) #pw-hooker
 * @method void deleted(Saveable $item) #pw-hooker
 * @method void cloned(Saveable $item, Saveable $copy) #pw-hooker
 *
 * 
 */

abstract class WireSaveableItems extends Wire implements \IteratorAggregate {

	/**
	 * Return the WireArray that this DAO stores it's items in
	 * 
	 * @return WireArray
	 *
	 */
	abstract public function getAll();

	/**
	 * Return a new blank item 
	 * 
	 * @return Saveable|Wire
	 *
	 */
	abstract public function makeBlankItem();

	/**
	 * Make an item and populate with given data
	 * 
	 * @param array $a Associative array of data to populate
	 * @return Saveable|Wire
	 * @throws WireException
	 * @since 3.0.146
	 * 
	 */
	public function makeItem(array $a = array()) {
		$item = $this->makeBlankItem();
		$this->wire($item);
		foreach($a as $key => $value) {
			$item->$key = $value;
		}
		$item->resetTrackChanges(true);
		return $item;
	}

	/**
	 * Return the name of the table that this DAO stores item records in
	 * 
	 * @return string
	 *
	 */
	abstract public function getTable();
	

	/**
	 * Return the default name of the field that load() should sort by (default is none)
	 *
	 * This is overridden by selectors if applied during the load method
	 * 
	 * @return string
	 *
	 */
	public function getSort() { return ''; }

	/**
	 * Provides additions to the ___load query for when selectors or selector string are provided
	 * 
	 * @param Selectors $selectors
	 * @param DatabaseQuerySelect $query
	 * @throws WireException
	 * @return DatabaseQuerySelect
	 *
	 */
	protected function getLoadQuerySelectors($selectors, DatabaseQuerySelect $query) {

		$database = $this->wire('database'); 

		if(is_object($selectors) && $selectors instanceof Selectors) {
			// iterable selectors
		} else if($selectors && is_string($selectors)) {
			// selector string, convert to iterable selectors
			$selectorString = $selectors;
			$selectors = $this->wire(new Selectors()); 
			$selectors->init($selectorString);

		} else {
			// nothing provided, load all assumed
			return $query; 
		}
	
		// Note: ProcessWire core does not appear to ever reach this point as the
		// core does not use selectors to load any of its WireSaveableItems

		$functionFields = array(
			'sort' => '', 
			'limit' => '', 
			'start' => '',
		);
		
		$item = $this->makeBlankItem();
		$fields = array_keys($item->getTableData());

		foreach($selectors as $selector) {

			if(!$database->isOperator($selector->operator)) {
				throw new WireException("Operator '$selector->operator' may not be used in {$this->className}::load()");
			}
			
			if(isset($functionFields[$selector->field])) {
				$functionFields[$selector->field] = $selector->value;
				continue;
			}

			if(!in_array($selector->field, $fields)) {
				throw new WireException("Field '$selector->field' is not valid for {$this->className}::load()");
			}

			$selectorField = $database->escapeTableCol($selector->field); 
			$query->where("$selectorField$selector->operator?", $selector->value); // QA
		}

		$sort = $functionFields['sort'];
		if($sort && in_array($sort, $fields)) {
			$query->orderby($database->escapeCol($sort));
		}
		
		$limit = (int) $functionFields['limit'];
		if($limit) {
			$start = $functionFields['start'];
			$query->limit(($start ? ((int) $start) . ',' : '') . $limit);
		}

		return $query; 

	}

	/**
	 * Get the DatabaseQuerySelect to perform the load operation of items
	 *
	 * @param Selectors|string|null $selectors Selectors or a selector string to find, or NULL to load all. 
	 * @return DatabaseQuerySelect
	 *
	 */
	protected function getLoadQuery($selectors = null) {

		$item = $this->makeBlankItem();
		$fields = array_keys($item->getTableData());
		$database = $this->wire('database'); 
		
		$table = $database->escapeTable($this->getTable());
		
		foreach($fields as $k => $v) {
			$v = $database->escapeCol($v);
			$fields[$k] = "$table.$v"; 
		}

		$query = $this->wire(new DatabaseQuerySelect());
		$query->select($fields)->from($table);
		if($sort = $this->getSort()) $query->orderby($sort); 
		$this->getLoadQuerySelectors($selectors, $query); 

		return $query; 

	}

	/**
	 * Load items from the database table and return them in the same type class that getAll() returns
	 
	 * A selector string or Selectors may be provided so that this can be used as a find() by descending classes that don't load all items at once.  
	 *
	 * @param WireArray $items
	 * @param Selectors|string|null $selectors Selectors or a selector string to find, or NULL to load all. 
	 * @return WireArray Returns the same type as specified in the getAll() method.
	 *
	 */
	protected function ___load(WireArray $items, $selectors = null) {

		/** @var WireDatabasePDO $database */
		$database = $this->wire('database');
		$sql = $this->getLoadQuery($selectors)->getQuery();
		
		$query = $database->prepare($sql);	
		$query->execute();
		
		while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
			if(isset($row['data'])) {
				if($row['data']) {
					$row['data'] = $this->decodeData($row['data']);
				} else {
					unset($row['data']);
				}
			}
			$item = $this->makeItem($row);
			if($item) $items->add($item);
		}
		
		$query->closeCursor();
		$items->setTrackChanges(true); 
		
		return $items; 
	}

	/**
	 * Should the given item key/field be saved in the database?
	 *
	 * Template method used by ___save()
	 * 
	 * @param string $key
	 * @return bool
	 *
	 */
	protected function saveItemKey($key) {
		if($key == 'id') return false;
		return true; 
	}

	/**
	 * Save the provided item to database
	 *
	 * @param Saveable $item The item to save
	 * @return bool Returns true on success, false on failure
	 * @throws WireException
	 *
	 */
	public function ___save(Saveable $item) {

		$blank = $this->makeBlankItem();
		
		if(!$item instanceof $blank) {
			$className = $blank->className();
			throw new WireException("WireSaveableItems::save(item) requires item to be of type: $className");
		}

		$database = $this->wire()->database; 
		$table = $database->escapeTable($this->getTable());
		$sql = "`$table` SET ";
		$id = (int) $item->id;
		$this->saveReady($item); 
		$data = $item->getTableData();
		$binds = array();

		foreach($data as $key => $value) {
			if(!$this->saveItemKey($key)) continue; 
			if($key === 'data') $value = is_array($value) ? $this->encodeData($value) : '';
			$key = $database->escapeTableCol($key);
			$bindKey = $database->escapeCol($key);
			$binds[":$bindKey"] = $value; 
			$sql .= "`$key`=:$bindKey, ";
		}

		$sql = rtrim($sql, ", "); 

		if($id) {
			
			$query = $database->prepare("UPDATE $sql WHERE id=:id");
			foreach($binds as $key => $value) {
				$query->bindValue($key, $value); 
			}
			$query->bindValue(":id", $id, \PDO::PARAM_INT);
			$result = $query->execute();
			
		} else {
			
			$query = $database->prepare("INSERT INTO $sql"); 
			foreach($binds as $key => $value) {
				$query->bindValue($key, $value); 
			}
			$result = $query->execute();
			if($result) {
				$item->id = (int) $database->lastInsertId();
				$this->getAll()->add($item);
				$this->added($item);
			}
		}

		if($result) {
			$this->saved($item); 
			$this->resetTrackChanges();
		} else {
			$this->error("Error saving '$item'"); 
		}
		
		return $result;
	}


	/** 
	 * Delete the provided item from the database
	 *
	 * @param Saveable $item Item to save
	 * @return bool Returns true on success, false on failure
	 * @throws WireException
	 *
	 */
	public function ___delete(Saveable $item) {
		$blank = $this->makeBlankItem();
		if(!$item instanceof $blank) throw new WireException("WireSaveableItems::delete(item) requires item to be of type '" . $blank->className() . "'"); 
		
		$id = (int) $item->id;
		if(!$id) return false; 
		
		$database = $this->wire('database'); 
		
		$this->deleteReady($item);
		$this->getAll()->remove($item); 
		$table = $database->escapeTable($this->getTable());
		
		$query = $database->prepare("DELETE FROM `$table` WHERE id=:id LIMIT 1"); 
		$query->bindValue(":id", $id, \PDO::PARAM_INT); 
		$result = $query->execute();
		
		if($result) {
			$this->deleted($item);
			$item->id = 0; 
		} else {
			$this->error("Error deleting '$item'"); 
		}
		
		return $result;	
	}

	/**
	 * Create and return a cloned copy of this item
	 *
	 * If no name is specified and the new item uses a 'name' field, it will contain a number at the end to make it unique
	 *
	 * @param Saveable $item Item to clone
	 * @param string $name Optionally specify new name
	 * @return bool|Saveable $item Returns the new clone on success, or false on failure
	 *
	 */
	public function ___clone(Saveable $item, $name = '') {

		$original = $item;
		$item = clone $item;

		if(array_key_exists('name', $item->getTableData())) {
			// this item uses a 'name' field for identification, so we want to ensure it's unique
			$n = 0;
			if(!strlen($name)) $name = $item->name; 
			// ensure the new name is unique
			while($this->get($name)) $name = rtrim($item->name, '_') . '_' . (++$n); 
			$item->name = $name; 
		}

		// id=0 forces the save() to create a new field
		$item->id = 0;
		$this->cloneReady($original, $item); 
		if($this->save($item)) {
			$this->cloned($original, $item); 
			return $item;
		}
		return false; 
	}

	/**
	 * Find items based on Selectors or selector string
	 *
	 * This is a delegation to the WireArray associated with this DAO.
	 * This method assumes that all items are loaded. Desecending classes that don't load all items should 
	 * override this to the ___load() method instead. 
	 *
	 * @param Selectors|string $selectors 
	 * @return WireArray 
	 *
	 */
	public function ___find($selectors) {
		return $this->getAll()->find($selectors); 
	}

	public function getIterator() {
		return $this->getAll();
	}

	public function get($key) {
		return $this->getAll()->get($key); 
	}

	public function __get($key) {
		$value = $this->get($key);
		if(is_null($value)) $value = parent::__get($key);
		return $value; 
	}

	public function has($item) {
		return $this->getAll()->has($item); 
	}

	public function __isset($key) {
		return $this->get($key) !== null;	
	}

	/**
	 * Encode the 'data' portion of the table.
	 * 	
	 * This is a front-end to wireEncodeJSON so that it can be overridden if needed.
	 * 
	 * @param array $value
	 * @return string
	 *
	 */
	protected function encodeData(array $value) {
		return wireEncodeJSON($value); 
	}

	/**
	 * Decode the 'data' portion of the table.
	 * 	
	 * This is a front-end to wireDecodeJSON that it can be overridden if needed.
	 * 
	 * @param string $value
	 * @return array
	 *
	 */
	protected function decodeData($value) {
		return wireDecodeJSON($value);
	}

	/**
	 * Enforce no locally-scoped fuel for this class
	 * 
	 * @param bool|null $useFuel
	 * @return bool
	 *
	 */
	public function useFuel($useFuel = null) {
		return false;
	}

	/**
	 * Hook that runs right before item is to be saved.
	 * 
	 * Unlike before(save), when this runs, it has already been confirmed that the item will indeed be saved.
	 * 
	 * @param Saveable $item
	 * 
	 */
	public function ___saveReady(Saveable $item) { }
	
	/**
	 * Hook that runs right before item is to be deleted.
	 *
	 * Unlike before(delete), when this runs, it has already been confirmed that the item will indeed be deleted.
	 *
	 * @param Saveable $item
	 *
	 */
	public function ___deleteReady(Saveable $item) { }
	
	/**
	 * Hook that runs right before item is to be cloned.
	 *
	 * @param Saveable $item
	 * @param Saveable $copy
	 *
	 */
	public function ___cloneReady(Saveable $item, Saveable $copy) { }
	
	/**
	 * Hook that runs right after an item has been saved. 
	 *
	 * Unlike after(save), when this runs, it has already been confirmed that the item has been saved (no need to error check).
	 *
	 * @param Saveable $item
	 * @param array $changes
	 *
	 */
	public function ___saved(Saveable $item, array $changes = array()) {
		if(count($changes)) {
			$this->log("Saved '$item->name', Changes: " . implode(', ', $changes)); 
		} else {
			$this->log("Saved", $item);
		}
	}
	
	/**
	 * Hook that runs right after a new item has been added. 
	 *
	 * @param Saveable $item
	 *
	 */
	public function ___added(Saveable $item) {
		$this->log("Added", $item);
	}
	
	/**
	 * Hook that runs right after an item has been deleted. 
	 * 
	 * Unlike after(delete), it has already been confirmed that the item was indeed deleted.
	 *
	 * @param Saveable $item
	 *
	 */
	public function ___deleted(Saveable $item) { 
		$this->log("Deleted", $item);
	}

	/**
	 * Hook that runs right after an item has been cloned. 
	 *
	 * Unlike after(delete), it has already been confirmed that the item was indeed deleted.
	 *
	 * @param Saveable $item
	 * @param Saveable $copy
	 *
	 */
	public function ___cloned(Saveable $item, Saveable $copy) {
		$this->log("Cloned '$item->name' to '$copy->name'", $item); 
	}

	/**
	 * Enables use of $apivar('name') or wire()->apivar('name')
	 * 
	 * @param $key
	 * @return Wire|null
	 * 
	 */
	public function __invoke($key) {
		return $this->get($key); 
	}

	/**
	 * Save to activity log, if enabled in config
	 *
	 * @param $str
	 * @param Saveable|null Item to log
	 * @return WireLog
	 *
	 */
	public function log($str, Saveable $item = null) {
		$logs = $this->wire('config')->logs;
		$name = $this->className(array('lowercase' => true)); 
		if($logs && in_array($name, $logs)) {
			if($item && strpos($str, "'$item->name'") === false) $str .= " '$item->name'";
			return parent::___log($str, array('name' => $name));
		}
		return parent::___log(); 
	}

	/**
	 * Record an error
	 *
	 * @param string $text
	 * @param int|bool $flags See Notices::flags
	 * @return Wire|WireSaveableItems
	 *
	 */
	public function error($text, $flags = 0) {
		$this->log($text); 
		return parent::error($text, $flags); 
	}


}
