<?php

/**
 * @brief		Converter vBulletin 4.x Blog Class
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @package		Invision Community
 * @subpackage	convert
 * @since		21 Jan 2015
 */

namespace IPS\convert\Software\Blog;

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

use DomainException;
use Exception;
use InvalidArgumentException;
use IPS\blog\Entry;
use IPS\convert\App;
use IPS\convert\Software;
use IPS\convert\Software\Core\Vbulletin as VbulletinClass;
use IPS\Db;
use IPS\Http\Url;
use IPS\Member;
use IPS\Request;
use IPS\Task;
use OutOfRangeException;
use UnderflowException;
use function count;
use function defined;
use function is_null;

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

/**
 * vBulletin Blog Converter
 */
class Vbulletin extends Software
{
	/**
	 * @brief	vBulletin 4 Stores all attachments under one table - this will store the content type for the blog app.
	 */
	protected static mixed $entryContentType		= NULL;
	
	/**
	 * @brief	The schematic for vB3 and vB4 is similar enough that we can make specific concessions in a single converter for either version.
	 */
	protected static ?bool $isLegacy					= NULL;
	
	/**
	 * Constructor
	 *
	 * @param	App	$app	The application to reference for database and other information.
	 * @param	bool				$needDB	Establish a DB connection
	 * @return	void
	 * @throws	InvalidArgumentException
	 */
	public function __construct( App $app, bool $needDB=TRUE )
	{
		parent::__construct( $app, $needDB );
		
		/* Is this vB3 or vB4? */
		try
		{
			if ( static::$isLegacy === NULL AND $needDB )
			{
				$version = $this->db->select( 'value', 'setting', array( "varname=?", 'templateversion' ) )->first();
				
				if ( mb_substr( $version, 0, 1 ) == '3' )
				{
					static::$isLegacy = TRUE;
				}
				else
				{
					static::$isLegacy = FALSE;
				}
			}
			
			
			/* If this is vB4, what is the content type ID for posts? */
			if ( static::$entryContentType === NULL AND ( static::$isLegacy === FALSE OR is_null( static::$isLegacy ) ) AND $needDB )
			{
				static::$entryContentType = $this->db->select( 'contenttypeid', 'contenttype', array( "class=?", 'BlogEntry' ) )->first();
			}
		}
		catch( Exception $e ) {}
	}
	
	/**
	 * Software Name
	 *
	 * @return    string
	 */
	public static function softwareName(): string
	{
		/* Child classes must override this method */
		return "vBulletin Blog (3.8.x/4.x)";
	}
	
	/**
	 * Software Key
	 *
	 * @return    string
	 */
	public static function softwareKey(): string
	{
		/* Child classes must override this method */
		return "vbulletin";
	}
	
	/**
	 * Content we can convert from this software. 
	 *
	 * @return    array|null
	 */
	public static function canConvert(): ?array
	{
		$attachmentTable = 'blog_attachment';
		$attachmentWhere = NULL;
		if ( static::$isLegacy === FALSE OR is_null( static::$isLegacy ) )
		{
			$attachmentTable = 'attachment';
			$attachmentWhere = array( "contenttypeid=?", static::$entryContentType );
		}
		
		return array(
			'convertBlogs'			=> array(
				'table'		=> 'blog_user',
				'where'		=> NULL,
			),
			'convertBlogEntryCategories' => array(
				'table'		=> 'blog_category',
				'where'		=> NULL
			),
			'convertBlogEntries'	=> array(
				'table'		=> 'blog',
				'where'		=> NULL
			),
			'convertBlogComments'	=> array(
				'table'		=> 'blog_text',
				'where'		=> NULL
			),
			'convertAttachments'	=> array(
				'table'		=> $attachmentTable,
				'where'		=> $attachmentWhere
			)
		);
	}
	
	/**
	 * Count Source Rows for a specific step
	 *
	 * @param string $table		The table containing the rows to count.
	 * @param string|array|NULL $where		WHERE clause to only count specific rows, or NULL to count all.
	 * @param bool $recache	Skip cache and pull directly (updating cache)
	 * @return    integer
	 * @throws	\IPS\convert\Exception
	 */
	public function countRows( string $table, string|array|null $where=NULL, bool $recache=FALSE ): int
	{
		switch( $table )
		{
			default:
				return parent::countRows( $table, $where );
			
			case 'blog_text':
				return $this->db->select( 'COUNT(*)', 'blog_text', array( 'blogtextid NOT IN (' . $this->db->select( 'firstblogtextid', 'blog' ) . ')' ) )->first();
		}
	}

	/**
	 * Requires Parent
	 *
	 * @return    boolean
	 */
	public static function requiresParent(): bool
	{
		return TRUE;
	}
	
	/**
	 * Possible Parent Conversions
	 *
	 * @return    array|null
	 */
	public static function parents(): ?array
	{
		return array( 'core' => array( 'vbulletin' ) );
	}

	/**
	 * Finish - Adds everything it needs to the queues and clears data store
	 *
	 * @return    array        Messages to display
	 */
	public function finish(): array
	{
		/* Content Rebuilds */
		Task::queue( 'core', 'RebuildContainerCounts', array( 'class' => 'IPS\blog\Entry\Category', 'count' => 0 ), 4, array( 'class' ) );
		Task::queue( 'convert', 'RebuildContent', array( 'app' => $this->app->app_id, 'link' => 'blog_entries', 'class' => 'IPS\blog\Entry' ), 2, array( 'app', 'link', 'class' ) );
		Task::queue( 'convert', 'RebuildContent', array( 'app' => $this->app->app_id, 'link' => 'blog_comments', 'class' => 'IPS\blog\Entry\Comment' ), 2, array( 'app', 'link', 'class' ) );
		Task::queue( 'convert', 'RebuildTagCache', array( 'app' => $this->app->app_id, 'link' => 'blog_entries', 'class' => 'IPS\blog\Entry' ), 3, array( 'app', 'link', 'class' ) );
		
		return array( "f_blog_entries_rebuilding", "f_entry_comments_rebuilding", "f_blogs_recounting", "f_entry_tags_cache" );
	}

	/**
	 * Pre-process content for the Invision Community text parser
	 *
	 * @param	string			The post
	 * @param	string|null		Content Classname passed by post-conversion rebuild
	 * @param	int|null		Content ID passed by post-conversion rebuild
	 * @param	App|null		App object if available
	 * @return	string			The converted post
	 */
	public static function fixPostData( string $post, ?string $className=null, ?int $contentId=null, ?App $app=null ): string
	{
		return VbulletinClass::fixPostData( $post, $className, $contentId, $app );
	}

	/**
	 * List of conversion methods that require additional information
	 *
	 * @return    array
	 */
	public static function checkConf(): array
	{
		return array( 'convertAttachments' );
	}
	
	/**
	 * Get More Information
	 *
	 * @param string $method	Conversion method
	 * @return    array|null
	 */
	public function getMoreInfo( string $method ): ?array
	{
		$return = array();
		switch( $method )
		{
			case 'convertAttachments':
				$return['convertAttachments'] = array(
					'file_location' => array(
						'field_class'			=> 'IPS\\Helpers\\Form\\Radio',
						'field_default'			=> 'database',
						'field_required'		=> TRUE,
						'field_extra'			=> array(
							'options'				=> array(
								'database'				=> Member::loggedIn()->language()->addToStack( 'conv_store_database' ),
								'file_system'			=> Member::loggedIn()->language()->addToStack( 'conv_store_file_system' ),
							),
							'userSuppliedInput'	=> 'file_system',
						),
						'field_hint'			=> NULL,
						'field_validation'	=> function( $value ) { if ( $value != 'database' AND !@is_dir( $value ) ) { throw new DomainException( 'path_invalid' ); } },
					)
				);
				break;
		}
		
		return ( isset( $return[ $method ] ) ) ? $return[ $method ] : array();
	}

	/**
	 * Convert blogs
	 *
	 * @return	void
	 */
	public function convertBlogs() : void
	{
		$libraryClass = $this->getLibrary();
		
		$libraryClass::setKey( 'bloguserid' );
		
		foreach( $this->fetch( 'blog_user', 'bloguserid' ) as $blog )
		{
			try
			{
				$member = $this->db->select( '*', 'user', array( "userid=?", $blog['bloguserid'] ) )->first();
			}
			catch( UnderflowException $e )
			{
				/* User no longer exists, so skip */
				$libraryClass->setLastKeyValue( $blog['bloguserid'] );
				continue;
			}
			
			$socialgroup = NULL;
			
			/* If this is a private blog, then we need to convert it to use a social group for invited users */
			if ( $blog['options_member'] == 0 AND $blog['options_buddy'] > 0 )
			{
				/* Fetch users friends */
				$friends = array();
				foreach( $this->db->select( 'relationid', 'userlist', array( "userid=?", $blog['bloguserid'] ) ) AS $friend )
				{
					$friends[$friend] = $friend;
				}
				
				$socialgroup = array( 'members' => $friends );
			}
			
			$info = array(
				'blog_id'					=> $blog['bloguserid'],
				'blog_member_id'			=> $blog['bloguserid'],
				'blog_allowguests'			=> ( $blog['options_guest'] > 0 ) ? 1 : 0, # we don't care about specific options
				'blog_rating_total'			=> $blog['ratingtotal'],
				'blog_rating_count'			=> $blog['ratingnum'],
				'blog_settings'				=> array( 'allowrss' => TRUE ),
				'blog_name'					=> $blog['title'] ?: $member['username'] . "'s Blog",
				'blog_description'			=> $blog['description'] ?: '',
				'blog_last_edate'			=> $blog['lastblog'],
				'blog_count_entries'		=> $blog['entries'],
				'blog_count_comments'		=> $blog['comments'],
				'blog_count_entries_hidden'	=> $blog['deleted'],
				'blog_rating_average'		=> $blog['rating'],
				'blog_count_entries_future'	=> $blog['pending'],
			);
			
			$libraryClass->convertBlog( $info, $socialgroup );
			
			foreach( $this->db->select( '*', 'blog_subscribeuser', array( "userid=?", $blog['bloguserid'] ) ) AS $follow )
			{
				$frequency = 'none';
				if ( $follow['type'] == 'email' )
				{
					$frequency = 'immediate';
				}
				
				$libraryClass->convertFollow( array(
					'follow_app'			=> 'blog',
					'follow_area'			=> 'blogs',
					'follow_rel_id'			=> $blog['bloguserid'],
					'follow_rel_id_type'	=> 'blog_blogs',
					'follow_member_id'		=> $follow['userid'],
					'follow_is_anon'		=> 0,
					'follow_added'			=> $follow['dateline'] ?: time(),
					'follow_notify_do'		=> 1,
					'follow_notify_meta'	=> '',
					'follow_notify_freq'	=> $frequency,
					'follow_notify_sent'	=> 0,
					'follow_visible'		=> 1,
				) );
			}
			
			$libraryClass->setLastKeyValue( $blog['bloguserid'] );
		}
	}

	/**
	 * Convert blog entries
	 *
	 * @return	void
	 */
	public function convertBlogEntryCategories() : void
	{
		$libraryClass = $this->getLibrary();
		$libraryClass::setKey( 'blogcategoryid' );

		foreach( $this->fetch( 'blog_category', 'blogcategoryid' ) AS $category )
		{
			$libraryClass->convertBlogEntryCategory( array(
				'entry_category_id'			=> $category['blogcategoryid'],
				'entry_category_blog_id'	=> $category['userid'],
				'entry_category_name'		=> $category['title'],
				'entry_category_position'	=> $category['displayorder'],
				'entry_category_count'		=> $category['entrycount']
			) );

			$libraryClass->setLastKeyValue( $category['blogcategoryid'] );
		}
	}

	/**
	 * Convert blog entries
	 *
	 * @return	void
	 */
	public function convertBlogEntries() : void
	{
		$libraryClass = $this->getLibrary();
		
		$libraryClass::setKey( 'blogid' );
		
		foreach( $this->fetch( 'blog', 'blogid' ) AS $entry )
		{
			try
			{
				$text = $this->db->select( '*', 'blog_text', array( "blogtextid=?", $entry['firstblogtextid'] ) )->first();
			}
			catch( UnderflowException $e )
			{
				$libraryClass->setLastKeyValue( $entry['blogid'] );
				continue;
			}
			
			$lastcommenterid = 0;
			
			try
			{
				$lastcommenterid = $this->db->select( 'userid', 'blog_text', array( "blogtextid=?", $entry['lastblogtextid'] ) )->first();
			}
			catch( UnderflowException $e ) {}
			
			$status = 'published';
			if ( $entry['state'] == 'draft' )
			{
				$status = 'draft';
			}
			
			$edittime = $editname = $editReason = NULL;
			try
			{
				$editlog = $this->db->select( 'dateline, username, reason', 'blog_editlog', array( "blogtextid=?", $entry['firstblogtextid'] ), "dateline DESC" )->first();
				$edittime = $editlog['dateline'];
				$editname = $editlog['username'];
				$editReason = $editlog['reason'];
			}
			catch( UnderflowException $e ) {}

			$categories = explode( ',', $entry['categories'] );
			$mainCategory = array_pop( $categories );
			
			$info = array(
				'entry_id'					=> $entry['blogid'],
				'entry_blog_id'				=> $entry['userid'],
				'entry_name'				=> $text['title'],
				'entry_content'				=> $text['pagetext'],
				'entry_author_id'			=> $entry['postedby_userid'],
				'entry_author_name'			=> $entry['postedby_username'],
				'entry_date'				=> $entry['dateline'],
				'entry_status'				=> $status,
				'entry_num_comments'		=> $entry['comments_visible'],
				'entry_queued_comments'		=> $entry['comments_moderation'],
				'entry_views'				=> $entry['views'],
				'entry_hidden_comments'		=> $entry['comments_deleted'],
				'entry_last_comment_mid'	=> $lastcommenterid,
				'entry_edit_time'			=> $edittime,
				'entry_edit_name'			=> $editname,
				'entry_edit_reason'			=> $editReason,
				'entry_append_edit'			=> (bool)$edittime,
				'entry_ip_address'			=> long2ip( (int) $text['ipaddress'] ),
				'entry_category_id'			=> $mainCategory
			);
			
			$libraryClass->convertBlogEntry( $info );
			
			foreach( $this->db->select( '*', 'blog_rate', array( "blogid=?", $entry['blogid'] ) ) as $rating )
			{
				$libraryClass->convertRating( array(
					'id'		=> $rating['blograteid'],
					'class'		=> 'IPS\blog\Entry',
					'item_link'	=> 'blog_entries',
					'item_id'	=> $entry['blogid'],
					'rating'	=> $rating['vote'],
					'ip'		=> $rating['ipaddress'],
					'member'	=> $rating['userid'],
				) );
			}

			if ( count( $categories ) )
			{
				foreach( $categories as $category )
				{
					try
					{
						$data = $this->db->select( '*', 'blog_category', array( "blogcategoryid=?", $category ) )->first();
					}
					catch( UnderflowException $e )
					{
						continue;
					}
					
					$libraryClass->convertTag( array(
						'tag_meta_app'			=> 'blog',
						'tag_meta_area'			=> 'blogs',
						'tag_meta_parent_id'	=> $entry['userid'],
						'tag_meta_id'			=> $entry['blogid'],
						'tag_text'				=> $data['title'],
						'tag_member_id'			=> $data['userid'],
						'tag_added'             => $entry['dateline'],
						'tag_prefix'			=> 0
					) );
				}
			}
			
			foreach( $this->db->select( '*', 'blog_subscribeentry', array( "blogid=?", $entry['blogid'] ) ) AS $follow )
			{
				$frequency = 'none';
				if ( $follow['type'] == 'email' )
				{
					$frequency = 'immediate';
				}
				
				$libraryClass->convertFollow( array(
					'follow_app'			=> 'blog',
					'follow_area'			=> 'entries',
					'follow_rel_id'			=> $entry['blogid'],
					'follow_rel_id_type'	=> 'blog_entries',
					'follow_member_id'		=> $follow['userid'],
					'follow_is_anon'		=> 0,
					'follow_added'			=> $follow['dateline'] ?: time(),
					'follow_notify_do'		=> 1,
					'follow_notify_meta'	=> '',
					'follow_notify_freq'	=> $frequency,
					'follow_notify_sent'	=> 0,
					'follow_visible'		=> 1,
				) );
			}
			
			$libraryClass->setLastKeyValue( $entry['blogid'] );
		}
	}

	/**
	 * Convert blog comments
	 *
	 * @return	void
	 */
	public function convertBlogComments() : void
	{
		$libraryClass = $this->getLibrary();
		
		$libraryClass::setKey( 'blogtextid' );

		// Exclude text of blog entry from iterator
		foreach( $this->fetch( 'blog_text', 'blogtextid', array( 'blogtextid NOT IN (' . $this->db->select( 'firstblogtextid', 'blog' ) . ')' ) ) AS $comment )
		{
			$edittime = $editname = NULL;
			try
			{
				$editlog = $this->db->select( 'dateline, username', 'blog_editlog', array( "blogtextid=?", $comment['blogtextid'] ), "dateline DESC" )->first();
				$edittime = $editlog['dateline'];
				$editname = $editlog['username'];
			}
			catch( UnderflowException $e ) {}
			
			switch( $comment['state'] )
			{
				case 'moderation':
					$approved = 0;
					break;
				
				case 'deleted':
					$approved = -1;
					break;
				
				case 'visible':
				default:
					$approved = 1;
					break;
			}
			
			$libraryClass->convertBlogComment( array(
				'comment_id'					=> $comment['blogtextid'],
				'comment_entry_id'				=> $comment['blogid'],
				'comment_text'					=> $comment['pagetext'],
				'comment_member_id'				=> $comment['userid'],
				'comment_member_name'			=> $comment['username'],
				'comment_ip_address'			=> long2ip( (int) $comment['ipaddress'] ),
				'comment_date'					=> $comment['dateline'],
				'comment_edit_time'				=> $edittime,
				'comment_edit_member_name'		=> $editname,
				'comment_edit_show'				=> 1,
				'comment_approved'				=> $approved
			) );

			$libraryClass->setLastKeyValue( $comment['blogtextid'] );
		}
	}

	/**
	 * Convert attachments
	 *
	 * @return	void
	 */
	public function convertAttachments() : void
	{
		$libraryClass = $this->getLibrary();
		
		$libraryClass::setKey( 'attachmentid' );
		
		$where			= NULL;
		$column			= NULL;
		
		if ( static::$isLegacy === FALSE OR is_null( static::$isLegacy ) )
		{
			$where			= array( "contenttypeid=?", static::$entryContentType );
			$column			= 'contentid';
			$table			= 'attachment';
		}
		else
		{
			$column			= 'blogid';
			$table			= 'blog_attachment';
		}
		
		foreach( $this->fetch( $table, 'attachmentid', $where ) as $attachment )
		{
			if ( static::$isLegacy === FALSE OR is_null( static::$isLegacy ) )
			{
				try
				{
					$filedata = $this->db->select( '*', 'filedata', array( "filedataid=?", $attachment['filedataid'] ) )->first();
				}
				catch( UnderflowException $e )
				{
					/* If the filedata row is missing, there isn't much we can do. */
					$libraryClass->setLastKeyValue( $attachment['attachmentid'] );
					$this->app->log( 'vb_attach_missing_filedata', __METHOD__, App::LOG_WARNING, $attachment['attachmentid'] );
					continue;
				}
			}
			else
			{
				$filedata				= $attachment;
				$filedata['filedataid']	= $attachment['attachmentid'];
			}
			
			$map = array(
				'id1'		=> $attachment[$column],
				'id2'		=> NULL
			);
			
			$info = array(
				'attach_id'			=> $attachment['attachmentid'],
				'attach_file'		=> $attachment['filename'],
				'attach_date'		=> $attachment['dateline'],
				'attach_member_id'	=> $attachment['userid'],
				'attach_hits'		=> $attachment['counter'],
				'attach_ext'		=> $filedata['extension'],
				'attach_filesize'	=> $filedata['filesize'],
			);
			
			if ( $this->app->_session['more_info']['convertAttachments']['file_location'] == 'database' )
			{
				/* Simples! */
				$data = $filedata['filedata'];
				$path = NULL;
			}
			else
			{
				$data = NULL;
				$path = implode( '/', preg_split( '//', $filedata['userid'], -1, PREG_SPLIT_NO_EMPTY ) );
				if ( static::$isLegacy === FALSE )
				{
					$file = $attachment['filedataid'];
				}
				else
				{
					$file = $attachment['attachmentid'];
				}
				$path = rtrim( $this->app->_session['more_info']['convertAttachments']['file_location'], '/' ) . '/' . $path . '/' . $file . '.attach';
			}
			
			$attach_id = $libraryClass->convertAttachment( $info, $map, $path, $data );

			if ( $attach_id !== FALSE )
			{
				try
				{
					/* Do some re-jiggery on the post itself to make sure attachment displays */
					$entry_id = $this->app->getLink( $attachment[ $column ], 'blog_entries' );

					$post = Db::i()->select( 'entry_content', 'blog_entries', array( "entry_id=?", $entry_id ) )->first();

					if ( preg_match( "/\[ATTACH([^\]]+?)?\]".$attachment['attachmentid']."\[\/ATTACH\]/i", $post ) )
					{
						$post = preg_replace( "/\[ATTACH([^\]]+?)?\]" . $attachment['attachmentid'] . "\[\/ATTACH\]/i", '[attachment=' . $attach_id . ':name]', $post );
						Db::i()->update( 'blog_entries', array( 'entry_content' => $post ), array( "entry_id=?", $entry_id ) );
					}
				}
				catch( UnderflowException|OutOfRangeException $e ) {}
			}

			$libraryClass->setLastKeyValue( $attachment['attachmentid'] );
		}
	}

	/**
	 * Check if we can redirect the legacy URLs from this software to the new locations
	 *
	 * @return    Url|NULL
	 */
	public function checkRedirects(): ?Url
	{
		$url = Request::i()->url();

		if( preg_match( '#/entries/([0-9]+)#i', $url->data[ Url::COMPONENT_PATH ], $matches ) )
		{
			try
			{
				$data = (string) $this->app->getLink( (int) $matches[1], array( 'blog_entries' ) );
				$item = Entry::load( $data );

				if( $item->can( 'view' ) )
				{
					return $item->url();
				}
			}
			catch( Exception $e ) {}
		}

		return NULL;
	}
}