<?php

namespace Limb_Chatbot\Includes\Data_Objects;

use JsonSerializable;
use Limb_Chatbot\Includes\Database_Strategy_Interface;
use Limb_Chatbot\Includes\Services\Collection;
use Limb_Chatbot\Includes\Services\Data_Object_Collection;
use Limb_Chatbot\Includes\Traits\Json_Serializable_Trait;
use ReflectionClass;
use ReflectionObject;
use stdClass;
use WP_Post;

/**
 * Abstract base class for all data objects.
 *
 * Provides common functionality such as hydration, serialization, database operations, etc.
 *
 * @since 1.0.0
 */
abstract class Data_Object implements JsonSerializable {

	use Json_Serializable_Trait;

	/**
	 * Default UUID column name.
	 *
	 * @since 1.0.0
	 */
	const UUID_COLUMN_NAME = 'uuid';

	/**
	 * Raw input data used for hydration.
	 *
	 * @var mixed
	 * @since 1.0.0
	 * @json_excluded
	 */
	public $data;

	/**
	 * Constructor.
	 *
	 * @param mixed|null $instance An ID, WP_Post, stdClass, or array.
	 * @since 1.0.0
	 */
	public function __construct( $instance = null ) {
		if ( is_numeric( $instance ) ) {
			$this->set_id( $instance );
		} elseif ( $instance instanceof WP_Post || is_array( $instance ) || $instance instanceof stdClass ) {
			$this->set_data( $instance );
			$this->hydrate();
		}
		// Allowing new Data_Object without $instance argument.
	}

	/**
	 * Set the object ID.
	 *
	 * @param mixed $id
	 * @since 1.0.0
	 */
	public function set_id( $id ) {
		$this->id = $id;
	}

	/**
	 * Hydrates object properties from internal `$data`.
	 *
	 * @since 1.0.0
	 */
	protected function hydrate() {
		if ( is_array( $this->data ) || $this->data instanceof stdClass ) {
			foreach ( $this->data as $key => $value ) {
				if ( property_exists( $this, $key ) ) {
					$setter = 'set' . '_' . strtolower( $key );
					if ( method_exists( $this, $setter ) ) {
						call_user_func( [ $this, $setter ], $value );
					} else {
						$this->{$key} = $value;
					}
				}
			}
		} elseif ( $this->data instanceof WP_Post ) {
			$this->id         = $this->data->ID;
			$this->title      = $this->data->post_title;
			$this->created_at = $this->data->post_date;
			$this->updated_at = $this->data->post_modified;
			if ( property_exists( $this, 'uuid' ) ) {
				$this->uuid = method_exists( $this, 'get_meta' ) ? $this->get_meta( '_' . self::UUID_COLUMN_NAME ) : null;
			}
			if ( property_exists( $this, 'status' ) ) {
				$this->status = WP_Post_Data_Object::get_our_status_equivalent( $this->data->post_status );
			}
		}
	}

	/**
	 * Creates a new record and returns the hydrated object.
	 *
	 * @param array $data
	 * @return static
	 * @since 1.0.0
	 */
	public static function create( $data ) {
		foreach ( $data as $key => $value ) {
			if ( is_object( $value ) || is_array( $value ) || $value instanceof Collection ) {
				$data[ $key ] = json_encode( $value, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE );
			}
		}

		return static::make( static::get_db_strategy()->create( $data ) );
	}

	/**
	 * Returns the database strategy used by the data object.
	 *
	 * @return Database_Strategy_Interface|null
	 * @since 1.0.0
	 */
	abstract static function get_db_strategy(): ?Database_Strategy_Interface;

	/**
	 * Create a new instance from data.
	 *
	 * @param mixed $data
	 * @return static
	 * @since 1.0.0
	 */
	public static function make( $data = [] ) {
		return new static( $data );
	}

	/**
	 * Find a record by ID.
	 *
	 * @param mixed $id
	 * @return static
	 * @since 1.0.0
	 */
	public static function find( $id ) {
		return static::make( static::get_db_strategy()->find( $id, static::TABLE_NAME ) );
	}

	/**
	 * Updates a record and returns the hydrated object.
	 *
	 * @param array $where
	 * @param array $data
	 * @return static
	 * @since 1.0.0
	 */
	public static function update( $where, $data ) {
		foreach ( $data as $key => $value ) {
			if ( is_object( $value ) || is_array( $value ) || $value instanceof Collection ) {
				$data[ $key ] = json_encode( $value, JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_UNICODE );
			}
		}

		return static::make( static::get_db_strategy()->update( $where, $data ) );
	}

	/**
	 * Deletes a record.
	 *
	 * @param array $where
	 * @return bool
	 * @since 1.0.0
	 */
	public static function delete( $where ): bool {
		return static::get_db_strategy()->delete( $where );
	}

	/**
	 * Runs a where query and returns a collection.
	 *
	 * @param array $where
	 * @param mixed ...$args
	 * @return Data_Object_Collection
	 * @since 1.0.0
	 */
	public static function where( $where, ...$args ): Data_Object_Collection {
		$results = static::get_db_strategy()->where( $where, ...$args );
		foreach ( $results as $result ) {
			$objects[] = static::make( $result );
		}

		return new Data_Object_Collection( $objects ?? [] );
	}

	/**
	 * Count the number of matching records.
	 *
	 * @param array $where
	 * @param mixed ...$args
	 * @return int|null
	 * @since 1.0.0
	 */
	public static function count( $where = [], ...$args ): ?int {
		return static::get_db_strategy()->count( $where, ...$args );
	}

	/**
	 * Get raw data.
	 *
	 * @return mixed
	 * @since 1.0.0
	 */
	public function get_data() {
		return $this->data;
	}

	/**
	 * Set raw data.
	 *
	 * @param  mixed  $data
	 *
	 * @since 1.0.0
	 */
	public function set_data( $data ) {
		$this->data = $data;
	}

	/**
	 * Get object ID.
	 *
	 * @return mixed
	 * @since 1.0.0
	 */
	public function get_id() {
		return $this->id;
	}

	/**
	 * Magic getter.
	 *
	 * @param  string  $name
	 *
	 * @return mixed|null
	 * @since 1.0.0
	 */
	public function __get( $name ) {
		$getter = 'get_' . strtolower( $name );
		if ( method_exists( $this, $getter ) ) {
			return call_user_func( [ $this, $getter ] );
		}
		if ( property_exists( $this, $name ) ) {
			return $this->$name;
		}
		if ( isset( $this->$name ) ) {
			return $this->$name;
		}

		return null;
	}

	/**
	 * Magic setter.
	 *
	 * @param string $name
	 * @param mixed $value
	 * @since 1.0.0
	 */
	public function __set( $name, $value ) {
		$setter = 'set' . '_' . strtolower( $name );
		if ( method_exists( $this, $setter ) ) {
			call_user_func( [ $this, $setter ], $value );
		}
		$this->$name = $value;
	}

	/**
	 * Refresh current object's values with another instance of same class.
	 *
	 * @param Data_Object $source
	 * @since 1.0.0
	 */
	public function refresh( $source ) {
		$reflection = new ReflectionClass( $this );
		if ( get_class( $source ) != static::class ) {
			return;
		}
		if ( $this !== $source ) {
			// Only if it's not the same object
			foreach ( $reflection->getProperties() as $property ) {
				if ( in_array( $property->getName(), [ 'id', 'data' ] ) ) {
					continue;
				}
				$property->setAccessible( true );
				$new_value = $property->isInitialized( $source ) ? $property->getValue( $source ) : null;
				if ( isset( $new_value ) ) {
					$property->setValue( $this, $new_value );
				}
			}
		}
	}

	/**
	 * Convert all initialized public and protected properties to array.
	 *
	 * @return array
	 * @since 1.0.0
	 */
	public function to_internal_array(): array {
		$reflection = new ReflectionClass( $this );
		$data       = [];
		foreach ( $reflection->getProperties() as $prop ) {
			$prop->setAccessible( true );
			if ( $prop->isInitialized( $this ) ) {
				$data[ $prop->getName() ] = $prop->getValue($this);
			}
		}

		return $data;
	}

	/**
	 * Load specified relation(s) onto object.
	 *
	 * @param  string|array  $relations  One or more relation method names to load.
	 * @param  array  $args  Optional arguments passed to each relation method.
	 *
	 * @return static
	 * @since 1.0.0
	 *
	 */
	public function with( $relations, $args = [] ) {
		if ( is_array( $relations ) ) {
			foreach ( $relations as $item ) {
				$this->include_property( $item, $args );
			}
		} else {
			$this->include_property( $relations, $args );
		}

		return $this;
	}

	/**
	 * Includes the specified relation property for the item.
	 *
	 * This uses reflection to verify the relation method exists, and populates the `included` property
	 * of the object if supported.
	 *
	 * @param  string  $relation  Name of the relation method to include.
	 * @param  array  $args  Optional arguments passed to the relation method.
	 *
	 * @return void
	 * @since 1.0.0
	 *
	 */
	private function include_property( $relation, $args ) {
		if ( property_exists( $this, 'included' ) && method_exists( $this, $relation ) ) {
			$this->included[ $relation ] = $this->{$relation}( $args );
		}
	}
}