<?php namespace ProcessWire;

/**
 * WireData with database storage
 * 
 * A WireData object that maintains its data in a database table rather than just in memory.
 * An example of usage is the `$page->meta()` method.
 * 
 * ProcessWire 3.x, Copyright 2023
 * https://processwire.com
 *
 */
class WireDataDB extends WireData implements \Countable {

	/**
	 * True when all data from the table has been loaded (a call to getArray will trigger this)
	 * 
	 * @var bool
	 * 
	 */
	protected $fullyLoaded = false;

	/**
	 * ID of the source object for this WireData
	 * 
	 * @var int
	 * 
	 */
	protected $sourceID = 0;

	/**
	 * Name of the table that data will be stored in
	 * 
	 * @var string
	 * 
	 */
	protected $table = '';
	
	/**
	 * Construct
	 *
	 * @param int $sourceID ID of the source item this WireData is maintaining/persisting data for. 
	 * @param string $tableName Name of the table to store data in. If it does not exist, it will be created.
	 *
	 */
	public function __construct($sourceID, $tableName) {
		$this->table($tableName);
		$this->sourceID($sourceID);
		parent::__construct();
	}

	/**
	 * Get the value for a specific property/name/key
	 * 
	 * @param string $key
	 * @return array|mixed|null
	 * @throws WireException
	 * 
	 */
	public function get($key) {
		$value = parent::get($key);
		if($value !== null) return $value;
		$value = $this->load($key);
		parent::set($key, $value);
		return $value;
	}

	/**
	 * Get all values in an associative array 
	 * 
	 * @return array|mixed|null
	 * @throws WireException
	 * 
	 */
	public function getArray() {
		return $this->load(true);
	}

	/**
	 * Set and save a value for a specific property/name/key
	 * 
	 * @param string $key
	 * @param mixed $value
	 * @return self
	 * @throws WireException
	 * 
	 */
	public function set($key, $value) {
		if(parent::get($key) === $value) return $this; // no change
		if($value === null) return $this->remove($key);  // remove
		$this->save($key, $value); // set
		parent::set($key, $value);
		return $this;
	}

	/**
	 * Remove value for a specific property/name/key 
	 * 
	 * @param string $key
	 * @return self
	 * @throws WireException
	 * 
	 */
	public function remove($key) {
		$this->delete("$key");
		parent::remove($key);
		return $this;
	}

	/**
	 * Remove all values for sourceID from the DB
	 * 
	 * @return $this
	 * 
	 */
	public function removeAll() {
		$this->delete(true);
		$this->reset();
		return $this;
	}

	/**
	 * Reset all loaded data so that it will re-load from DB on next access
	 * 
	 * @return $this
	 * 
	 */
	public function reset() {
		$this->data = array();
		$this->fullyLoaded = false;
		return $this;
	}

	/**
	 * Delete meta value or all meta values (if you specify true)
	 * 
	 * @param string|bool $name Meta property name to delete or specify boolean true for all
	 * @return int Number of rows deleted
	 * @throws WireException
	 * 
	 */
	protected function delete($name) {
		if(empty($name)) return 0;
		$table = $this->table();
		$sql = "DELETE FROM `$table` WHERE source_id=:source_id ";
		if($name !== true) $sql .= "AND name=:name";
		$query = $this->wire()->database->prepare($sql);
		$query->bindValue(':source_id', $this->sourceID(), \PDO::PARAM_INT);
		if($name !== true) $query->bindValue(':name', $name);
		try {
			$query->execute();
			$result = $query->rowCount();
			$query->closeCursor();
		} catch(\Exception $e) {
			$result = 0;
		}
		return $result;
	}

	/**
	 * Load a value or all values
	 * 
	 * @param string|bool $name Property name to load or boolean true to load all
	 * @return array|mixed|null
	 * @throws WireException
	 * 
	 */
	protected function load($name) {
		if(empty($name)) return null;
		if($this->fullyLoaded) return $name === true ? parent::getArray() : parent::get($name);
		$table = $this->table();
		$sql = "SELECT name, data FROM `$table` WHERE source_id=:source_id ";
		if($name !== true) $sql .= "AND name=:name ";
		$query = $this->wire()->database->prepare($sql);
		$query->bindValue(':source_id', $this->sourceID(), \PDO::PARAM_INT);
		if($name !== true) $query->bindValue(':name', $name);
		try {
			$query->execute();
		} catch(\Exception $e) {
			return $name === true ? array() : null;
		}
		if($query->rowCount()) {
			$meta = array();
			while($row = $query->fetch(\PDO::FETCH_NUM)) {
				list($key, $data) = $row;
				$meta[$key] = json_decode($data, true);
				parent::set($key, $meta[$key]);
				if($name !== true) break;
			}
			if($name !== true) $meta = empty($meta) ? null : $meta[$name];
		} else {
			$meta = null;
		}
		if($name === true) $this->fullyLoaded = true;
		$query->closeCursor();
		return $meta;
	}

	/**
	 * Save a value 
	 * 
	 * @param string $name
	 * @param mixed $value
	 * @param bool $recursive
	 * @return bool
	 * @throws WireException
	 * 
	 */
	protected function save($name, $value, $recursive = false) {
		if(is_object($value)) return false; // we do not currently save objects
		$data = json_encode($value);
		$table = $this->table();
		$sourceID = $this->sourceID();
		if(!$sourceID) return false;
		$sql =
			"INSERT INTO `$table` (source_id, name, data) VALUES(:source_id, :name, :data) " .
			"ON DUPLICATE KEY UPDATE source_id=VALUES(source_id), name=VALUES(name), data=VALUES(data)";
		$query = $this->wire()->database->prepare($sql);
		$query->bindValue(':source_id', $this->sourceID(), \PDO::PARAM_INT);
		$query->bindValue(':name', $name);
		$query->bindValue(':data', $data);
		try {
			$query->execute();
			$result = $query->rowCount();
		} catch(\Exception $e) {
			if($recursive) throw $e;
			// table might not yet exist, try to create and save() again
			$result = $this->install() ? $this->save($name, $value, true) : false;
		}
		return $result ? true : false;
	}
	
	/**
	 * Get or set the the source ID for this instance
	 * 
	 * @param int|null $id
	 * @return int
	 * @throws WireException
	 * 
	 */
	public function sourceID($id = null) {
		if(!is_int($id)) return $this->sourceID;
		if($id < 1) throw new WireException($this->className() . ' sourceID must be greater than 0');
		$this->sourceID = $id;
		return $this->sourceID;
	}

	/**
	 * Count the number of rows this WireDataDB maintains in the database for source ID. 
	 * 
	 * This implements the \Countable interface. 
	 * 
	 * @return int
	 * 
	 */
	#[\ReturnTypeWillChange] 
	public function count() {
		$table = $this->table();
		$sql = "SELECT COUNT(*) FROM `$table` WHERE source_id=:source_id";
		$query = $this->wire()->database->prepare($sql);
		$query->bindValue(':source_id', $this->sourceID(), \PDO::PARAM_INT); 
		try {
			$query->execute();
			$count = (int) $query->fetchColumn();
		} catch(\Exception $e) {
			$count = 0;
		}
		return $count; 
	}

	/**
	 * Copy all data to a new source ID
	 * 
	 * Useful to call on the source object after a clone has been created from it. 
	 * 
	 * @param int $newSourceID
	 * @throws WireException
	 * @return int Number of rows copied
	 * 
	 */
	public function copyTo($newSourceID) {
		if(!$this->count()) return 0;
		$sourceID = $this->sourceID;
		if($newSourceID == $sourceID) return 0;
		$data = $this->getArray();
		$this->sourceID($newSourceID); // temporarily set new
		foreach($data as $key => $value) {
			$this->save($key, $value);
		}
		$this->sourceID($sourceID); // set back
		return count($data);
	}

	/**
	 * Get the current table name
	 * 
	 * @param string $tableName
	 * @return string
	 * 
	 */
	public function table($tableName = '') {
		if($tableName === '') return $this->table;
		if(!ctype_alnum(str_replace('_', '', $tableName))) {
			$tableName = preg_replace('/[^_a-zA-Z0-9]/', '_', $tableName);
		}
		$this->table = strtolower($tableName);
		return $this->table;
	}

	/**
	 * Get DB schema in an array
	 * 
	 * @return array
	 * 
	 */
	protected function schema() {
		return array(
			"source_id INT UNSIGNED NOT NULL",
			"name VARCHAR(128) NOT NULL",
			"data MEDIUMTEXT NOT NULL",
			"PRIMARY KEY (source_id, name)",
			"INDEX name (name)",
			"FULLTEXT KEY data (data)"
		);
	}

	/**
	 * Install the table
	 * 
	 * @return bool
	 * @throws WireException
	 * 
	 */
	public function install() {
		$config = $this->wire()->config;
		$database = $this->wire()->database;
		$engine = $config->dbEngine;
		$charset = $config->dbCharset;
		$table = $this->table();
		if($database->tableExists($table)) return false;
		$schema = implode(', ', $this->schema());
		$sql = "CREATE TABLE `$table` ($schema) ENGINE=$engine DEFAULT CHARSET=$charset";
		$this->database->exec($sql);
		$this->message("Added '$table' table to database");
		return true;
	}

	/**
	 * Uninstall the table
	 * 
	 * @return bool
	 * @throws WireException
	 * 
	 */
	public function uninstall() {
		$table = $this->table();
		$this->wire()->database->exec("DROP TABLE `$table`"); 
		return true;
	}

	#[\ReturnTypeWillChange] 
	public function getIterator() {
		return new \ArrayObject($this->getArray());
	}

}
