<?php
/**
 * @brief		Followable Trait for Content Models/Comments
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @since		02 Dec 2013
 */

namespace IPS\Content;

use ArrayObject;
use BadMethodCallException;
use IPS\Content;
use IPS\core\Followed\Follow;
use IPS\Db;
use IPS\Events\Event;
use IPS\IPS;
use IPS\Member;
use IPS\Notification;
use IPS\Application;
use	IPS\DateTime;
use IPS\Node\Model;
use IPS\Db\Select;
use ArrayIterator;
use Iterator;
use OutOfRangeException;
use UnderflowException;
use function defined;
use function header;
use function mb_strtolower;
use function mb_substr;
use function mb_strrpos;
use function get_called_class;
use function get_class;

/* To prevent PHP errors (extending class does not exist) revealing path */

if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ($_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0') . ' 403 Forbidden' );
	exit;
}

/**
 * Followable Trait for Content Models/Comments
 */
trait Followable
{
    /**
     * Follow an object. Using a static method here
     * because Model does not use the Followable trait
     *
     * @param object $object
     * @param string $frequency
     * @param bool $public
     * @param Member|null $member
     * @return void
     */
    public static function staticFollow( object $object, string $frequency, bool $public=true, ?Member $member=null ) : void
    {
        $member = $member ?: Member::loggedIn();

        try
        {
            /* Are we already following this? */
            $follow = Follow::loadByObject( $object, $member );
        }
        catch( OutOfRangeException )
        {
            $follow = new Follow;
            $follow->object = $object;
            $follow->member_id = $member->member_id;
        }

        $follow->is_anon = !$public;
        $follow->notify_do = ( $frequency == 'none' ? 0 : 1 );
        $follow->notify_freq = $frequency;
        $follow->save();
		$followParams = Follow::getFollowParameters( $object );
		$idCol = $object::$databaseColumnId;
		$member->clearFollowingCache( $followParams['app'], $followParams['area'], $object->$idCol );

        /* Fire the event */
        Event::fire( 'onFollow', $member, [ $object, $public ] );
    }

    /**
     * Follow this object
     *
     * @param string        $frequency      ( 'none', 'immediate', 'daily', 'weekly' )
     * @param bool          $public
     * @param Member|null   $member
     * @return void
     */
    public function follow( string $frequency, bool $public=true, ?Member $member=null ) : void
    {
        static::staticFollow( $this, $frequency, $public, $member );

        /* If this is an item, handle achievements */
        if( $this instanceof Item )
        {
            Member::loggedIn()->achievementAction( 'core', 'FollowContentItem', [
                'item' => $this,
                'author' => $this->author()
            ] );
        }
    }

    /**
     * Nodes don't use followable, so we have a static method on the item level
     *
     * @param Model $container
     * @param Member|null $member
     * @return void
     */
    public static function containerUnfollow( Model $container, ?Member $member=null ) : void
    {
        try
        {
            $member = $member ?: Member::loggedIn();
            Follow::loadByObject( $container, $member )->delete();
        }
        catch( OutOfRangeException ){}
    }

    /**
     * Unfollow this object
     *
     * @param Member|null $member
     * @param string|null   $followId
     * @return void
     */
    public function unfollow( ?Member $member=null, ?string $followId=null ) : void
    {
        $member = $member ?: Member::loggedIn();

        try
        {
            /* We might already have the follow ID (like in the notifications controller) */
            if( $followId === null )
            {
                Follow::loadByObject( $this, $member )->delete();

	            $followParams = Follow::getFollowParameters( $this );
	            $idCol = $this::$databaseColumnId;
	            $member->clearFollowingCache( $followParams['app'], $followParams['area'], $this->$idCol );
            }
            else
            {
                Follow::load( $followId )->delete();
            }
        }
        catch( OutOfRangeException ){}
    }

	/**
	 * @brief	Cache for current follow data, used on "My Followed Content" screen
	 */
	public ?array $_followData = array();

	/**
	 * Followers
	 *
	 * @param	int						$privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'none', 'immediate', 'daily', 'weekly' )
	 * @param	DateTime|int|NULL		$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @param	int|array|NULL			$limit			LIMIT clause
	 * @param	string|NULL				$order			Column to order by
	 * @param	bool					$countOnly		Return only the count
	 * @return	Select|int
	 * @throws	BadMethodCallException
	 */
	public function followers(int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL, int|array|null $limit=array( 0, 25 ), string|null $order=NULL, bool $countOnly=FALSE ): Select|int
	{
		$idColumn = static::$databaseColumnId;

		return static::_followers( mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) ), $this->$idColumn, $privacy, $frequencyTypes, $date, $limit, $order, $countOnly, null );
	}

	/**
	 * Followers Count
	 *
	 * @param	int						$privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'none', 'immediate', 'daily', 'weekly' )
	 * @param	DateTime|int|NULL	$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @return	int
	 * @throws	BadMethodCallException
	 */
	public function followersCount( int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL ): int
	{
		return $this->followers( $privacy, $frequencyTypes, $date, NULL, NULL, TRUE );
	}

	/**
	 * Followers Count
	 *
	 * @param	array					$items			Array of \IPS\Content\Item
	 * @param	int						$privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'none', 'immediate', 'daily', 'weekly' )
	 * @param	DateTime|int|null	$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @return	array|Iterator|int
	 * @throws	BadMethodCallException
	 */
	public static function followersCounts( array $items, int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL ): array|Iterator|int
	{
		$ids = array();
		$idField = NULL;
		foreach( $items as $item )
		{
			if ( $idField === NULL )
			{
				$idField = static::$databaseColumnId;
			}
			$ids[] = $item->$idField;
		}

		return static::_followersCount( mb_strtolower( mb_substr( get_called_class(), mb_strrpos( get_called_class(), '\\' ) + 1 ) ), $ids, $privacy, $frequencyTypes, $date );
	}
	
	/**
	 * Container Followers
	 *
	 * @param	Model			$container		The container
	 * @param	int						$privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'none', 'immediate', 'daily', 'weekly' )
	 * @param	DateTime|int|NULL	$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @param	int|array|NULL			$limit			LIMIT clause
	 * @param	string|NULL				$order			Column to order by
	 * @param	bool					$countOnly		Return only the count
	 * @return	Select|int
	 */
	public static function containerFollowers( Model $container, int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL, int|array|null $limit=array( 0, 25 ), string|null $order=NULL, bool $countOnly=FALSE ): Select|int
	{
		return static::_followers( mb_strtolower( mb_substr( get_class( $container ), mb_strrpos( get_class( $container ), '\\' ) + 1 ) ), $container->_id, $privacy, $frequencyTypes, $date, $limit, $order, $countOnly, null );
	}

	/**
	 * Container Follower Count
	 *
	 * @param Model $container The container
	 * @param int $privacy Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param array $frequencyTypes array( 'immediate', 'daily', 'weekly' )
	 * @param DateTime|int|null $date Only users who started following before this date will be returned. NULL for no restriction
	 * @return array|Iterator|int
     */
	public static function containerFollowerCount( Model $container, int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL ): array|Iterator|int
	{
		/* Return the count */
		return static::_followersCount( mb_strtolower( mb_substr( get_class( $container ), mb_strrpos( get_class( $container ), '\\' ) + 1 ) ), $container->_id, $privacy, $frequencyTypes, $date );
	}

	/**
	 * Container Follower Count
	 *
	 * @param	array					$containers		Array of \IPS\Node\Model
	 * @param	int						$privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param	array					$frequencyTypes	array( 'immediate', 'daily', 'weekly' )
	 * @param	DateTime|int|NULL	$date			Only users who started following before this date will be returned. NULL for no restriction
	 * @return	int|ArrayIterator
	 */
	public static function containerFollowerCounts( array $containers, int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), DateTime|int|null $date=NULL ): int|ArrayIterator
	{
		$ids = array();
		$class = NULL;
		foreach( $containers as $node )
		{
			if ( $class === NULL )
			{
				$class = get_class( $node );
			}

			$ids[] = $node->_id;
		}

		/* Return the count */
		return static::_followersCount( mb_strtolower( mb_substr( $class, mb_strrpos( $class, '\\' ) + 1 ) ), $ids, $privacy, $frequencyTypes, $date );
	}

	/**
	 * Return the count of followers for all tags in this item
	 *
	 * @param int $privacy
	 * @param array $frequencyTypes
	 * @return int|ArrayIterator
	 */
	public function tagsFollowerCount( int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ) ) : int|ArrayIterator
	{
		if( !IPS::classUsesTrait( $this, Taggable::class ) )
		{
			return 0;
		}

		$ids = Tag::getTagIdsForItem( $this );
		if( !count( $ids ) )
		{
			return 0;
		}

		/* Return the count */
		$total = 0;
		foreach( static::_followersCount( 'tag', $ids, $privacy, $frequencyTypes, $this->mapped( 'date' ), 'core' ) as $k => $v )
		{
			$total += $v['count'];
		}

		return $total;
	}

	/**
	 * Return the followers for all tags in this item
	 *
	 * @param int $privacy
	 * @param array $frequencyTypes
	 * @param int|array|null $limit
	 * @param string|null $order
	 * @param bool $countOnly
	 * @return int|Iterator|array
	 */
	public function tagsFollowers( int $privacy=3, array $frequencyTypes=array( 'none', 'immediate', 'daily', 'weekly' ), int|array|null $limit=array( 0, 25 ), string|null $order=NULL, bool $countOnly=FALSE ): int|Iterator|array
	{
		if( !IPS::classUsesTrait( $this, Taggable::class ) )
		{
			return 0;
		}

		$tagIds = Tag::getTagIdsForItem( $this );
		return static::_followers( 'tag', $tagIds, $privacy, $frequencyTypes, $this->mapped( 'date' ), $limit, $order, $countOnly, 'core' );
	}

	/**
	 * Users to receive immediate notifications
	 *
	 * @param int|array|null $limit LIMIT clause
	 * @param bool $countOnly Just return the count
	 * @return Select|int
	 */
	public function notificationRecipients( int|array|null $limit=array( 0, 25 ), bool $countOnly=FALSE ): Select|int
	{
		if( $this instanceof Comment )
		{
			return $this->item()->notificationRecipientsForComments( $limit, $countOnly, $this );
		}

		/* Do we only want the count? */
		if( $countOnly )
		{
			$count	= 0;
			$count	+= $this->author()->followersCount( 3, array( 'immediate' ), $this->mapped('date') );
			$count	+= static::containerFollowerCount( $this->container(), 3, array( 'immediate' ), $this->mapped('date') );
			return $count;
		}

		$unionFollowers = [];
		$memberFollowersCount = $this->author()->followersCount( 3, array( 'immediate' ), $this->mapped( 'date' ) );
		if( $memberFollowersCount )
		{
			$unionFollowers[] = $this->author()->followers( 3, array( 'immediate' ), $this->mapped('date'), NULL );
		}

		if( count( $unionFollowers ) )
		{
			$unions	= array_merge(
				array(
					static::containerFollowers( $this->container(), 3, array( 'immediate' ), $this->mapped('date'), NULL )
				),
				$unionFollowers
			);

			return Db::i()->union( $unions, 'follow_added', $limit );
		}
		else
		{
			return static::containerFollowers( $this->container(), Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS, array( 'immediate' ), $this->mapped('date'), $limit, 'follow_added' );
		}
	}

	/**
	 * Users to receive immediate notifications
	 *
	 * @param	int|array|null		$limit		LIMIT clause
	 * @param	boolean			$countOnly	Just return the count
	 * @param	Comment|null	$comment	The comment that is triggering this notification
	 * @return Select|int
	 */
	public function notificationRecipientsForComments( int|array|null $limit=array( 0, 25 ), bool $countOnly=FALSE, ?Comment $comment=null ): Select|int
	{
		if( !( $comment instanceof Comment ) or !IPS::classUsesTrait( $this, Followable::class ) or ( !$comment->item() instanceof static ) )
		{
			return 0;
		}

		/* Do we only want the count? */
		if( $countOnly )
		{
			$count	= 0;
			$count	+= $comment->author()->followersCount( 3, array( 'immediate' ), $comment->mapped('date') );
			if( IPS::classUsesTrait( $this, 'IPS\Content\Followable' ) )
			{
				$count	+= $this->followersCount( 3, array( 'immediate' ), $comment->mapped('date') );
			}

			return $count;
		}

		$memberFollowers = $comment->author()->followers( 3, array( 'immediate' ), $comment->mapped('date'), NULL );

		if( $memberFollowers !== 0 )
		{
			$unions	= array(
				$this->followers( 3, array( 'immediate' ), $comment->mapped('date'), NULL ),
				$memberFollowers
			);

			return Db::i()->union( $unions, 'follow_added', $limit );
		}
		else
		{
			return $this->followers( static::FOLLOW_PUBLIC + static::FOLLOW_ANONYMOUS, array( 'immediate' ), $comment->mapped('date'), $limit, 'follow_added' );
		}
	}

	
	/**
	 * Create Notification
	 *
	 * @param	mixed		$extra		Additional data
	 * @param	Comment|null	$comment
	 * @return	Notification
	 */
	public function createNotification( mixed $extra=NULL, ?Comment $comment=null ): Notification
	{
		if( $comment instanceof Comment )
		{
			$key = ( $comment instanceof Review ) ? 'new_review' : 'new_comment';
			return new Notification( Application::load( 'core' ), $key, $this, array( $comment ) );
		}

		// New content is sent with itself as the item as we deliberately do not group notifications about new content items. Unlike comments where you're going to read them all - you might scan the notifications list for topic titles you're interested in
		return new Notification( Application::load( 'core' ), 'new_content', $this, array( $this ), $extra );
	}

	/**
	 * @brief	Cache followers count query to prevent running it multiple times
	 */
	protected static array $followerCountCache = array();

	/**
	 * Get follow data
	 *
	 * @param string $area			Area
	 * @param array|int $id				ID or array of IDs
	 * @param int $privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param array $frequencyTypes	array( 'none', 'immediate', 'daily', 'weekly' )
	 * @param int|DateTime|null $date			Only users who started following before this date will be returned. NULL for no restriction
	 * @param array|int|null $limit			LIMIT clause
	 * @param string|null $order			Column to order by
	 * @param bool $countOnly		Return only the count
	 * @param string|null $app
	 * @return	Iterator|int|array
	 * @throws	BadMethodCallException
	 */
	public static function _followers( string $area, array|int $id, int $privacy, array $frequencyTypes, int|DateTime $date=NULL, array|int $limit=NULL, string $order=NULL, bool $countOnly = FALSE, ?string $app = null ): int|Iterator|array
	{
		/* We might need to override this - for tags, specifically */
		if( $app === null )
		{
			$app = static::$application;
		}

		/* Normalize the input */
		sort( $frequencyTypes );

		/* Can we use the cache table? */
		$canCache = FALSE;
		$cached = array();
		if ( count( $frequencyTypes ) == 4 and $countOnly and ( $privacy == Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS ) )
		{
			$canCache = TRUE;

			if ( is_array( $id ) )
			{
				foreach( Db::i()->select( 'id, count', 'core_follow_count_cache', array( 'class=? and ' . Db::i()->in( 'id', $id ), Follow::getClassToFollow( $app, $area ) ) ) as $row )
				{
					$cached[ $row['id'] ] = array( 'count' => $row['count'], 'follow_rel_id' => $row['id'] );
				}

				/* Got everything? */
				if ( count( $id ) == count( $cached ) )
				{
					$obj = new ArrayObject( $cached );
					return $obj->getIterator();
				}
			}
			else
			{
				$_key = md5( $app . $area . $id );

				if( isset( static::$followerCountCache[ $_key ] ) )
				{
					return static::$followerCountCache[ $_key ];
				}

				try
				{
					static::$followerCountCache[ $_key ] = (int) Db::i()->select( 'count', 'core_follow_count_cache', array('class=? and id=?', Follow::getClassToFollow( $app, $area ), $id ) )->first();

					return static::$followerCountCache[ $_key ];
				}
				catch ( UnderflowException ){}
			}
		}

		/* We need to use a group by if $id is an array, but otherwise not */
		$groupBy = NULL;

		/* Initial where clause */
		if( is_array( $id ) )
		{
			$where[]	= array( 'follow_app=? AND follow_area=? AND follow_rel_id IN(' . implode( ',', $id ) . ')', $app, $area );

			if( $area != 'tag' )
			{
				$groupBy	= 'follow_rel_id';
			}
		}
		else
		{
			$where[] = array( 'follow_app=? AND follow_area=? AND follow_rel_id=?', $app, $area, $id );
		}

		/* Public / Anonymous */
		if ( !( $privacy & Content::FOLLOW_PUBLIC ) )
		{
			$where[] = array( 'follow_is_anon=1' );
		}
		elseif ( !( $privacy & Content::FOLLOW_ANONYMOUS ) )
		{
			$where[] = array( 'follow_is_anon=0' );
		}

		/* Specific type */
		if ( count( array_diff( array( 'immediate', 'daily', 'weekly', 'none' ), $frequencyTypes ) ) )
		{
			$where[] = array( Db::i()->in( 'follow_notify_freq', $frequencyTypes ) );
		}

		/* Since */
		if( $date !== NULL )
		{
			$where[] = array( 'follow_added<?', ( $date instanceof DateTime ) ? $date->getTimestamp() : intval( $date ) );
		}

		/* We don't need order or limit if we're doing a count only, which makes the query more efficient */
		if( $countOnly === TRUE )
		{
			$limit = NULL;
			$order = NULL;
		}

		/* Cache the results as this may be called multiple times in one page load */
		static $cache	= array();
		$_hash			= md5( json_encode( func_get_args() ) );

		if( isset( $cache[ $_hash ] ) )
		{
			return $cache[ $_hash ];
		}

		/* Get */
		if ( $order === 'name' )
		{
			$cache[ $_hash ]	= Db::i()->select( 'core_follow.*, core_members.name', 'core_follow', $where, 'name ASC', $limit )->join( 'core_members', array( 'core_members.member_id=core_follow.follow_member_id' ) );
		}
		else
		{
			$cache[ $_hash ]	= Db::i()->select( $countOnly ? ( is_array( $id ) ? 'COUNT(*) as count, follow_rel_id' : 'COUNT(*)' ) : 'core_follow.*', 'core_follow', $where, $order, $limit, $groupBy );
		}

		/* If we only want the count, fetch it and store it now */
		if( $countOnly )
		{
			if( is_array( $id ) )
			{
				$args = func_get_args();

				foreach( $cache[ $_hash ] as $result )
				{
					$args[1] = $result['follow_rel_id'];
					$cache[ md5( json_encode( $args ) ) ] = $result;

					if ( $canCache and ! isset( $cached[ $result['follow_rel_id'] ] ) )
					{
						Db::i()->replace( 'core_follow_count_cache', array(
							'id'	 => $result['follow_rel_id'],
							'class'  => Follow::getClassToFollow( $app, $area ),
							'count'  => $result['count'],
							'added'  => time()
						) );
					}

					$cached[ $result['follow_rel_id'] ] = $result;
				}

				/* And then any that do not exist were not found in the query, so they're 0 */
				foreach( $id as $_id )
				{
					$args[1] = $_id;
					$cache[ md5( json_encode( $args ) ) ] = array( 'follow_rel_id' => $id, 'count' => 0 );

					if ( $canCache and ! isset( $cached[ $_id ] ) )
					{
						Db::i()->replace( 'core_follow_count_cache', array(
							'id'	 => $_id,
							'class'  => Follow::getClassToFollow( $app, $area ),
							'count'  => 0,
							'added'  => time()
						) );
					}
				}
			}
			else
			{
				$cache[ $_hash ] = $cache[ $_hash ]->first();

				if ( $canCache )
				{
					Db::i()->replace( 'core_follow_count_cache', array(
						'id'	 => $id,
						'class'  => Follow::getClassToFollow( $app, $area ),
						'count'  => $cache[ $_hash ],
						'added'  => time()
					) );
				}
			}
		}

		if ( $canCache AND isset( $cached ) AND is_array( $id ) )
		{
			$obj = new ArrayObject( $cached );
			return $obj->getIterator();
		}

		return $cache[ $_hash ];
	}

	/**
	 * Get follower count
	 *
	 * @param string $area			Area
	 * @param array|int $id				ID or array of IDs
	 * @param int $privacy		Content::FOLLOW_PUBLIC + Content::FOLLOW_ANONYMOUS
	 * @param array $frequencyTypes	array( 'immediate', 'daily', 'weekly' )
	 * @param int|DateTime|null $date			Only users who started following before this date will be returned. NULL for no restriction
	 * @param string|null $app
	 * @return	array|Iterator|int
	 * @throws	BadMethodCallException
	 */
	public static function _followersCount( string $area, array|int $id, int $privacy=3, array $frequencyTypes=array( 'immediate', 'daily', 'weekly', 'none' ), int|DateTime|null $date=NULL, ?string $app = null ): array|Iterator|int
	{
		return static::_followers( $area, $id, $privacy, $frequencyTypes, $date, NULL, NULL, TRUE, $app );
	}
}