<?php
// Copyright 2011 Toby Zerner, Simon Zerner
// This file is part of esoTalk. Please see the included license file for usage information.

if (!defined("IN_ESOTALK")) exit;

/**
 * The member model provides functions for retrieving and managing member data. It also provides methods to
 * handle "last action" types.
 *
 * @package esoTalk
 */
class ETMemberModel extends ETModel {


/**
 * Reserved user names which cannot be used.
 * @var array
 */
protected static $reservedNames = array("members", "moderators", "administrators");


/**
 * An array of last action types => their callback functions.
 * @var array
 **/
protected static $lastActionTypes = array();


/**
 * Class constructor; sets up the base model functions to use the member table.
 *
 * @return void
 */
public function __construct()
{
	parent::__construct("member");
}


/**
 * Create a member.
 *
 * @param array $values An array of fields and their values to insert.
 * @return bool|int The new member ID, or false if there were errors.
 */
public function create(&$values)
{
	// Trim the username.
	$values["username"] = trim($values["username"]);

	// Validate the username, email, and password.
	$this->validate("username", $values["username"], array($this, "validateUsername"));
	$this->validate("email", $values["email"], array($this, "validateEmail"));
	$this->validate("password", $values["password"], array($this, "validatePassword"));

	// Hash the password and set the join time.
	$values["password"] = $this->hashPassword($values["password"]);
	$values["joinTime"] = time();

	// Set default preferences.
	if (empty($values["preferences"])) {
		$preferences = array("email.privateAdd", "email.post", "email.mention", "starOnReply", "starPrivate", "hideOnline");
		foreach ($preferences as $p) {
			$values["preferences"][$p] = C("esoTalk.preferences.".$p);
		}
	}
	$values["preferences"] = serialize($values["preferences"]);

	if ($this->errorCount()) return false;

	$memberId = parent::create($values);
	$values["memberId"] = $memberId;

	$this->trigger("createAfter", array($values));
	
	// Create "join" activity for this member.
	ET::activityModel()->create("join", $values);

	// Go through the list of channels and unsubscribe from any ones that have that attribute set.
	$channels = ET::channelModel()->getAll();
	$inserts = array();
	foreach ($channels as $channel) {
		if (!empty($channel["attributes"]["defaultUnsubscribed"]))
			$inserts[] = array($memberId, $channel["channelId"], 1);
	}
	if (count($inserts)) {
		ET::SQL()
			->insert("member_channel")
			->setMultiple(array("memberId", "channelId", "unsubscribed"), $inserts)
			->exec();
	}

	return $memberId;
}


/**
 * Update a member's details.
 *
 * @param array $values An array of fields to update and their values.
 * @param array $wheres An array of WHERE conditions.
 * @return bool|ETSQLResult
 */
public function update($values, $wheres = array())
{
	if (isset($values["username"])) {
		$values["username"] = trim($values["username"]);
		$this->validate("username", $values["username"], array($this, "validateUsername"));
	}

	if (isset($values["email"]))
		$this->validate("email", $values["email"], array($this, "validateEmail"));

	if (isset($values["password"])) {
		$this->validate("password", $values["password"], array($this, "validatePassword"));
		$values["password"] = $this->hashPassword($values["password"]);
	}

	// Serialize preferences.
	if (isset($values["preferences"])) $values["preferences"] = serialize($values["preferences"]);

	if ($this->errorCount()) return false;

	return parent::update($values, $wheres);
}


/**
 * Get standardized member data given an SQL query (which can specify WHERE conditions, for example.)
 *
 * @param ETSQLQuery $sql The SQL query to use as a basis.
 * @return array An array of members and their details.
 */
public function getWithSQL($sql)
{
	$sql->select("m.*")
		->select("GROUP_CONCAT(g.groupId) AS groups")
		->select("GROUP_CONCAT(g.name) AS groupNames")
		->select("BIT_OR(g.canSuspend) AS canSuspend")
		->from("member m")
		->from("member_group mg", "mg.memberId=m.memberId", "left")
		->from("group g", "g.groupId=mg.groupId", "left")
		->groupBy("m.memberId");

	if (ET::$session and ET::$session->user) {
		$sql->select("mm.*")
			->from("member_member mm", "mm.memberId2=m.memberId AND mm.memberId1=:userId", "left")
			->bind(":userId", ET::$session->userId);
	}

	$members = $sql->exec()->allRows();

	// Expand the member data.
	foreach ($members as &$member) $this->expand($member);

	return $members;
}


/**
 * Get member data for the specified post ID.
 *
 * @param int $memberId The ID of the member.
 * @return array An array of the member's details.
 */
public function getById($memberId)
{
	return $this->get(array("m.memberId" => $memberId))[0];
}


/**
 * Get member data for the specified member IDs, in the same order.
 *
 * @param array $ids The IDs of the members to fetch.
 * @return array An array of member details, ordered by the order of the IDs.
 */
public function getByIds($ids)
{
	$sql = ET::SQL()
		->where("m.memberId IN (:memberIds)")
		->orderBy("FIELD(m.memberId,:memberIdsOrder)")
		->bind(":memberIds", $ids, PDO::PARAM_INT)
		->bind(":memberIdsOrder", $ids, PDO::PARAM_INT);

	return $this->getWithSQL($sql);
}


/**
 * Expand raw member data into more readable values.
 *
 * @param array $member The member to expand data for.
 * @return void
 */
public function expand(&$member)
{
	// Make the groups into an array of groupId => names. (Possibly consider using ETGroupModel::getAll()
	// instead of featching the groupNames in getWithSQL()?)
	if (array_key_exists("groups", $member) and array_key_exists("groupNames", $member))
		$member["groups"] = array_combine(explode(",", $member["groups"]), explode(",", $member["groupNames"]));

	// Unserialize the member's preferences.
	if (isset($member["preferences"]))
		$member["preferences"] = unserialize($member["preferences"]);
}


/**
 * Generate a password hash using phpass.
 *
 * @param string $password The plain-text password.
 * @return string The hashed password.
 */
public function hashPassword($password)
{
	require_once PATH_LIBRARY."/vendor/phpass/PasswordHash.php";
	$hasher = new PasswordHash(8, FALSE);
	return $hasher->HashPassword($password);
}


/**
 * Check if a plain-text password matches an encrypted password.
 *
 * @param string $password The plain-text password to check.
 * @param string $hash The password hash to check against.
 * @return bool Whether or not the password is correct.
 */
public function checkPassword($password, $hash)
{
	require_once PATH_LIBRARY."/vendor/phpass/PasswordHash.php";
	$hasher = new PasswordHash(8, FALSE);
	return $hasher->CheckPassword($password, $hash);
}


/**
 * Validate a username.
 *
 * @param string $username The username to validate.
 * @param bool $checkForDuplicate Whether or not to check if a member with this username already exists.
 * @return null|string An error code, or null if there were no errors.
 */
public function validateUsername($username, $checkForDuplicate = true)
{
	// Make sure the name isn't a reserved word.
	if (in_array(strtolower($username), self::$reservedNames)) return "nameTaken";
	
	//fitting
	if(preg_match('/[<>\'\\"\s]/i', $username)) return "invalidUsername";

	// Make sure the username is not too small or large.
	$length = mb_strlen($username, "UTF-8");
	if ($length < 3 or $length > 20) return "invalidUsername";

	// Make sure there's no other member with the same username.
	if ($checkForDuplicate and ET::SQL()->select("1")->from("member")->where("username=:username")->bind(":username", $username)->exec()->numRows())
		return "nameTaken";
}


/**
 * Validate an email.
 *
 * @param string $email The email to validate.
 * @param bool $checkForDuplicate Whether or not to check if a member with this email already exists.
 * @return null|string An error code, or null if there were no errors.
 */
public function validateEmail($email, $checkForDuplicate = true)
{
	// Check it against a regular expression to make sure it's a valid email address.
	if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return "invalidEmail";

	// Make sure there's no other member with the same email.
	if ($checkForDuplicate and ET::SQL()->select("1")->from("member")->where("email=:email")->bind(":email", $email)->exec()->numRows())
		return "emailTaken";
}


/**
 * Validate a password.
 *
 * @param string $password The password to validate.
 * @return null|string An error code, or null if there were no errors.
 */
public function validatePassword($password)
{
	// Make sure the password isn't too short.
	if (strlen($password) < C("esoTalk.minPasswordLength")) return "passwordTooShort";
}


/**
 * Returns whether or not the current user can rename a member.
 *
 * @return bool
 */
public function canRename($member)
{
	// The user must be an administrator.
	return ET::$session->isAdmin();
}


/**
 * Returns whether or not the current user can delete a member.
 *
 * @return bool
 */
public function canDelete($member)
{
	return $this->canChangePermissions($member);
}


/**
 * Returns whether or not the current user can change a member's permissions.
 *
 * @return bool
 */
public function canChangePermissions($member)
{
	// The user must be an administrator, and the root admin's permissions can't be changed. A user also
	// cannot change their own permissions.
	return ET::$session->isAdmin() and $member["memberId"] != C("esoTalk.rootAdmin") and $member["memberId"] != ET::$session->userId;
}


/**
 * Returns whether or not the current user can suspend/unsuspend a member.
 *
 * @return bool
 */
public function canSuspend($member)
{
	// The user must be an administrator, or they must have the "canSuspend" permission and the member's
	// account be either "member" or "suspended". A user cannot suspend or unsuspend themselves, and the root
	// admin cannot be suspended.
	return
	(
		ET::$session->isAdmin()
		or (ET::$session->user["canSuspend"] and ($member["account"] == ACCOUNT_MEMBER or $member["account"] == ACCOUNT_SUSPENDED))
	)
	and $member["memberId"] != C("esoTalk.rootAdmin") and $member["memberId"] != ET::$session->userId;
}


/**
 * Set a member's account and groups.
 *
 * @param array $member The details of the member to set the account/groups for.
 * @param string $account The new account.
 * @param array $groups The new group IDs.
 * @return bool true on success, false on error.
 */
public function setGroups($member, $account, $groups = array())
{
	// Make sure the account is valid.
	if (!in_array($account, array(ACCOUNT_MEMBER, ACCOUNT_ADMINISTRATOR, ACCOUNT_SUSPENDED, ACCOUNT_PENDING)))
		$this->error("account", "invalidAccount");

	if ($this->errorCount()) return false;

	// Set the member's new account.
	$this->updateById($member["memberId"], array("account" => $account));

	// Delete all of the member's existing group associations.
	ET::SQL()
		->delete()
		->from("member_group")
		->where("memberId", $member["memberId"])
		->exec();

	// Insert new member-group associations.
	$inserts = array();
	foreach ($groups as $id) $inserts[] = array($member["memberId"], $id);
	if (count($inserts))
		ET::SQL()
			->insert("member_group")
			->setMultiple(array("memberId", "groupId"), $inserts)
			->exec();

	// Now we need to create a new activity item, and to do that we need the names of the member's groups.
	$groupData = ET::groupModel()->getAll();
	$groupNames = array();
	foreach ($groups as $id) $groupNames[$id] = $groupData[$id]["name"];

	ET::activityModel()->create("groupChange", $member, ET::$session->user, array("account" => $account, "groups" => $groupNames));

	return true;
}


/**
 * Set a member's preferences.
 *
 * @param array $member An array of the member's details.
 * @param array $preferences A key => value array of preferences to set.
 * @return array The member's new preferences array.
 */
public function setPreferences($member, $preferences)
{
	// Merge the member's old preferences with the new ones, giving preference to the new ones. Geddit?!
	$preferences = array_merge((array)$member["preferences"], $preferences);

	$this->updateById($member["memberId"], array(
		"preferences" => $preferences
	));

	return $preferences;
}


/**
 * Set a member's status entry for another member (their record in the member_member table.)
 *
 * @param int $memberId1 The ID of the primary member (usually the currently-logged-in user).
 * @param int $memberId2 The ID of the other member to set the status about.
 * @param array $data An array of key => value data to save to the database.
 * @return void
 */
public function setStatus($memberId1, $memberId2, $data)
{
	$keys = array(
		"memberId1" => $memberId1,
		"memberId2" => $memberId2
	);
	ET::SQL()->insert("member_member")->set($keys + $data)->setOnDuplicateKey($data)->exec();
}


/**
 * Delete a member with the specified ID, along with all of their associated records.
 *
 * @param int $memberId The ID of the member to delete.
 * @param bool $deletePosts Whether or not to mark the member's posts as deleted.
 * @return void
 */
public function deleteById($memberId, $deletePosts = false)
{
	// Delete the member's posts if necessary.
	if ($deletePosts) {
		ET::SQL()
			->update("post")
			->set("deleteMemberId", ET::$session->userId)
			->set("deleteTime", time())
			->where("memberId", $memberId)
			->exec();
	}

	// Delete member and other records associated with the member.
	ET::SQL()
		->delete()
		->from("member")
		->where("memberId", $memberId)
		->exec();

	ET::SQL()
		->delete()
		->from("member_channel")
		->where("memberId", $memberId)
		->exec();

	ET::SQL()
		->delete()
		->from("member_conversation")
		->where("id", $memberId)
		->where("type", "member")
		->exec();

	ET::SQL()
		->delete()
		->from("member_group")
		->where("memberId", $memberId)
		->exec();
}


/**
 * Update the user's last action.
 *
 * @todo Probably move the serialize part into update().
 * @param string $type The type of last action.
 * @param array $data An array of custom data that can be used by the last action type callback function.
 * @return bool|ETSQLResult
 */
public function updateLastAction($type, $data = array())
{
	if (!ET::$session->user) return false;

	$data["type"] = $type;
	ET::$session->updateUser("lastActionTime", time());
	ET::$session->updateUser("lastActionDetail", $data);

	return $this->updateById(ET::$session->userId, array(
		"lastActionTime" => time(),
		"lastActionDetail" => serialize($data)
	));
}


/**
 * Register a type of "last action".
 *
 * @param string $type The name of the last action type.
 * @param mixed The callback function that will be called to format the last action for display. The function
 * 		should return an array:
 * 			0 => the last action description (eg. Viewing [title])
 * 			1 => an optional associated URL (eg. a conversation URL)
 * @return void
 */
public static function addLastActionType($type, $callback)
{
	self::$lastActionTypes[$type] = $callback;
}


/**
 * Get formatted last action info for a member, given their lastActionTime and lastActionDetail fields.
 *
 * @todo Probably move the serialize part into expand().
 * @param int $time The member's lastActionTime field.
 * @param string $action The member's lastActionDetail field.
 */
public static function getLastActionInfo($time, $action)
{
	// If there is no action, or the time passed since the user was last seen is too great, then return no info.
	if (!$action or $time < time() - C("esoTalk.userOnlineExpire"))
		return false;

	$data = unserialize($action);
	if (!isset($data["type"])) return false;

	// If there's a callback for this last action type, return its output.
	if (isset(self::$lastActionTypes[$data["type"]]))
		return call_user_func(self::$lastActionTypes[$data["type"]], $data) + array(null, null);

	// Otherwise, return an empty array.
	else return array(null, null);
}


/**
 * Return a formatted last action array for the "viewingConversation" type.
 *
 * @param array $data An array of data associated with the last action.
 * @return array 0 => last action description, 1 => URL
 */
public static function lastActionViewingConversation($data)
{
	if (empty($data["conversationId"])) return array(sprintf(T("Viewing %s"), T("a private conversation")));
	return array(
		sprintf(T("Viewing: %s"), sanitizeHTML($data["title"])),
		URL(conversationURL($data["conversationId"], sanitizeHTML($data["title"])))
	);
}


/**
 * Return a formatted last action array for the "startingConversation" type.
 *
 * @param array $data An array of data associated with the last action.
 * @return array
 */
public static function lastActionStartingConversation($action)
{
	return array(T("Starting a conversation"));
}

}

// Add default last action types.
ETMemberModel::addLastActionType("viewingConversation", array("ETMemberModel", "lastActionViewingConversation"));
ETMemberModel::addLastActionType("startingConversation", array("ETMemberModel", "lastActionStartingConversation"));
