<?php

namespace WPGraphQL\Model;

use Exception;

/**
 * Class Model - Abstract class for modeling data for all core types
 *
 * @property bool $isPrivate
 * @property bool $isPublic
 * @property bool $isRestricted
 *
 * @template TData
 */
abstract class Model {

	/**
	 * Stores the name of the type the child class extending this one represents
	 *
	 * @var string
	 */
	protected $model_name;

	/**
	 * Stores the raw data passed to the child class when it's instantiated before it's transformed
	 *
	 * @var TData
	 */
	protected $data;

	/**
	 * Stores the capability name for what to check on the user if the data should be considered
	 * "Restricted"
	 *
	 * @var string
	 */
	protected $restricted_cap;

	/**
	 * Stores the array of allowed fields to show if the data is restricted
	 *
	 * @var string[]
	 */
	protected $allowed_restricted_fields;

	/**
	 * Stores the DB ID of the user that owns this piece of data, or null if there is no owner
	 *
	 * @var int|null
	 */
	protected $owner;

	/**
	 * Stores the WP_User object for the current user in the session
	 *
	 * @var \WP_User $current_user
	 */
	protected $current_user;

	/**
	 * Stores the visibility value for the current piece of data
	 *
	 * @var string
	 */
	protected $visibility;

	/**
	 * The fields for the modeled object. This will be populated in the child class
	 *
	 * @var array<string,mixed>
	 */
	public $fields;

	/**
	 * Model constructor.
	 *
	 * @param string   $restricted_cap            The capability to check against to determine if
	 *                                            the data should be restricted or not
	 * @param string[] $allowed_restricted_fields The allowed fields if the data is in fact restricted
	 * @param int|null $owner                     Database ID of the user that owns this piece of
	 *                                            data to compare with the current user ID
	 *
	 * @return void
	 * @throws \Exception Throws Exception.
	 */
	protected function __construct( $restricted_cap = '', $allowed_restricted_fields = [], $owner = null ) {
		if ( empty( $this->data ) ) {
			// translators: %s is the name of the model.
			throw new Exception( esc_html( sprintf( __( 'An empty data set was used to initialize the modeling of this %s object', 'wp-graphql' ), $this->get_model_name() ) ) );
		}

		$this->restricted_cap            = $restricted_cap;
		$this->allowed_restricted_fields = $allowed_restricted_fields;
		$this->owner                     = $owner;
		$this->current_user              = wp_get_current_user();

		if ( 'private' === $this->get_visibility() ) {
			return;
		}

		$this->init();
		$this->prepare_fields();
	}

	/**
	 * Magic method to re-map the isset check on the child class looking for properties when
	 * resolving the fields
	 *
	 * @param string $key The name of the field you are trying to retrieve
	 *
	 * @return bool
	 */
	public function __isset( $key ) {
		return isset( $this->fields[ $key ] );
	}

	/**
	 * Magic method to re-map setting new properties to the class inside of the $fields prop rather
	 * than on the class in unique properties
	 *
	 * @param string                    $key   Name of the key to set the data to
	 * @param callable|int|string|mixed $value The value to set to the key
	 *
	 * @return void
	 */
	public function __set( $key, $value ) {
		$this->fields[ $key ] = $value;
	}

	/**
	 * Magic method to re-map where external calls go to look for properties on the child objects.
	 * This is crucial to let objects modeled through this class work with the default field
	 * resolver.
	 *
	 * @param string $key Name of the property that is trying to be accessed
	 *
	 * @return mixed|null
	 */
	public function __get( $key ) {
		if ( ! array_key_exists( $key, $this->fields ) ) {
			return null;
		}

		// If the property is a callable, we need to process it.
		if ( is_callable( $this->fields[ $key ] ) ) {
			$data       = call_user_func( $this->fields[ $key ] );
			$this->$key = $data;

			return $data;
		}

		return $this->fields[ $key ];
	}

	/**
	 * Setup the global state before each field is resolved so the Model has the necessary context.
	 *
	 * @return void
	 */
	public function setup() {
	}

	/**
	 * Tear-down call that runs after each field is resolved.
	 *
	 * This can be used to reset state to where it was before the model was setup.
	 *
	 * @return void
	 */
	public function tear_down() {
	}

	/**
	 * Returns the name of the model, built from the child className
	 *
	 * @return string
	 */
	protected function get_model_name() {
		if ( empty( $this->model_name ) ) {
			$name = static::class;

			if ( false !== strpos( static::class, '\\' ) ) {
				$starting_character = strrchr( static::class, '\\' );
				if ( ! empty( $starting_character ) ) {
					$name = substr( $starting_character, 1 );
				}
			}
			$this->model_name = $name . 'Object';
		}

		return $this->model_name;
	}

	/**
	 * Return the visibility state for the current piece of data
	 *
	 * @return string|null
	 */
	public function get_visibility() {
		if ( null === $this->visibility ) {

			/**
			 * Filter for the capability to check against for restricted data
			 *
			 * @param string      $restricted_cap The capability to check against
			 * @param string      $model_name     Name of the model the filter is currently being executed in
			 * @param TData       $data           The un-modeled incoming data
			 * @param string|null $visibility     The visibility that has currently been set for the data at this point
			 * @param int|null    $owner          The user ID for the owner of this piece of data
			 * @param \WP_User $current_user The current user for the session
			 *
			 * @return string
			 */
			$protected_cap = apply_filters( 'graphql_restricted_data_cap', $this->restricted_cap, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

			/**
			 * Filter to short circuit default is_private check for the model. This is expensive in some cases so
			 * this filter lets you prevent this from running by returning a true or false value.
			 *
			 * @param ?bool       $is_private   Whether the model data is private. Defaults to null.
			 * @param string      $model_name   Name of the model the filter is currently being executed in
			 * @param TData       $data         The un-modeled incoming data
			 * @param string|null $visibility   The visibility that has currently been set for the data at this point
			 * @param int|null    $owner        The user ID for the owner of this piece of data
			 * @param \WP_User $current_user The current user for the session
			 *
			 * @return bool|null
			 */
			$pre_is_private = apply_filters( 'graphql_pre_model_data_is_private', null, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

			// If 3rd party code has not filtered this, use the Models default logic to determine
			// whether the model should be considered private
			if ( null !== $pre_is_private ) {
				$is_private = $pre_is_private;
			} else {
				$is_private = $this->is_private();
			}

			/**
			 * Filter to determine if the data should be considered private or not
			 *
			 * @param bool        $is_private   Whether the model is private
			 * @param string      $model_name   Name of the model the filter is currently being executed in
			 * @param TData       $data         The un-modeled incoming data
			 * @param string|null $visibility   The visibility that has currently been set for the data at this point
			 * @param int|null    $owner        The user ID for the owner of this piece of data
			 * @param \WP_User    $current_user The current user for the session
			 *
			 * @return bool
			 */
			$is_private = apply_filters( 'graphql_data_is_private', (bool) $is_private, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

			if ( true === $is_private ) {
				$this->visibility = 'private';
			} elseif ( null !== $this->owner && true === $this->owner_matches_current_user() ) {
				$this->visibility = 'public';
			} elseif ( empty( $protected_cap ) || current_user_can( $protected_cap ) ) {
				$this->visibility = 'public';
			} else {
				$this->visibility = 'restricted';
			}
		}

		/**
		 * Filter the visibility name to be returned
		 *
		 * @param string|null $visibility   The visibility that has currently been set for the data at this point
		 * @param string      $model_name   Name of the model the filter is currently being executed in
		 * @param TData       $data         The un-modeled incoming data
		 * @param int|null    $owner        The user ID for the owner of this piece of data
		 * @param \WP_User    $current_user The current user for the session
		 *
		 * @return string
		 */
		return apply_filters( 'graphql_object_visibility', $this->visibility, $this->get_model_name(), $this->data, $this->owner, $this->current_user );
	}

	/**
	 * Method to return the private state of the object. Can be overwritten in classes extending
	 * this one.
	 *
	 * @return bool
	 */
	protected function is_private() {
		return false;
	}

	/**
	 * Whether or not the owner of the data matches the current user
	 *
	 * @return bool
	 */
	protected function owner_matches_current_user() {
		if ( empty( $this->current_user->ID ) || empty( $this->owner ) ) {
			return false;
		}

		return absint( $this->owner ) === absint( $this->current_user->ID );
	}

	/**
	 * Restricts fields for the data to only return the allowed fields if the data is restricted
	 *
	 * @return void
	 */
	protected function restrict_fields() {
		$this->fields = array_intersect_key(
			$this->fields,
			array_flip(
			/**
			 * Filter for the allowed restricted fields
			 *
			 * @param string[]    $allowed_restricted_fields The fields to allow when the data is designated as restricted to the current user
			 * @param string      $model_name                Name of the model the filter is currently being executed in
			 * @param TData       $data                      The un-modeled incoming data
			 * @param string|null $visibility                The visibility that has currently been set for the data at this point
			 * @param int|null    $owner                     The user ID for the owner of this piece of data
			 * @param \WP_User    $current_user The current user for the session
			 */
				apply_filters( 'graphql_allowed_fields_on_restricted_type', $this->allowed_restricted_fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user )
			)
		);
	}

	/**
	 * Wraps all fields with another callback layer so we can inject hooks & filters into them
	 *
	 * @return void
	 */
	protected function wrap_fields() {
		if ( ! is_array( $this->fields ) || empty( $this->fields ) ) {
			return;
		}

		$clean_array = [];
		foreach ( $this->fields as $key => $data ) {
			$clean_array[ $key ] = function () use ( $key, $data ) {
				/**
				 * Filter to short circuit the callback for any field on a type.
				 *
				 * Returning anything other than null will stop the callback for the field from executing,
				 * and will return your data or execute your callback instead.
				 *
				 * @param mixed    $result       The data returned from the callback. Null by default.
				 * @param string   $key          The name of the field on the type
				 * @param string   $model_name   Name of the model the filter is currently being executed in
				 * @param TData    $data         The un-modeled incoming data
				 * @param string   $visibility   The visibility setting for this piece of data
				 * @param int|null $owner        The user ID for the owner of this piece of data
				 * @param \WP_User $current_user The current user for the session
				 */
				$pre = apply_filters( 'graphql_pre_return_field_from_model', null, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

				if ( ! is_null( $pre ) ) {
					// If the pre filter returns a value, we use that instead of the callback.
					$result = $pre;
				} else {
					$result = $this->prepare_field( $key, $data );
				}

				/**
				 * Hook that fires after the data is returned for the field
				 *
				 * @param mixed    $result       The returned data for the field
				 * @param string   $key          The name of the field on the type
				 * @param string   $model_name   Name of the model the filter is currently being executed in
				 * @param TData    $data         The un-modeled incoming data
				 * @param string   $visibility   The visibility setting for this piece of data
				 * @param int|null $owner        The user ID for the owner of this piece of data
				 * @param \WP_User $current_user The current user for the session
				 */
				do_action( 'graphql_after_return_field_from_model', $result, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

				return $result;
			};
		}

		$this->fields = $clean_array;
	}

	/**
	 * Prepares an individual field for the model.
	 *
	 * @param string $field_name The name of the field on the type
	 * @param TData  $field      The field data to prepare.
	 *
	 * @return TData
	 */
	private function prepare_field( string $field_name, $field ) {
		$can_access_field = $this->current_user_can_access_field( $field_name, $field );

		// If the field is an array with a 'callback', use that as the callback.
		if ( is_array( $field ) && ! empty( $field['callback'] ) ) {
			$field = $field['callback'];
		}

		// If the user doesn't have access to the field, sanitize it to null.
		if ( ! $can_access_field ) {
			$field = null;
		}

		if ( is_callable( $field ) ) {
			$this->setup();
			$field = call_user_func( $field );
			$this->tear_down();
		}

		/**
		 * Filter the data returned by the default callback for the field
		 *
		 * @param mixed    $field        The data returned from the callback
		 * @param string   $field_name   The name of the field on the type
		 * @param string   $model_name   Name of the model the filter is currently being executed in
		 * @param TData    $data         The un-modeled incoming data
		 * @param string   $visibility   The visibility setting for this piece of data
		 * @param int|null $owner        The user ID for the owner of this piece of data
		 * @param \WP_User $current_user The current user for the session
		 */
		return apply_filters( 'graphql_return_field_from_model', $field, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
	}

	/**
	 * Returns the capability to check for the field, or null if there is no capability set.
	 *
	 * @uses 'graphql_model_field_capability' to filter the capability to check for the field.
	 *
	 * @param string $field_name The name of the field to check
	 * @param mixed  $field The original metadata for the field.
	 */
	private function current_user_can_access_field( string $field_name, $field ): bool {
		$capability = '';

		// If the field metadata is an array, check for the capability key
		if ( is_array( $field ) && isset( $field['capability'] ) ) {
			$capability = (string) $field['capability'];
		}

		/**
		 * Capability to check required for the field
		 *
		 * @param string   $capability   The capability to check against to return the field
		 * @param string   $field_name   The name of the field on the type
		 * @param string   $model_name   Name of the model the filter is currently being executed in
		 * @param TData    $data         The un-modeled incoming data
		 * @param string   $visibility   The visibility setting for this piece of data
		 * @param int|null $owner        The user ID for the owner of this piece of data
		 * @param \WP_User $current_user The current user for the session
		 */
		$capability = apply_filters( 'graphql_model_field_capability', $capability, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );

		if ( empty( $capability ) ) {
			return true;
		}

		// @todo add support passing capability args.
		return current_user_can( $capability );
	}

	/**
	 * Adds the model visibility fields to the data
	 */
	private function add_model_visibility(): void {

		/**
		 * @todo: potentially abstract this out into a more central spot
		 */
		$this->fields['isPublic']     = function () {
			return 'public' === $this->get_visibility();
		};
		$this->fields['isRestricted'] = function () {
			return 'restricted' === $this->get_visibility();
		};
		$this->fields['isPrivate']    = function () {
			return 'private' === $this->get_visibility();
		};
	}

	/**
	 * Returns instance of the data fully modeled
	 *
	 * @return void
	 */
	protected function prepare_fields() {
		if ( 'restricted' === $this->get_visibility() ) {
			$this->restrict_fields();
		}

		/**
		 * Filter the array of fields for the Model before the object is hydrated with it
		 *
		 * @param array<string,mixed>    $fields       The array of fields for the model
		 * @param string                 $model_name   Name of the model the filter is currently being executed in
		 * @param TData                  $data         The un-modeled incoming data
		 * @param string                 $visibility   The visibility setting for this piece of data
		 * @param ?int                   $owner        The user ID for the owner of this piece of data
		 * @param \WP_User               $current_user The current user for the session
		 */
		$this->fields = apply_filters( 'graphql_model_prepare_fields', $this->fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
		$this->wrap_fields();
		$this->add_model_visibility();
	}

	/**
	 * Given a string, and optional context, this decodes html entities if html_entity_decode is
	 * enabled.
	 *
	 * @param string $str        The string to decode
	 * @param string $field_name The name of the field being encoded
	 * @param bool   $enabled    Whether decoding is enabled by default for the string passed in
	 *
	 * @return string
	 */
	public function html_entity_decode( $str, $field_name, $enabled = false ) {

		/**
		 * Determine whether html_entity_decode should be applied to the string
		 *
		 * @param bool                   $enabled    Whether decoding is enabled by default for the string passed in
		 * @param string                 $str        The string to decode
		 * @param string                 $field_name The name of the field being encoded
		 * @param \WPGraphQL\Model\Model $model      The Model the field is being decoded on
		 */
		$decoding_enabled = apply_filters( 'graphql_html_entity_decoding_enabled', $enabled, $str, $field_name, $this );

		if ( false === $decoding_enabled ) {
			return $str;
		}

		return html_entity_decode( $str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' );
	}

	/**
	 * Filter the fields returned for the object
	 *
	 * @param string|string[]|null $fields The field or fields to build in the modeled object. Null to leave all fields.
	 * @return void
	 */
	public function filter( $fields ) {
		if ( is_string( $fields ) ) {
			$fields = [ $fields ];
		}

		if ( is_array( $fields ) ) {
			$this->fields = array_intersect_key( $this->fields, array_flip( $fields ) );
		}
	}

	/**
	 * Initialized the object.
	 *
	 * @return void
	 */
	abstract protected function init();
}
