<?php
	namespace Juju;
	require_once('pdo.class.php');
	require_once('ORM/relationship.class.php');
	require_once('Data/securestring.class.php');
	use \Juju\{Data\SecureString, ORM\Relationship, PDO};

	abstract class ORM implements \ArrayAccess, \JsonSerializable {
		// Model definition
		protected  static $table_name = null;
		protected static $primary_key = 'id';
		protected static $foreign_key_suffix = '_id';
		protected static $has_one = [];
		protected static $has_many = [];
		protected static $belongs_to = [];
		// Data tracking
		private $_data = [];
		private $_changed = [];
		private $_related = [];
		private static $aliases = [];
		private static $instances = [];
		protected static $pdo;
		protected static $table;
		// Magic functions
		private function __construct($idOrData){
			if(!isset(self::$aliases[$this->name])){
				$aliases = [
					'belongs_to' => [],
					'has_one' => [],
					'has_many' => []
				];
				if(is_array(static::$belongs_to)){
					foreach(static::$belongs_to as $alias => $details){
						$aliases['belongs_to'][$alias] = array_merge(
							[
								'model'=>$alias,
								'foreign_key'=>$this->name.static::foreign_key_suffix()
							],
							$details
						);
					}
				}
				if(is_array(static::$has_one)){
					foreach(static::$has_one as $alias => $details){
						$aliases['has_one'][$alias] = array_merge(
							[
								'model'=>$alias,
								'foreign_key'=>$alias.static::foreign_key_suffix()
							],
							$details
						);
					}
				}
				if(is_array(static::$has_many)){
					foreach(static::$has_many as $alias => $details){
						$aliases['has_many'][$alias] = array_merge(
							[
								'model'=>$alias,
								'foreign_key'=>$this->name.static::foreign_key_suffix(),
								'through'=> null
							],
							$details
						);
					}
				}
				self::$aliases[$this->name] = $aliases;
				// Clear relationship definitions to save memory
				static::$belongs_to = static::$has_one = static::$has_many = null;
			}
			if(is_array($idOrData)){
				if(isset($idOrData[static::primary_key()]) && self::cached($idOrData[static::primary_key()])){
					throw new \Exception('Instance already cached');
				}
				foreach($idOrData as $key => $val){
					$this->set($key, $val);
				}
			}else{
				if(self::cached($idOrData)){
					throw new \Exception('Instance already cached');
				}
				$this->_data[static::primary_key()] = (int)$idOrData;
			}
			self::$instances[] = $this;
		}
		public function __destruct(){
			$this->__sleep();
			$this->_changed = [];
			$this->_data = [];
			$this->_related = [];
			$key = array_search($this, self::$instances);
			if($key !== false){
				array_splice(self::$instances, $key, 1);
			}
		}
		public function __get(string $name){
			switch($name){
				case 'pdo':
					return static::pdo();
				case 'table':
					return static::table();
				case 'name':
					return static::table_name();
				case 'primary_key':
					return static::primary_key();
				case 'foreign_key_suffix':
					return static::foreign_key_suffix();
				case 'has_one':case 'has_many':case 'belongs_to':
					return self::$aliases[$this->name][$name];
				case 'id':
					$pk = static::primary_key();
					return $this->has($pk) ? $this->get($pk) : null;
				default:
					throw new \Exception('Unknown property '.$name);
			}
		}
		public function __set(string $name, $val){
			switch($name){
				case 'id':case 'name':
					throw new \Exception('Property '.$name.' is read only');
				break;
				default:
					throw new \Exception('Unknown property '.$name);
			}
		}
		public function __sleep(){
			$this->save();
		}
		public function __wakeup(){
			$this->reload(true);
		}
		public function __toString(){
			return $this->name.'('.$this->id.')';
		}
		public function __invoke(){
			return $this->_data;
		}
		public function __clone(){
			unset($this->$_data[static::primary_key()]);
		}
		// JsonSerializable
		public function jsonSerialize(){
			return $this->_data;
		}
		// ArrayAccess
		public function offsetSet($key, $val){
			$this->set($key, $val);
		}
		public function offsetExists($key){
			return $this->has($key);
		}
		public function offsetUnset($key){
			return $this->unset($key);
		}
		public function offsetGet($key){
			return $this->get($key);
		}
		// Main API
		public static function table_name(){
			if(is_null(static::$table_name)){
				$name = get_called_class();
				return substr($name, strrpos($name, '\\') + 1);
			}
			return static::$table_name;
		}
		public static function table(){
			if(is_null(static::$table)){
				static::$table = self::$pdo->table(static::table_name());
			}
			return static::$table;
		}
		public static function primary_key(){
			return static::$primary_key;
		}
		public static function foreign_key_suffix(){
			return static::$foreign_key_suffix;
		}
		public static function bind($pdo){
			if(is_string($pdo)){
				$pdo = PDO::from($pdo);
			}
			if($pdo instanceof SecureString){
				$pdo = PDO::from((string)$pdo);
			}
			if($pdo instanceof PDO){
				self::$pdo = $pdo;
				// @todo handle updating live instances
			}else{
				throw new \Exception("Invalid argument. Must pass a DSN string or a PDO instance");
			}
		}
		public static function pdo(){
			return static::$pdo;
		}
		public static function query(...$args){
			return self::$pdo->query(...$args);
		}
		public static function exec(...$args){
			return self::$pdo->exec(...$args);
		}
		public static function prepare(...$args){
			return self::$pdo->prepare(...$args);
		}
		public static function quote(...$args){
			return self::$pdo->quote(...$args);
		}
		public static function instance(int $id){
			$instance = self::cached_instance($id);
			if(!is_null($instance)){
				return $instance;
			}elseif(self::exists($id)){
				return new static($id);
			}
			return null;
		}
		public static function create(array $data){
			return new static($data);
		}
		public static function exists(int $id){
			$query = self::query(
				"select count(1) as count ".
				"from `".static::table_name().'` '.
				"where `".static::primary_key()."` = ".self::quote($id)
			);
			$count = $query->fetch()['count'];
			$query->closeCursor();
			return (int)$count > 0;
		}
		public static function cached_instance(int $id){
			$name = static::table_name();
			if(isset(self::$instances[$name])){
				$instances = array_filter(self::$instances[$name], function(&$instance){
					return $instance->id === $id;
				});
				return isset($instances[0]) ? $instances[0] : null;
			}
			return null;
		}
		public static function cached(int $id){
			return !is_null(self::cached_instance($id));
		}
		public static function delete(int $id){
			return static::table()->delete([
				static::primary_key() => $id
			]) > 0;
		}
		public static function each_cached(callable $fn){
			$name = static::table_name();
			if(self::$instances[$name]){
				array_walk(self::$instances[$name], $fn);
			}
		}
		public static function each(callable $fn, array $filter = null, int $start = null, int $amount = null){
			static::table()->each(function($row) use($fn){
				$fn(self::instance((int)$row['id']));
			}, [static::primary_key()], $filter, $start, $amount);
		}
		public static function fetch(array $filter = null, int $start = null, int $amount = null){
			$results = [];
			self::each(function($item) use($results){
				$results[] = $item;
			}, $filter, $start, $amount);
			return $results;
		}
		public static function str_replace_first($search, $replace, $source) {
			$explode = explode($search, $source);
			$shift = array_shift($explode);
			$implode = implode($search, $explode);
			return $shift.$replace.$implode;
		}
		public static function models(){
			array_filter(get_declared_classes(), function($class){
				return 0 === strpos($class, "Model\\");
			});
		}
		public static function count(array $filter = null){
			return static::table()->count($filter);
		}
		// Instance Api
		public function values($values){
			foreach($values as $key => $val){
				$this->set($key, $val);
			}
			return $this;
		}
		public function load(bool $force = false){
			if(!$force && $this->dirty()){
				throw new \Exception('Cannot load, there are pending changes');
			}else{
				if(!is_null($this->id)){
					$query = self::query(
						"select * " .
						"from {$this->name} ".
						"where ".static::primary_key()." = ".self::quote($this->id)
					);
					$data = $query->fetch();
					$query->closeCursor();
					if($data === false){
						throw new \Exception("{$this->name} with ".static::primary_key()." of {$this->id} does not exist");
					}
					$this->_data = $data;
				}
			}
			return $this;
		}
		public function save(){
			if($this->dirty()){
				$data = [];
				foreach($this->_changed as $key){
					if(isset($this->_data[$key]) || is_null($this->_data[$key])){
						$data[$key] = $this->_data[$key];
					}else{
						$data[$key] = null;
					}
				}
				$pk = static::primary_key();
				$table = static::table();
				if($this->has($pk) && !is_null($this->id) && !in_array($pk, $this->_changed)){
					$table->update($data, [
						$pk => $this->id
					]);
				}else{
					$table->insert($data);
					$this->_data[$pk] = self::$pdo->lastInsertId();
				}
				foreach($this->_related as $related){
					$related->save();
				}
			}
			// Always fetch again from the database in case saving
			// forces something to change at the database level
			return $this->reload(true);
		}
		public function reload(bool $force = false){
			if($force){
				$this->_changed = [];
			}
			return $this->load($force);
		}
		public function clear(){
			return $this->reload(true);
		}
		public function get($key){
			return $this->_data[$key];
		}
		public function set($key, $val){
			if($key === static::primary_key() && !is_null($this->id)){
				throw new \Exception('You are not allowed to change the primary key');
			}
			$this->_data[$key] = $val;
			$this->_changed = array_merge($this->_changed, [$key]);
			return $this;
		}
		public function unset($key){
			unset($this->_data[$key]);
			return $this;
		}
		public function has($key){
			return isset($this->_data[$key]);
		}
		public function dirty(string $key = null){
			if(is_null($key)){
				$dirty = count($this->_changed) > 0;
				if(!$dirty){
					foreach($this->_related as $rel){
						if($rel instanceof Relationship){
							$dirty = $rel->dirty();
						}
						if($dirty){
							break;
						}
					}
				}
				return $dirty;
			}else{
				return in_array($key, $this->_changed);
			}
		}
		public function related(string $name){
			if(!isset($this->_related[$name])){
				$aliases = self::$aliases[$this->name];
				if(isset($aliases['belongs_to'][$name])){
					$alias = $aliases['has_many'][$name];
					$class = "\\Model\\{$alias['model']}";
					$this->_related[$name] = $class::fetch([$alias['foreign_key'] => $this->id])[0];
				}elseif(isset($aliases['has_one'][$name])){
					$alias = $aliases['has_many'][$name];
					$class = "\\Model\\{$alias['model']}";
					$this->_related[$name] = $class::instance($this[$alias['foreign_key']]);
				}elseif(isset($aliases['has_many'][$name])){
					$alias = $aliases['has_many'][$name];
					$class = "\\Model\\{$alias['model']}";
					$sql = "select ";
					if($alias['through']){
						$sql .= $class::table_name().$class::foreign_key_suffix()." id from {$alias['through']} ";
					}else{
						$sql .= $class::primary_key()." id from {$alias['model']} ";
					}
					$sql .= "where ".$alias['foreign_key']." = ".self::quote($this->id);
					$related = [];
					$query = self::query($sql);
					foreach($query->fetchAll() as $row){
						$related[$row['id']] = new $class($row['id']);
					}
					$query->closeCursor();
					$this->_related[$name] = Relationship::from($this, $name, $alias, $related);
				}
			}
			if(isset($this->_related[$name])){
				return $this->_related[$name];
			}else{
				throw new \Exception("Relationship {$name} does not exist");
			}
		}
		public function release(string $name){
			if(isset($this->_related[$name])){
				unset($this->_related[$name]);
			}
		}
	}
?>