<?php namespace ProcessWire;

/**
 * ProcessWire Comments Fieldtype
 *
 * A field that stores user posted comments for a single Page. 
 *
 * For documentation about the fields used in this class, please see:  
 * /wire/core/Fieldtype.php
 * /wire/core/FieldtypeMulti.php
 * 
 * ProcessWire 3.x, Copyright 2022 by Ryan Cramer
 * https://processwire.com
 *
 */

$dirname = dirname(__FILE__); 

require_once($dirname . "/Comment.php");
require_once($dirname . "/CommentStars.php");
require_once($dirname . "/CommentArray.php"); 
require_once($dirname . "/CommentList.php"); 
require_once($dirname . "/CommentForm.php"); 
require_once($dirname . "/CommentField.php"); 

/**
 * ProcessWire Comments Fieldtype
 *
 * A field that stores user posted comments for a single Page. 
 * 
 * @method mixed updateComment(Page $page, $field, Comment $comment, array $properties) Update an existing comment
 * @method void commentDeleted(Page $page, Field $field, Comment $comment, $notes = '') #pw-hooker
 * @method void commentApproved(Page $page, Field $field, Comment $comment, $notes = '') #pw-hooker
 * @method void commentUnapproved(Page $page, Field $field, Comment $comment, $notes = '') #pw-hooker
 * @method void commentAddReady(Page $page, Field $field, Comment $comment) #pw-hooker
 * @method void commentAdded(Page $page, Field $field, Comment $comment) #pw-hooker
 * @method int sendNotifyEmails(Page $page, Field $field, Comment $comment, array $emailsData)
 * 
 *
 */
class FieldtypeComments extends FieldtypeMulti {

	/**
	 * Constant that designates comments are posted immediately with NO moderation
	 *
 	 */
	const moderateNone = 0;

	/**
	 * Constant that designates that ALL comments require moderation
	 *
	 */
	const moderateAll = 1; 

	/**
	 * Constant that designates that all comments require moderation, except those posted by users that have an approved comment
	 *
	 */
	const moderateNew = 2;

	/**
	 * Time period (in seconds) after which the same IP address may vote again on the same comment
	 *
	 */
	const votesMaxAge = 3600; 
	
	const useVotesNo = 0;
	const useVotesUp = 1; 
	const useVotesAll = 2;

	const useStarsNo = 0;
	const useStarsYes = 1;
	const useStarsRequired = 2;

	public static function getModuleInfo() {
		return array(
			'title' => __('Comments', __FILE__),
			'version' => 110,
			'summary' => __('Field that stores user posted comments for a single Page', __FILE__),
			'installs' => array('InputfieldCommentsAdmin'),
		);
	}
	
	public function wired() {
		if($this->wire()->config->ajax) {
			$this->addHookBefore('Page::render', $this, 'checkVoteAction');
		}
		parent::wired();
	}
	
	public function getBlankValue(Page $page, Field $field) {
		/** @var CommentArray $commentArray */
		$commentArray = $this->wire(new CommentArray()); 
		$commentArray->setPage($page); 
		$commentArray->setField($field); 
		$commentArray->setTrackChanges(true);
		return $commentArray; 
	}

	public function sanitizeValue(Page $page, Field $field, $value) {
		if($value instanceof CommentArray) return $value; 
		/** @var CommentArray $commentArray */
		$commentArray = $this->wire()->pages->get($field->name); 
		if(!$value) return $commentArray; 
		if($value instanceof Comment) return $commentArray->add($value); 
		if(!is_array($value)) $value = array($value);
		foreach($value as $comment) $commentArray->add($comment); 
		return $commentArray; 
	}

	public function getInputfield(Page $page, Field $field) {
		/** @var InputfieldCommentsAdmin $inputfield */
		$inputfield = $this->wire()->modules->get('InputfieldCommentsAdmin'); 
		if(!$inputfield) return null; 
		$inputfield->set('class', $this->className());
		return $inputfield; 
	}
	
	public function getFieldClass(array $a = array()) {
		return 'CommentField';
	}

	/**
	 * Update a query to match the text with a fulltext index
	 * 
	 * @param PageFinderDatabaseQuerySelect $query
	 * @param string $table
	 * @param string $subfield
	 * @param string $operator
	 * @param mixed $value
	 * @return DatabaseQuery|DatabaseQuerySelect
	 *
	 */
	public function getMatchQuery($query, $table, $subfield, $operator, $value) {
		if($subfield == 'text') $subfield = 'data';
		if(empty($subfield) || $subfield === 'data') { 
			/** @var DatabaseQuerySelectFulltext $ft */
			$ft = $this->wire(new DatabaseQuerySelectFulltext($query));
			$ft->match($table, $subfield, $operator, $value);
			return $query;
		} 
		return parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
	}


	/**
	 * Given a raw value (value as stored in DB), return the value as it would appear in a Page object
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|array $value
	 * @return CommentArray
	 *
	 */
	public function ___wakeupValue(Page $page, Field $field, $value) {

		if($value instanceof CommentArray) return $value; 
		$commentArray = $this->getBlankValue($page, $field); 
		if(empty($value)) return $commentArray; 
		
		$editable = $page->editable();
		if(!is_array($value)) $value = array($value); 
		
		foreach($value as $item) {
		
			if(!is_array($item)) continue; 
		
			// don't load non-approved comments if the user can't edit them
			if(!$editable && $item['status'] < Comment::statusApproved) continue; 
	
			$comment = $this->makeComment($page, $field, $item); 
			$commentArray->add($comment);
			$comment->setIsLoaded(true); 
		}
		
		if($field->get('sortNewest')) $commentArray->sort("-created"); 
		$commentArray->resetTrackChanges(true); 
		
		return $commentArray;  
	}

	/**
	 * Given an 'awake' value, as set by wakeupValue, convert the value back to a basic type for storage in DB. 
	 *              
	 * @param Page $page
	 * @param Field $field
	 * @param string|int|array|object|CommentArray $value
	 * @return array
	 *
	 */
	public function ___sleepValue(Page $page, Field $field, $value) {

		$sleepValue = array();
		if(!$value instanceof CommentArray) return $sleepValue; 
		
		$schemaVersion = $field->get('schemaVersion');
		$sanitizer = $this->wire()->sanitizer;
		$maxIdxLen = $this->wire()->database->getMaxIndexLength();

		foreach($value as $comment) {
			/** @var Comment $comment */

			if($comment->id) {
				$this->checkExistingComment($page, $field, $comment);
			} else {
				$this->checkNewComment($page, $field, $comment);
			}
				
			$a = array(
				'id' => $comment->id, 
				'status' => $comment->status, 
				'data' => $comment->text, 
				'cite' => $sanitizer->maxLength($comment->cite, 128, 128 * 3), 
				'email' => $sanitizer->maxLength($comment->email, 128, 128 * 3), 
				'created' => $comment->created, 
				'created_users_id' => $comment->created_users_id, 
				'ip' => $sanitizer->maxLength($comment->ip, 15), 
				'user_agent' => $sanitizer->maxLength($comment->user_agent, $maxIdxLen, $maxIdxLen * 3), 
			); 

			if($schemaVersion > 0) $a['website'] = $comment->website;
			if($schemaVersion > 1) {
				$a['parent_id'] = $comment->parent_id;
				$a['flags'] = (int) $comment->flags;
			}
			if($schemaVersion > 2) {
				$a['code'] = $comment->code; 
			}
			if($schemaVersion > 3) {
				$a['subcode'] = $comment->subcode;
			}
			if($schemaVersion > 4) {
				$a['upvotes'] = (int) $comment->upvotes;
				$a['downvotes'] = (int) $comment->downvotes;
			}
			if($schemaVersion > 5) {
				$a['stars'] = ($comment->stars >= 1 && $comment->stars <= 5 ? (int) $comment->stars : null);
			}
			if($schemaVersion > 7) {
				$a['meta'] = json_encode($comment->getMeta()); 
			}

			$sleepValue[] = $a;

		}
			
		return $sleepValue;
	}

	/**
	 * Review an existing comment for changes to the status
	 *
	 * If the status was changed, check if Akismet made an error and send it to them if they did
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 *
	 */
	protected function checkExistingComment(Page $page, Field $field, Comment $comment) {
		
		if($comment->quiet()) return;

		$submitSpam = false; 
		$submitHam = false; 

		if($comment->prevStatus === Comment::statusSpam && $comment->status >= Comment::statusApproved) {
			$submitHam = true; // identified a false positive
			$this->commentApproved($page, $field, $comment, 'Existing comment changed from spam to approved'); 

		} else if($comment->status === Comment::statusSpam && $comment->prevStatus >= Comment::statusApproved) {
			$submitSpam = true; // a missed spam
			$this->commentUnapproved($page, $field, $comment); 
			
		} else if($comment->prevStatus === Comment::statusPending && $comment->status >= Comment::statusApproved) {
			$this->commentApproved($page, $field, $comment, 'Existing comment changed from pending to approved'); 
			
		} else if($comment->status === Comment::statusPending && $comment->prevStatus >= Comment::statusApproved) {
			$this->commentUnapproved($page, $field, $comment); 
		}

		if($field->get('useAkismet') && $comment->ip && $comment->user_agent && ($submitHam || $submitSpam)) {
			/** @var CommentFilterAkismet $akismet */
			$akismet = $this->wire()->modules->get('CommentFilterAkismet'); 
			$akismet->setComment($comment); 
			if($submitHam) $akismet->submitHam();
				else if($submitSpam) $akismet->submitSpam();
		}
		
		$this->checkCommentCodes($comment);
	}

	/**
	 * Assign comment code and subcode as needed
	 * 
	 * @param Comment $comment
	 * 
	 */
	protected function checkCommentCodes(Comment $comment) {
		// assign code and subcode
		if(!$comment->code || !$comment->subcode) {
			$rand = new WireRandom();
			if(!strlen("$comment->code")) {
				// code: visible to admin only
				$code = $rand->alphanumeric(128);
				$comment->code = $code;
			}
			if(!strlen("$comment->subcode")) {
				// subcode: may be visible to commenter
				$subcode = $rand->alphanumeric(40);
				$comment->subcode = $subcode;
			}
		}
	}


	/**
	 * If comment is new, it sets the status based on whether it's spam, and notifies any people that need to be notified
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 *
	 */ 
	protected function checkNewComment(Page $page, Field $field, Comment $comment) {

		if($comment->id || $comment->quiet()) return;
		$comment->isNew = true;
		
		$this->checkCommentCodes($comment);
		
		if($field->get('useAkismet')) {
			/** @var CommentFilterAkismet $akismet */
			$akismet = $this->wire()->modules->get('CommentFilterAkismet'); 
			$akismet->setComment($comment); 
			$akismet->checkSpam(); // automatically sets status if spam
		} else {
			$comment->status = Comment::statusPending; 
		}

		if($comment->status != Comment::statusSpam) {
			if($field->get('moderate') == self::moderateNone) {
				$comment->status = Comment::statusApproved; 
				$comment->approvalNote = 'New comment approved / moderation is off';

			} else if($field->get('moderate') == self::moderateNew && $comment->email) {
				$database = $this->wire()->database;
				$table = $database->escapeTable($field->table); 
				$query = $database->prepare("SELECT count(*) FROM `$table`  WHERE status=:status AND email=:email"); 
				$query->bindValue(":status", Comment::statusApproved, \PDO::PARAM_INT);
				$query->bindValue(":email", $comment->email); 
				$query->execute();
				$numApproved = (int) $query->fetchColumn();
					
				if($numApproved > 0) {
					$cite = $this->wire()->sanitizer->text($comment->cite); 
					$comment->status = Comment::statusApproved;
					$comment->approvalNote = "New comment auto-approved because user '$cite' has other approved comments";
				}
			}
		}
	}

	/**
	 * Delete spam that is older than $field->deleteSpamDays
	 * 
	 * @param Field $field
	 *
	 */
	protected function commentMaintenance(Field $field) {
		
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);
		
		// delete old spam
		$expiredTime = time() - (86400 * $field->get('deleteSpamDays'));
		$query = $database->prepare("DELETE FROM `$table` WHERE status=:status AND created < :expiredTime");
		$query->bindValue(":status", Comment::statusSpam, \PDO::PARAM_INT);
		$query->bindValue(":expiredTime", $expiredTime); 
		$query->execute();
		
		// delete upvote/downvote IP address records
		$expiredTime = time() - self::votesMaxAge;
		$query = $database->prepare("DELETE FROM `{$table}_votes` WHERE created < :expiredTime");
		$query->bindValue(":expiredTime", $expiredTime);
		try {
			// we use a try/catch here in case the votes table doesn't yet exist
			$query->execute();
		} catch(\Exception $e) {
			$this->error($e->getMessage(), Notice::log); 
		}
	}

	/**
	 * Schema for the Comments Fieldtype
	 * 
	 * @param Field $field
	 * @return array
	 *
	 */
	public function getDatabaseSchema(Field $field) {
		
		$database = $this->wire()->database;

		$maxIndexLength = $database->getMaxIndexLength();
		$websiteSchema = "varchar($maxIndexLength) NOT NULL default ''"; 
		$parentSchema = "int unsigned NOT NULL default 0";
		$flagSchema = "int unsigned NOT NULL default 0";
		$codeSchema = "varchar(128) default NULL";
		$codeIndexSchema = "INDEX `code` (`code`)";
		$subcodeSchema = "varchar(40) default NULL";
		$subcodeIndexSchema = "INDEX `subcode` (`subcode`)";
		$upvoteSchema = "int unsigned NOT NULL default 0";
		$downvoteSchema = "int unsigned NOT NULL default 0";
		$starsSchema = "tinyint unsigned default NULL";
		$ipSchema = "varchar(45) NOT NULL default ''";
		$metaSchema = "text DEFAULT NULL";
		$table = $field->getTable();
		
		$schemaVersion = (int) $field->get('schemaVersion');
		$updateSchema = true;

		if(!$schemaVersion) {
			// add website field for PW 2.3+ 
			try { 
				if(!$database->columnExists($table, 'website')) {
					$database->query("ALTER TABLE `$table` ADD website $websiteSchema");
				}
				$schemaVersion = 1;
			} catch(\Exception $e) {
				$updateSchema = false;
			}
		}
		
		if($schemaVersion < 2 && $updateSchema) {
			// add parent_id and flags columns
			try { 
				if(!$database->columnExists($table, 'parent_id')) {
					$database->query("ALTER TABLE `$table` ADD parent_id $parentSchema");
				}
				if(!$database->columnExists($table, 'flags')) {
					$database->query("ALTER TABLE `$table` ADD flags $flagSchema");
				}
				$schemaVersion = 2;
			} catch(\Exception $e) {
				$updateSchema = false;
			}
		}
		
		if($schemaVersion < 3 && $updateSchema) {
			// add code column (admin code)
			try {
				if(!$database->columnExists($table, 'code')) {
					$database->query("ALTER TABLE `$table` ADD `code` $codeSchema");
					$database->query("ALTER TABLE `$table` ADD $codeIndexSchema");
				}
				$schemaVersion = 3;
			} catch(\Exception $e) {
				$updateSchema = false;
			}
		}
		
		if($schemaVersion < 4 && $updateSchema) {
			// add subcode column (subscriber code)
			try {
				if(!$database->columnExists($table, 'subcode')) {
					$database->query("ALTER TABLE `$table` ADD `subcode` $subcodeSchema");
					$database->query("ALTER TABLE `$table` ADD $subcodeIndexSchema");
				}
				$schemaVersion = 4;
			} catch(\Exception $e) {
				$updateSchema = false;
			}
		}
		
		if($schemaVersion < 5 && $updateSchema) {
			// add upvote/downvote columns 
			$parentSchema = parent::getDatabaseSchema($field);
			try {
				if(!$database->tableExists("{$table}_votes")) {
					$sql = "
						CREATE TABLE `{$table}_votes` (
							`comment_id` int unsigned NOT NULL,
							`vote` tinyint NOT NULL, 
							`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 
							`ip` $ipSchema, 
							`user_id` int unsigned NOT NULL default 0, 
							PRIMARY KEY (`comment_id`, `ip`, `vote`), 
							INDEX `created` (`created`)
						) " . $parentSchema['xtra']['append']; // engine and charset
					$database->exec($sql);
				}
				$createdVotesTable = true;	
			} catch(\Exception $e) {
				$createdVotesTable = $e->getCode() == '42S01'; // 42S01=table already exists (which we consider success too)
				if(!$createdVotesTable) {
					$this->error($e->getMessage(), Notice::log);
					$updateSchema = false;
				}
			}
			if($createdVotesTable) try {
				if(!$database->columnExists($table, 'upvotes')) {
					$database->query("ALTER TABLE `$table` ADD `upvotes` $upvoteSchema");
				}
				if(!$database->columnExists($table, 'downvotes')) {
					$database->query("ALTER TABLE `$table` ADD `downvotes` $downvoteSchema");
				}
				$schemaVersion = 5;
			} catch(\Exception $e) { 
				$this->error($e->getMessage(), Notice::log);
				$updateSchema = false;
			}
		}

		if($schemaVersion < 6 && $updateSchema) {
			try {
				if(!$database->columnExists($table, 'stars')) {
					$database->query("ALTER TABLE `$table` ADD `stars` $starsSchema");
				}
				$schemaVersion = 6;
			} catch(\Exception $e) {
				$updateSchema = false;
			}
 		}
 		
 		if($schemaVersion < 7 && $updateSchema) {
		    try {
			    $database->query("ALTER TABLE `$table` MODIFY `ip` $ipSchema");
			    if($database->tableExists("{$table}_votes")) {
				    $database->query("ALTER TABLE `{$table}_votes` MODIFY `ip` $ipSchema");
			    }
			    $schemaVersion = 7;
		    } catch(\Exception $e) {
		    	$this->error($e->getMessage(), Notice::debug);
		    	$updateSchema = false;
		    }
	    }
	    
	    if($schemaVersion < 8 && $updateSchema) {
		    try {
		    	if(!$database->columnExists($table, 'meta')) {
				    $database->query("ALTER TABLE `$table` ADD `meta` $metaSchema");
			    }
			    $schemaVersion = 8;
		    } catch(\Exception $e) {
			    $this->error($e->getMessage(), Notice::debug);
		    }
	    }

 		$_schemaVersion = (int) $field->get('schemaVersion');
		if($_schemaVersion < $schemaVersion) {
			$this->message("Updating schema version of '{$field->name}' from $_schemaVersion to $schemaVersion", Notice::log);
			$field->set('schemaVersion', $schemaVersion);
			$field->save();
		}

		$schema = parent::getDatabaseSchema($field); 

		$schema['id'] = "int unsigned NOT NULL auto_increment";
		$schema['status'] = "tinyint(3) NOT NULL default '0'";
		$schema['cite'] = "varchar(128) NOT NULL default ''"; 
		$schema['email'] = "varchar(128) NOT NULL default ''";
		$schema['data'] = "text NOT NULL"; 
		$schema['sort'] = "int unsigned NOT NULL";
		$schema['created'] = "int unsigned NOT NULL"; 	
		$schema['created_users_id'] = "int unsigned NOT NULL";
		$schema['ip'] = $ipSchema;
		$schema['user_agent'] = "varchar($maxIndexLength) NOT NULL default ''";

		$schemaVersion = $field->get('schemaVersion');
		if($schemaVersion > 0) $schema['website'] = $websiteSchema;
		if($schemaVersion > 1) {
			$schema['parent_id'] = $parentSchema;
			$schema['flags'] = $flagSchema;
		}
		if($schemaVersion > 2) {
			$schema['code'] = $codeSchema;
			$schema['keys']['code'] = $codeIndexSchema;
		}
		if($schemaVersion > 3) {
			$schema['subcode'] = $subcodeSchema;
			$schema['keys']['subcode'] = $subcodeIndexSchema;
		}
		if($schemaVersion > 4) {
			$schema['upvotes'] = $upvoteSchema;
			$schema['downvotes'] = $downvoteSchema;
		}
		if($schemaVersion > 5) {
			$schema['stars'] = $starsSchema;
		}
		if($schemaVersion > 7) {
			$schema['meta'] = $metaSchema;
		}

		$schema['keys']['primary'] = "PRIMARY KEY (`id`)"; 
		$schema['keys']['pages_id_sort'] = "KEY `pages_id_sort` (`pages_id`, `sort`)";
		$schema['keys']['status'] = "KEY `status` (`status`, `email`)";
		$schema['keys']['pages_id'] = "KEY `pages_id` (`pages_id`,`status`,`created`)"; 
		$schema['keys']['created'] = "KEY `created` (`created`, `status`)";
		$schema['keys']['data'] = "FULLTEXT KEY `data` (`data`)";
		$schema['xtra']['all'] = false;

		return $schema;
	}

	/**
	 * Per the Fieldtype interface, Save the given Field from the given Page to the database
	 *
	 * @param Page $page
	 * @param Field $field
	 * @return bool
	 *
	 */
	public function ___savePageField(Page $page, Field $field) {

		if(!$page->id || !$field->id) return false;

		/** @var CommentArray $allItems */
		$allItems = $page->get($field->name);
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);

		if(!$allItems) return false;
		if(!$allItems->isChanged() && !$page->isChanged($field->name)) return true; 

		/** @var CommentArray $itemsRemoved */
		$itemsRemoved = $allItems->getItemsRemoved();
		if(count($itemsRemoved)) {
			foreach($itemsRemoved as $item) {
				/** @var Comment $item */
				if(!$item->id) continue; 
				$this->deleteComment($page, $field, $item, 'deleted from savePageField()'); 
			}
		}

		$maxSort = 0; 
		$items = $allItems->makeNew();
		foreach($allItems as $item) {
			/** @var Comment $item */
			if($item->isChanged() || !$item->id) $items->add($item); 
			if($item->sort > $maxSort) $maxSort = $item->sort; 
		}

		if(!count($items)) return true; 

		$values = $this->sleepValue($page, $field, $items); 
		$value = reset($values); 
		$keys = is_array($value) ? array_keys($value) : array('data'); 
		
		// cycle through the values, executing an update query for each
		foreach($values as $value) {
			
			$binds = array();
			$sql = $value['id'] ? "UPDATE " : "INSERT INTO ";
			//$sql .= "`{$table}` SET pages_id=" . ((int) $page->id) . ", ";
			$sql .= "`{$table}` SET pages_id=:pages_id, "; 
			$binds['pages_id'] = (int) $page->id; 

			// if the value is not an associative array, then force it to be one
			if(!is_array($value)) $value = array('data' => $value); 

			// cycle through the keys, which represent DB fields (i.e. data, description, etc.) and generate the update query
			foreach($keys as $key) {
				if($key == 'id') continue; 
				if($key == 'sort' && !$value['id']) continue; 
				$v = $value[$key];
				$col = $database->escapeCol($key); 
				if(is_null($v) && ($key == 'code' || $key == 'subcode')) {
					// currently 'code' and 'subcode' are the only column that allows null
					$sql .= "$col=NULL, ";
				} else {
					$sql .= "$col=:$col, ";
					$binds[$col] = $v; 
				}
			}

			if($value['id']) {
				$sql = rtrim($sql, ', ') . " WHERE id=:id"; // . (int) $value['id'];
				$binds['id'] = (int) $value['id'];
			} else {
				$sql .= "sort=:sort"; 
				$binds['sort'] = ++$maxSort; 
			}
			try {
				$query = $database->prepare($sql);
				foreach($binds as $k => $v) {
					$query->bindValue(":$k", $v);
				}
				$result = $query->execute();
				if($result && !$value['id']) {
					// populate newly added comment ID to Comment object
					$value['id'] = $database->lastInsertId();
					foreach($allItems as $item) {
						/** @var Comment $item */
						if(!$item->id && $item->code === $value['code']) {
							$item->id = $value['id'];
							$this->commentAdded($page, $field, $item); 
						}
					}
				}
				$error = '';
			} catch(\Exception $e) {
				$result = false; 
				$error = $e->getMessage();
			}
			if(!$result) {
				$this->error("Error saving comment id $value[id]: $error", Notice::log);
			}
		}	
		
		return true; 
	}

	/**
	 * Configuration that appears with each Comments fieldtype
	 * 
	 * @param Field $field
	 * @return InputfieldWrapper
	 * @todo move to separate config file
	 *
	 */
	public function ___getConfigInputfields(Field $field) {
		
		$modules = $this->wire()->modules;

		$inputfields = parent::___getConfigInputfields($field);
		$disabledLabel = $this->_('Disabled');

		/** @var InputfieldFieldset $fieldset */
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->label = $this->_('Behavior');
		$fieldset->icon = 'comment-o';
		$inputfields->add($fieldset); 

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios'); 
		$name = 'moderate';
		$f->attr('name', $name); 
		$f->addOption(self::moderateNone, $this->_('None - Comments posted immediately')); 
		$f->addOption(self::moderateAll, $this->_('All - All comments must be approved by user with page edit access')); 
		$f->addOption(self::moderateNew, $this->_('Only New - Only comments from users without prior approved comments require approval'));
		$f->attr('value', (int) $field->$name); 
		$f->label = $this->_('Comment moderation');
		$f->description = $this->_('This determines when a newly posted comment will appear on your site.'); 
		$fieldset->append($f);
	
		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$name = 'redirectAfterPost';
		$f->attr('name', $name);
		$f->attr('value', 1);
		$f->attr('checked', $field->$name ? 'checked' : '');
		$f->label = $this->_('Redirect after comment post?');
		$f->description = $this->_('When checked, ProcessWire will issue a redirect after the comment is posted in order to prevent double submissions. Recommended.');
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$name = 'quietSave';
		$f->attr('name', $name);
		$f->attr('value', 1);
		$f->attr('checked', $field->$name ? 'checked' : '');
		$f->label = $this->_('Quiet save?');
		$f->columnWidth = 50;
		$f->description = $this->_('When checked, the page modification time and user will not be updated when a comment is added.');
		$fieldset->append($f);

		/*
		$name = 'notificationType';
		$f = $this->wire('modules')->get('InputfieldRadios');
		$f->attr('name', $name);
		$f->label = $this->_('Notification Type');
		$f->addOption(self::notificationNone, $this->_('Do not send notifications'));
		$f->addOption(self::notificationEmail, $this->_('Send notifications to specific email address'));
		$f->addOption(self::notificationCreated, $this->_('Send notifications to user that created the page'));
		$f->addOption(self::notificationUser, $this->_('Send notifications to specific user')); 
		$f->attr('value', $field->$name);
		$inputfields->append($f);
		*/
		
		// ----------------------------
	
		/** @var InputfieldFieldset $fieldset */	
		$fieldset = $modules->get('InputfieldFieldset'); 
		$fieldset->label = $this->_('Notifications'); 
		$fieldset->icon = 'bell-o';
		$inputfields->add($fieldset); 

		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText');
		$name = 'notificationEmail';
		$f->attr('name', $name); 
		$f->attr('value', $field->$name); 
		$f->label = $this->_('Admin notification email');
		$f->description = $this->_('E-mail address to be notified when a new comment is posted. Separate multiple email addresses with commas or spaces.') . ' '; 
		$f->description .= $this->_('Users receiving this email will have the ability to approve or deny posts directly from links in the email.');
		$f->notes = 
			$this->_('In addition to (or instead of) email addresses, you may also use one or more of the following:') . "\n" .
			$this->_('1. Enter **user:karen** to email a specific user, replacing "karen" with the name of the actual user.') . "\n" . 
			$this->_('2. Enter **field:email** to pull the email from a field on the page, replacing "email" with name of field containing email address.') . "\n" . 
			$this->_('3. Enter **123:email** to pull the email from an given page ID and field name, replacing "123" with the page ID and "email" with name of field containing email address.') . "\n" . 
			$this->_('4. Enter **/path/to/page:email** to pull the email from an given page path and field name, replacing "/path/to/page" with the page path and "email" with name of field containing email address.');
		$fieldset->append($f);
	
		/** @var InputfieldEmail $f */
		$f = $modules->get('InputfieldEmail');
		$name = 'fromEmail';
		$f->attr('name', $name);
		$f->attr('value', $field->$name);
		$f->label = $this->_('Notifications from email');
		$f->description = $this->_('Optional e-mail address that notifications will appear from. Leave blank to use the default server email.');
		$f->columnWidth = 50; 
		$fieldset->append($f);
	
		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$name = 'notifySpam';
		$f->attr('name', $name);
		$f->attr('value', 1);
		if($field->$name) $f->attr('checked', 'checked');
		$f->label = $this->_('Send e-mail notification on spam?');
		$f->description = $this->_('When checked, ProcessWire will still send you an e-mail notification even if the message is identified as spam.');
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$name = 'useNotify';
		$f->attr('name', $name);
		$f->label = $this->_('Allow commenter e-mail notifications?');
		$f->description = $this->_('This option enables anyone that posts a comment to receive email notifications of new comments.');
		$f->addOption(0, $disabledLabel);
		$f->addOption(Comment::flagNotifyReply, $this->_('Users can receive email notifications of replies to their comment only'));
		$f->addOption(Comment::flagNotifyAll, $this->_('Users can receive email notifications for all new comments on the page'));
		$f->attr('value', (int) $field->get('useNotify'));
		$f->columnWidth = 50;
		$fieldset->append($f);
	
		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$name = 'useNotifyText';
		$f->attr('name', $name);
		$f->attr('value', 1);
		if($field->$name) $f->attr('checked', 'checked');
		$f->label = $this->_('Allow comment text in notifications?');
		$f->description = $this->_('When checked, the entire comment text will also appear in the notification emails, rather than just a link to it.');
		$f->columnWidth = 50;
		$f->showIf = 'useNotify>0';
		$fieldset->append($f);

		// ---------------------------------------
	
		/** @var InputfieldFieldset $fieldset */	
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->label = $this->_('Spam'); 
		$fieldset->icon = 'fire-extinguisher';
		$inputfields->add($fieldset);
	
		/** @var InputfieldCheckbox $f */
		$f = $modules->get('InputfieldCheckbox');
		$name = 'useAkismet';
		$f->attr('name', $name);
		$f->attr('value', 1);
		$f->attr('checked', $field->$name ? 'checked' : '');
		$f->label = $this->_('Use Akismet Spam Filter Service?');
		$f->description = $this->_('This service will automatically identify most spam. Before using it, please ensure that you have entered an Akismet API key under Modules > Comment Filter: Akismet.');
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger');
		$name = 'deleteSpamDays';
		$f->attr('name', $name);
		$value = $field->$name;
		if(is_null($value)) $value = 3; // default
		$f->attr('value', $value);
		$f->label = $this->_('Number of days after which to delete spam');
		$f->description = $this->_('After the number of days indicated, spam will be automatically deleted.');
		$f->columnWidth = 50; 
		$fieldset->append($f);
		
		// ---------------------------------------

		/** @var InputfieldFieldset $fieldset */	
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->label = $this->_('Output');
		$fieldset->icon = 'comments-o';
		$inputfields->add($fieldset);
	
		/** @var InputfieldInteger $f */
		$f = $modules->get('InputfieldInteger');
		$name = 'depth';
		$f->attr('name', $name);
		$f->attr('value', (int) $field->$name);
		$f->label = $this->_('Reply depth');
		$f->description = $this->_('Specify 0 for traditional flat chronological comments. For threaded comments (replies appear with comment being replied to) specify the maximum depth allowed for replies (0 to 4 recommended).');
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldCheckbox $f */	
		$f = $modules->get('InputfieldCheckbox');
		$name = 'sortNewest'; 
		$f->attr('name', $name); 
		$f->attr('value', 1); 
		$f->attr('checked', $field->$name ? 'checked' : ''); 
		$f->label = $this->_('Sort newest to oldest?');
		$f->description = $this->_('By default, comments will sort chronologically (oldest to newest). To reverse that behavior check this box.');
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldCheckbox $f */	
		$f = $modules->get('InputfieldCheckbox');
		$name = 'useWebsite'; 
		$f->attr('name', $name); 
		$f->attr('value', 1); 
		$f->attr('checked', $field->$name ? 'checked' : ''); 
		$f->label = $this->_('Use website field in comment form?');
		$f->description = $this->_('When checked, the comment submission form will also include a website field.');
		$f->columnWidth = 50;
		$fieldset->append($f);
	
		/** @var InputfieldText $f */
		$f = $modules->get('InputfieldText');
		$name = 'dateFormat';
		$f->attr('name', $name);
		$f->attr('value', $field->get('dateFormat') ? $field->get('dateFormat') : 'relative');
		$f->label = $this->_('Date/time format (for comment list)');
		$f->description =
			$this->_('Enter the date/time format you want the default comment list output to use.') . ' ' . 
			sprintf(
				$this->_('May be a PHP [date](%s) or [strftime](%s) format.'),
				'https://php.net/manual/en/function.date.php',
				'https://php.net/manual/en/function.strftime.php'
			) . ' ' . 
			$this->_('May also be "relative" for relative date format.');
		$f->columnWidth = 50; 
		$fieldset->append($f);
	
		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$name = 'useVotes';
		$f->attr('name', $name);
		$f->label = $this->_('Allow comment voting?');
		$f->description = $this->_('Comment voting enables visitors to upvote and/or downvote comments. Vote counts are displayed alongside each comment. Only one upvote and/or downvote is allowed per comment, per IP address, per hour.'); 
		$f->addOption(0, $this->_('Voting off')); 
		$f->addOption(1, $this->_('Allow upvoting'));
		$f->addOption(2, $this->_('Allow upvoting and downvoting'));
		$f->attr('value', (int) $field->$name);
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$name = 'useStars';
		$f->attr('name', $name);
		$f->label = $this->_('Use stars rating?');
		$f->description = $this->_('Star ratings enable the commenter to rate the subject they are commenting on, using a scale of 1 to 5 stars.');
		$f->notes = $this->_('To change default star used for output (HTML is okay too):') . "\nCommentStars::setDefault('star', '★');";
		$f->addOption(0, $this->_x('Disabled', 'star-rating'));
		$f->addOption(1, $this->_('Yes (star rating optional)'));
		$f->addOption(2, $this->_('Yes (star rating required)'));
		$f->attr('value', (int) $field->$name);
		$f->columnWidth = 50;
		$fieldset->append($f);

		/** @var InputfieldRadios $f */
		$f = $modules->get('InputfieldRadios');
		$name = 'useGravatar'; 
		$f->attr('name', $name); 
		$f->addOption('', $disabledLabel); 
		$f->addOption('g', $this->_('G: Suitable for display on all websites with any audience type.'));
		$f->addOption('pg', $this->_('PG: May contain rude gestures, provocatively dressed individuals, the lesser swear words, or mild violence.'));
		$f->addOption('r', $this->_('R: May contain such things as harsh profanity, intense violence, nudity, or hard drug use.'));
		$f->addOption('x', $this->_('X: May contain hardcore sexual imagery or extremely disturbing violence.'));
		$f->attr('value', $field->get('useGravatar')); 
		$f->label = $this->_('Use Gravatar?');
		$f->description = $this->_('This service provides an avatar image with each comment (unique to the email address). To enable, select the maximum gravatar rating. These are the same as movie ratings, where G is the most family friendly and X is not.');
		$f->notes = $this->_('Rating descriptions provided by [Gravatar](https://en.gravatar.com/site/implement/images/).');
		$fieldset->append($f); 

		// @todo 
		/*
		$textformatters = $this->wire('modules')->find("className^=Textformatter");
		if(count($textformatters)) {
			$f = $this->modules->get('InputfieldAsmSelect');
			$f->attr('name', 'textformatters');
			$f->label = $this->_('Text Formatters');
			$f->description = $this->_('Optionally select one or more text formatters to be applied to the comment text, in the selected order. If you do not select any then the text will be entity encoded, have newlines converted to <br> tags, and be output in a <p> tag.');
			$f->notes = $this->_('Warning: only select text formatters that are known to be safe with anonymous user input, like Entity Encoder (core) or Textile Restricted (3rd party). If you are not sure, then do not select anything here, as making the wrong choice can be a security problem.'); 
			foreach($textformatters as $textformatter) {
				$info = $this->wire('modules')->getModuleInfo($textformatter); 
				$f->addOption($textformatter->className(), "$info[title]");
			}
			$f->attr('value', is_array($field->textformatters) ? $field->textformatters : array());
			$inputfields->append($f);
		}
		*/
		
		// -----------------------------
	
		/** @var InputfieldFieldset $fieldset */	
		$fieldset = $modules->get('InputfieldFieldset');
		$fieldset->label = $this->_('Implementation'); 
		$fieldset->icon = 'file-code-o';
		$fieldset->description = $this->_('This section is here to help you get started with outputting comments on the front-end of your site. Everything here is optional.'); 
		$fieldset->notes = $this->_('If using a cache for output, configure it to bypass the cache when the GET variable "comment_success" is present.'); 
		$inputfields->add($fieldset); 
	
		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup'); 
		$f->label = $this->_('PHP code to output comments'); 
		$f->value = 
			"<p>Please copy and paste the following into your template file(s) where you would like the comments to be output:</p>" .
			"<pre style='border-left: 4px solid #ccc; padding-left: 1em;'>&lt;?php echo \$page-&gt;{$field->name}-&gt;renderAll(); ?&gt;</pre>" . 
			"<p>For more options please see the <a href='https://processwire.com/api/fieldtypes/comments/' target='_blank'>comments documentation page</a>.</p>";
		$fieldset->add($f);

		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup'); 
		$f->label = $this->_('CSS for front-end comments output'); 
		$ftUrl = $this->wire('config')->urls('FieldtypeComments');
		$f->value = 
			"<p>Please copy and paste the following into the document <code>&lt;head&gt;</code> of your site:</p>" .
			"<pre style='border-left: 4px solid #ccc; padding-left: 1em;'>&lt;link rel='stylesheet' type='text/css' href='&lt;?=\$config-&gt;urls-&gt;FieldtypeComments?&gt;comments.css' /&gt;</pre>" . 
			"<p>Or if you prefer, copy the <a target='_blank' href='{$ftUrl}comments.css'>comments.css</a> file to your own location, " . 
			"modify it as desired, and link to it in your document head as you do with your other css files.</p>";
		$fieldset->add($f);

		/** @var InputfieldMarkup $f */
		$f = $modules->get('InputfieldMarkup');
		$f->label = $this->_('JS for front-end comments output');
		$f->value =
			"<p>If you are using threaded comments (i.e. reply depth > 0), please also copy and paste the following into the document <code>&lt;head&gt;</code> " .
			"or before the closing <code>&lt;/body&gt;</code> tag. In either case, jQuery is required to have been loaded first.</p>" .
			"<pre style='border-left: 4px solid #ccc; padding-left: 1em;'>&lt;script type='text/javascript' src='&lt;?=\$config-&gt;urls-&gt;FieldtypeComments?&gt;comments.min.js'&gt;&lt;/script&gt;</pre>" .
			"<p>Like with the comments.css file, feel free to copy and link to the <a target='_blank' href='{$ftUrl}comments.js'>comments.js</a> file from your own " .
			"location if you prefer it.</p>";
		$fieldset->add($f); 
	
		/** @var InputfieldHidden $f */
		$f = $modules->get('InputfieldHidden');
		$name = 'schemaVersion'; 
		$f->attr('name', $name);
		$value = (int) $field->$name; 
		$f->attr('value', $value);
		$f->label = 'Schema Version';
		$inputfields->append($f);

		return $inputfields;
	}

	/**
	 * For FieldtypeMulti interface, return NULL to indicate that the field is not auto-joinable
	 * 
	 * @param Field $field
	 * @param DatabaseQuerySelect $query
	 * @return null
	 *
	 */
	public function getLoadQueryAutojoin(Field $field, DatabaseQuerySelect $query) {
		return null; // make this field not auto-joinable
	}

	/**
	 * Find comments, static version for backwards compatibility
	 *
	 * @param Field|string Field object or name of field
	 * @param string $selectorString Selector string with query
	 * @return CommentArray
	 * @throws WireException
	 * @deprecated Use $field->type->find($selectorString) instead.
	 *
	 */
	static public function findComments($field, $selectorString) {
		if(is_string($field)) $field = wire()->fields->get($field);
		if(!$field instanceof Field) throw new WireException('Arg 1 to findComments() must be a field');
		$fieldtype = $field->type; /** @var FieldtypeComments $fieldtype */
		return $fieldtype->find($field, $selectorString);
	}

	/**
	 * Given a field and a selector, find all comments matching the selector 
	 *
	 * - If you don’t specify a “limit=n”, it will default to a limit of 10.
	 * - If you don’t specify a sort, it will default to “sort=-created”.
	 * - Arguments may be optionally reversed (for backwards compatibility).
	 * 
	 * Unlike $pages->find(), pagination of comments is not automatically tied to 
	 * $input->pageNum(). As a result, if you paginate, you should specify both 
	 * “start=n” and “limit=n” in your selector:
	 * 
	 * ~~~~~~
	 * $limit = 20;
	 * $start = ($input->pageNum() - 1) * $limit; 
	 * $comments = $field->type->find("text%=something, start=$start, limit=$limit"); 
	 * ~~~~~~
	 * 
	 * Please also note that this function returns comments without taking anything 
	 * about the page they live on into account. As a result, it can return comments 
	 * from unpublished or non-viewable pages. If you need this, check the returned 
	 * results:
	 * 
	 * ~~~~
	 * $comments = $field->type->find('text%=something'); 
	 * foreach($comments as $comment) {
	 *   $p = $comment->getPage();
	 *   if(!$p->viewable()) $comments->remove($comment); 
	 * }
	 * ~~~~
	 *
	 * @param string|null $selectorString Selector string with query
	 * @param CommentField|string Optional Field object. If omitted, then it will be determined automatically. 
	 * @param array $options
	 *  - `count` (bool): Return a total count rather than a CommentArray? (default=false)
	 * @return CommentArray|int
	 * @throws WireException
	 * 
	 * @todo add an 'include=' property for matching based on page status
	 * @todo add support for page.native properties
	 *
	 */
	public function find($selectorString, $field = null, array $options = array()) {
		$defaults = array(
			'count' => false, 
		);
		$options = array_merge($defaults, $options);
		if($field !== null) {
			if($selectorString instanceof Field || (is_string($field) && Selectors::stringHasSelector($field))) {
				// arguments are reversed
				list($selectorString, $field) = array($field, $selectorString);
			}
			if(is_string($field)) $field = $this->wire()->fields->get($field);
		}
		if(!$field instanceof Field) $field = $this->getLastAccessField();
		if(!$field instanceof Field) $field = $this->getCommentsFields(true);
		if(!$field instanceof Field) throw new WireException("Unable to determine comments field"); 
		
		if($selectorString === null) $selectorString = '';
		
		$intColumns = array(
			'id', 
			'status', 
			'flags',
			'created', 
			'pages_id', 
			'parent_id', 
			'created_users_id', 
			'upvotes', 
			'downvotes', 
			'stars'
		);
			
		$sortColumns = array(
			'sort', 
			'status', 
			'id', 
			'pages_id', 
			'created_users_id', 
			'created', 
			'upvotes', 
			'downvotes', 
			'stars'
		);

		$limit = 10;
		$start = 0;
		$sorts = array();
		$database = $this->wire()->database;
		$fields = $this->wire()->fields;
		$pages = $this->wire()->pages;

		$selectQuery = new DatabaseQuerySelect();
		$countQuery = new DatabaseQuerySelect();
		
		$this->wire($selectQuery);
		$this->wire($countQuery);
		
		$table = $database->escapeTable($field->getTable());
		
		$selectQuery->select("$table.*")->from($table)->where("$table.id>0");
		$countQuery->select('COUNT(*)')->from($table)->where("$table.id>0");
	
		/** @var Selectors $selectors */
		$selectors = $selectorString instanceof Selectors ? $selectorString : $this->wire(new Selectors($selectorString));
		$wheres = array();
		$joins = array();
		$leftJoins = array();
		$binds = array();
		$status = null;
		$cnt = 0;

		foreach($selectors as $selector) {

			$cnt++;
			$f = $selector->field;
			if(is_array($selector->field)) throw new WireException("OR not supported for field in: $selector"); 
			$f = $database->escapeCol($f);
			$operator = $selector->operator;
			$value = $selector->value;

			if(is_array($f)) $f = reset($f);
			if(is_array($value)) {
				$values = $value;
				$value = reset($value);
			} else {
				$values = array($value);
			}

			if($f === 'status') {
				if(!ctype_digit($value)) {
					if($value === 'approved') $value = Comment::statusApproved;
						else if($value === 'featured') $value = Comment::statusFeatured;
						else if($value === 'pending') $value = Comment::statusPending;
						else if($value === 'spam') $value = Comment::statusSpam;
				}
				$status = (int) $value;
			}
			
			if($f == 'page') $f = 'pages_id';
			if($f == 'user') $f = 'created_users_id';
			if(strpos($f, 'page.') === 0) $f = 'page_' . substr($f, 6); 

			if($f === 'stars' && !$value && ($operator === '=' || $operator === '!=')) {
				if($operator === '=') {
					$wheres[] = "($table.$f=0 OR $table.$f IS NULL)";
				} else { // if($operator === '!=') {
					$wheres[] = "($table.$f!=0 OR $table.$f IS NOT NULL)";
				}
				
			} else if($f === 'flags') {
				if(!is_array($value)) $value = array($value);
				$whereFlags = array();
				if(!in_array($operator, array('=', '!=', '&'))) {
					throw new WireException("Operator $operator not supported for comment flags");
				}
				foreach($value as $flag) {
					$intFlag = (int) $flag;
					if($operator === '!=') {
						$whereFlags[] = "(NOT $table.flags & $intFlag)";
					} else {
						$whereFlags[] = "($table.flags & $intFlag)";
					}
				}
				$andOr = $operator === '!=' ? ' AND ' : ' OR ';
				$wheres[] = '(' . implode($andOr, $whereFlags) . ')';

			} else if(in_array($f, $intColumns)) {
				if(!$database->isOperator($operator)) $operator = '=';
				if(count($values) > 1 && ($operator == '=' || $operator == '!=')) {
					$intValues = array();
					foreach($values as $v) {
						if(is_object($v)) $v = (string) $v;
						$intValues[] = (int) $v;
					}
					$wheres[] = "$table.$f " . ($operator == '=' ? 'IN(' : 'NOT IN(') . implode(',', $intValues) . ')';
				} else {
					$wheres[] = "$table.$f$operator" . ((int) $value);
				}

			} else if($f == 'start') {
				$start = (int) $value;

			} else if($f == 'limit') {
				$limit = (int) $value;

			} else if($f == 'sort') {
				$desc = substr($value, 0, 1) == '-';
				$value = trim($value, '-');
				if(in_array($value, $sortColumns)) {
					$sort = $database->escapeCol($value);
					$sorts[] = "$table.$sort" . ($desc ? ' DESC' : ' ASC');
				}

			} else if($f == 'cite' || $f == 'email' || $f == 'ip') {
				if(!$database->isOperator($operator)) $operator = '=';
				if(count($values) > 1) {
					$ors = array();
					foreach($values as $v) {
						$ors[] = "$table.$f$operator:cnt$cnt";
						$binds["cnt$cnt"] = $v;
						$cnt++;
					}
					$wheres[] = '(' . implode(' OR ', $ors) . ')';
				} else {
					$wheres[] = "$table.$f$operator:cnt$cnt";
					$binds["cnt$cnt"] = $value;
				}
				
			} else if($f == 'text' || $f == 'data') {
				$f = 'data';
				foreach(array($selectQuery, $countQuery) as $q) {
					$fulltext = new DatabaseQuerySelectFulltext($q);
					$this->wire($fulltext);
					$fulltext->match($table, $f, $operator, $value);
				}
			} else if(strpos($f, 'page_') === 0) {
				list($f, $fieldName) = explode('_', $f, 2); 
				if($f) {} // ignore
				if(strpos($fieldName, '.')) {
					list($fieldName, $colName) = explode('.', $fieldName); 
					$colName = $database->escapeCol($colName);
				} else {
					$colName = 'data';
				}
				
				if($fieldName === 'parent_id' || $fieldName === 'templates_id') {
					$ids = array();
					foreach($values as $id) $ids[] = (int) $id;
					$ids = implode(',', $ids);
					$joinTable = "pages_$fieldName";
					$joins[] = "pages AS $joinTable ON $joinTable.$fieldName IN($ids) AND $table.pages_id=$joinTable.id";
					
				} else {
					/** @var Field $f */
					$f = $fields->get($fieldName);
					if(!$f) continue;
					$fieldTable = $f->getTable();
					if(!$database->isOperator($operator)) $operator = '=';
					if(count($values) > 1) {
						$ors = array();
						foreach($values as $v) {
							$ft = $fieldTable . $cnt;
							$leftJoins[] = "$fieldTable AS $ft ON $ft.pages_id=$table.pages_id AND $ft.$colName$operator:cnt$cnt";
							$binds["cnt$cnt"] = $v;
							$ors[] = "$ft.$colName IS NOT NULL";
							$cnt++;
						}
						$wheres[] = '(' . implode(' OR ', $ors) . ')';
					} else {
						$ft = $fieldTable . $cnt;
						$joins[] = "$fieldTable AS $ft ON $ft.pages_id=$table.pages_id AND $ft.$colName$operator:cnt$cnt";
						$binds["cnt$cnt"] = $value;
					}
				}
			}
		}
	
		// if no status was specified and we’re on the front-end, match only approved comments
		if($status === null && $this->wire()->page->template->name != 'admin') {
			$wheres[] = "$table.status>=" . Comment::statusApproved;
		}
		
		foreach($wheres as $where) {
			$selectQuery->where($where);
			$countQuery->where($where);
		}
		foreach($joins as $join) {
			$selectQuery->join($join);
			$countQuery->join($join);
		}
		foreach($leftJoins as $leftJoin) {
			$selectQuery->leftjoin($leftJoin);
			$countQuery->leftjoin($leftJoin);
		}
		
		if(count($selectQuery->get('orderby')) && !empty($sorts)) {
			$selectQuery->set('orderby', array());
		}
		
		if(empty($sorts)) {
			$sorts[] = "$table.created DESC";
		}

		// COUNT
		$sql = $countQuery->getQuery();
		$query = $database->prepare($sql);
		foreach($binds as $key => $value) {
			$query->bindValue(":$key", $value);
		}
		$countQuery->copyBindValuesTo($query); // populate from $countQuery to $query
		$query->execute();
		list($total) = $query->fetch(\PDO::FETCH_NUM);
		$total = (int) $total;
		$query->closeCursor();
		if($options['count']) return $total;

		// SELECT
		$selectQuery->orderby(implode(',', $sorts));
		$selectQuery->limit("$start,$limit"); 

		/** @var CommentArray $comments */
		$comments = $this->wire(new CommentArray());
		$comments->setField($field);
		$comments->setStart($start);
		$comments->setLimit($limit);
		$comments->data('selectors', $selectors); // only present on CommentArray from find()

		$sql = $selectQuery->getQuery();
		$query = $database->prepare($sql);
		foreach($binds as $key => $value) {
			$query->bindValue(":$key", $value); 
		}
		$selectQuery->copyBindValuesTo($query); // populate from $selectQuery to $query
		$query->execute();
		$commentPages = array();

		while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
			$pageID = $row['pages_id'];
			if(isset($commentPages[$pageID])) {
				$page = $commentPages[$pageID];
			} else {
				$page = $pages->get((int) $pageID);
				$commentPages[$page->id] = $page;
			}
			$comment = $this->makeComment($page, $field, $row); 
			$comments->add($comment);
			if($page->id) $comment->setPage($page);
			$comment->setIsLoaded(true);
		}
		$query->closeCursor();
		
		$comments->resetTrackChanges();
		$comments->setTotal($total);

		return $comments; 
	}
	
	/**
	 * Given a field and a selector, return total quantity of comments matching the selector
	 *
	 * @param string|null $selectorString Selector string with query
	 * @param Field|string Optional Field object. If omitted, then it will be determined automatically.
	 * @return int
	 * @throws WireException
	 *
	 */
	public function count($selectorString, $field = null) {
		return $this->find($selectorString, $field, array('count' => true)); 
	}

	/**
	 * Return comments field(s)
	 * 
	 * @param bool $one Return only the first one found? (default=false)
	 * @return array|Field|null
	 * 
	 */
	public function getCommentsFields($one = false) {
		$fields = array();
		foreach($this->wire()->fields as $field) {
			if($field->type instanceof FieldtypeComments) {
				$fields[] = $field;
				if($one) break;
			}
		}
		if($one) return count($fields) ? $fields[0] : null;
		return $fields;
	}

	/**
	 * Given a comment code or subcode, return the associated comment ID or 0 if it doesn't exist
	 * 
	 * @param Page|int $page
	 * @param Field|string $field
	 * @param $code
	 * @return Comment|null
	 * 
	 */
	public function getCommentByCode($page, $field, $code) {
		if(!is_object($page)) $page = $this->wire()->pages->get((int) $page); 
		if(!$page->id) return null;
		if(!trim($code)) return null; 
		if(!is_object($field)) $field = $this->wire()->fields->get($this->wire()->sanitizer->fieldName($field)); 
		if(!$field || !$field->type instanceof FieldtypeComments) return null; 
		$table = $field->getTable();
		$col = strlen($code) > 100 ? 'code' : 'subcode';
		$sql = "SELECT * FROM `$table` WHERE `$col`!='' AND `$col` IS NOT NULL AND `$col`=:code AND pages_id=:pageID";
		$query = $this->wire('database')->prepare($sql);
		$query->bindValue(':code', substr($code, 0, 128), \PDO::PARAM_STR); 
		$query->bindValue(':pageID', $page->id, \PDO::PARAM_INT); 
		if(!$query->execute()) return null; 
		if(!$query->rowCount()) return null; 
		$data = $query->fetch(\PDO::FETCH_ASSOC); 
		return $this->makeComment($page, $field, $data); 
	}

	/**
	 * Get a comment by ID or NULL if not found
	 * 
	 * @param Page|int|null $page Provide Page object or ID, or null to auto-detect Page
	 * @param Field|string $field Field object or name
	 * @param int $id Comment ID
	 * @return Comment|null
	 * 
	 */
	public function getCommentByID($page, $field, $id) {
		$pages = $this->wire()->pages;
		if(empty($id)) return null;
		if($page && !is_object($page)) {
			$page = $pages->get((int) $page);
			if(!$page->id) return null;
		}
		if(!is_object($field) && $field) {
			$field = $this->wire()->fields->get($this->wire()->sanitizer->fieldName($field));
		}
		if(!$field || !$field->type instanceof FieldtypeComments) return null;
		$table = $field->getTable();
		$sql = "SELECT * FROM `$table` WHERE id=:id ";
		if($page) $sql .= "AND pages_id=:pageID";
		$query = $this->wire()->database->prepare($sql);
		$query->bindValue(':id', (int) $id, \PDO::PARAM_INT); 
		if($page) $query->bindValue(':pageID', $page->id, \PDO::PARAM_INT);
		if(!$query->execute()) return null;
		if(!$query->rowCount()) return null;
		$data = $query->fetch(\PDO::FETCH_ASSOC);
		if(!$page) {
			$page = $pages->get((int) $data['pages_id']); 
		}	
		return $this->makeComment($page, $field, $data); 
	}

	/**
	 * Get parent comments for given Comment
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @return CommentArray
	 * @since 3.0.153
	 * 
	 */
	public function getCommentParents(Page $page, Field $field, Comment $comment) {
		
		/** @var CommentArray $parents */
		$parents = $this->wire(new CommentArray());
		$parents->setPage($page);
		$parents->setField($field);
		
		if(!$comment->parent_id) return $parents;
		
		$parent = $comment;
		
		do {
			$parent = $this->getCommentByID($page, $field, $parent->parent_id); 
			if(!$parent || !$parent->id || $parents->has($parent)) break;
			$parents->add($parent); 
		} while($parent->parent_id > 0);
	
		return $parents;
	}

	/**
	 * Given an array of data, convert it to a Comment object
	 * 
	 * @param $page
	 * @param $field
	 * @param array $data
	 * @return Comment
	 * 
	 */
	protected function makeComment($page, $field, array $data) {
	
		/** @var Comment $comment */
		$comment = $this->wire(new Comment());
		$comment->setPage($page);
		$comment->setField($field);
		
		foreach($data as $key => $val) {
			if($key === 'data') {
				$key = 'text';
			} else if($key === 'meta') {
				$val = is_string($val) ? json_decode($val, true) : array();
				if(is_array($val)) $comment->setMeta($val);
				continue;
			}
			$comment->set($key, $val);
		}
		
		$comment->resetTrackChanges(true);
		$comment->setIsLoaded(true); 
		
		return $comment; 
	}

	/**
	 * Update specific properties for a comment
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param array $properties Associative array of properties to update
	 * @return bool
	 * @throws WireException
	 * 
	 */
	public function ___updateComment(Page $page, $field, Comment $comment, array $properties) {
		if(!count($properties)) return false;
		
		$commentID = $comment->id; 
		$database = $this->wire()->database;
		$sanitizer = $this->wire()->sanitizer;
		$fields = $this->wire()->fields;
		
		if(!is_object($field)) $field = $fields->get($sanitizer->fieldName($field)); 
		if(!$field instanceof Field) return false;
		$table = $database->escapeTable($field->getTable());
		$sql = "UPDATE `$table` SET ";
		$values = array();
		foreach($properties as $property => $value) {
			if($property === 'meta') {
				if(!is_array($value)) continue;
				$comment->setMeta($value);
				$value = json_encode($value);
				if(!is_string($value)) continue;
			} else {
				$comment->set($property, $value);
			}
			$property = $sanitizer->fieldName($property); 
			$property = $database->escapeCol($property); 
			if($property === 'text') $property = 'data';
			if(is_null($value) && ($property === 'code' || $property === 'subcode')) {
				$sql .= "`$property`=NULL, ";
			} else {
				$sql .= "`$property`=:$property, ";
				$values[$property] = $value;
			}
		}
		$sql = rtrim($sql, ', ') . " WHERE id=:commentID AND pages_id=:pageID";
		$query = $database->prepare($sql); 
		$query->bindValue(':commentID', $commentID, \PDO::PARAM_INT);
		$query->bindValue(':pageID', $page->id, \PDO::PARAM_INT); 
		foreach($values as $property => $value) {
			$query->bindValue(":$property", $value); 
		}
		try {
			$this->triggerPageFieldSaveReady($page, $field);
			$result = $query->execute();
			$this->triggerPageFieldSaved($page, $field);
		} catch(\Exception $e) {
			$result = false;
			if($this->wire()->config->debug) {
				$this->error($e->getMessage(), Notice::log);
			} else {
				$this->wire()->log->error($e->getMessage());
			}
		}
		if($result) { 
			$this->checkExistingComment($page, $field, $comment); 
		}
		return $result;
	}

	/**
	 * Add new comment
	 * 
	 * Requires a new Comment object with no id, that has all its required field populated and validated
	 * and is ready to add. Note that the `sort` property is assigned automatically if not specified in Comment.
	 * 
	 * The primary reason to use this method is if you want to add a comment without loading all the other
	 * comments on a given Page. 
	 * 
	 * Note: if you are hooking this method, comments can also be added by the savePageField method
	 * 
	 * @param Page $page Page where comments field exists
	 * @param Field $field Comments field
	 * @param Comment $comment New comment to add
	 * @param bool $send Send comment for automatic approval filtering and email notifications?
	 *  - `true` if comment was just submitted now from user input and filtering should apply, notifications sent, etc.
	 *  - `false` if you are importing comments and NO filtering should be applied, NO notifications sent, etc.
	 * @return bool Returns true on success, false on fail
	 * @throws WireException if given a Comment object that is not ready to add
	 * @since 3.0.153
	 * 
	 */
	public function addComment(Page $page, Field $field, Comment $comment, $send) {
		
		if($comment->id) {
			throw new WireException("Comment already has an ID (not a new comment)");
		} else if($comment->getPage() && $comment->getPage()->id != $page->id) {
			throw new WireException("Comment is already assigned to a different Page"); 
		} else if($comment->getField() && $comment->getField()->id != $field->id) {
			throw new WireException("Comment is already assigned to a different Field"); 
		}

		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);
		$sets = array('pages_id=:pages_id');
		$binds = array('pages_id' => (int) $page->id);
		$nextSort = $this->getMaxSort($page, $field) + 1;
		$approved = $comment->isApproved();
		$comment->quiet(!$send); 

		if(!$comment->sort) $comment->sort = $nextSort;
		
		$this->commentAddReady($page, $field, $comment); 
	
		/** @var CommentArray $commentArray */
		$commentArray = $this->wire(new CommentArray());
		$commentArray->setPage($page);
		$commentArray->setField($field); 
		$commentArray->add($comment);

		// convert CommentArray to sleep value array
		$value = $this->sleepValue($page, $field, $commentArray);
		if(!count($value)) return false;
		
		if($approved) $this->triggerPageFieldSaveReady($page, $field); 
		
		// use just first item, since only 1 comment
		$value = $value[0]; 
		$value['sort'] = $nextSort;
	
		// determine all values to set
		foreach(array_keys($value) as $key) {
			if($key == 'id') continue;
			
			$v = $value[$key];
			$col = $database->escapeCol($key);
			
			if($v === null && ($key === 'code' || $key === 'subcode')) {
				// 'code' and 'subcode' allow null
				$sets[] = "$col=NULL";
			} else {
				$sets[] = "$col=:$col";
				$binds[$col] = $v;
			}
		}
		
		$tries = 0;
		$maxTries = 10;

		do {
			try {
				$error = '';
				$sql = "INSERT INTO `$table` SET " . implode(', ', $sets);
				$query = $database->prepare($sql);
				foreach($binds as $k => $v) {
					$query->bindValue(":$k", $v);
				}
				$result = $query->execute();
				if($result) $comment->id = $database->lastInsertId();
			} catch(\Exception $e) {
				$result = false;
				$error = $e->getMessage();
				if($e->getCode() == 23000) {
					// colliding index, maybe race condition, see if increasing sort solves it
					if($binds['sort'] == $nextSort) $nextSort++;
					$binds['sort'] = $nextSort;
				}
			}
		} while(!$result && ++$tries < $maxTries);
		
		if($result && $comment->id) {
			// the $page->data() call used so load not triggered, only returns comments if already loaded
			$comments = $page->data($field->name);
			// add to loaded CommentArray for page, but only if it is already loaded in memory
			if($comments instanceof CommentArray) $comments->add($comment); 
			$this->commentAdded($page, $field, $comment);
			if($approved) $this->triggerPageFieldSaved($page, $field); 
		} else {
			$error = "Error adding new comment on page $page field $field->name for $comment->email: $error";
			$this->error($error, Notice::superuser | Notice::log);
		}
		
		return $result;
	}

	/**
	 * Delete a given comment
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param string $notes
	 * @return bool
	 *
	 */
	public function deleteComment(Page $page, Field $field, Comment $comment, $notes = '') {
		
		$approved = $comment->isApproved();
		$database = $this->wire()->database;
		
		if($field->get('depth') > 0) {
			foreach($comment->children() as $child) {
				$this->deleteComment($page, $field, $child); 
			}
		}
		
		$table = $database->escapeTable($field->getTable());
		$sql = "DELETE FROM `$table` WHERE id=:id AND pages_id=:pages_id";
		$query = $database->prepare($sql); 
		$query->bindValue(':id', $comment->id, \PDO::PARAM_INT);
		$query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
		$comments = $page->get($field->name); 
		
		try {
			if($approved) $this->triggerPageFieldSaveReady($page, $field);
			$result = $query->execute();
			if($comments) $comments->remove($comment); 
			$this->commentDeleted($page, $field, $comment, $notes); 
			if($approved) $this->triggerPageFieldSaved($page, $field);
			
		} catch(\Exception $e) {
			$this->error($e->getMessage()); 
			$result = false;
		}
		
		return $result;
	}

	/**
	 * Get max 'sort' value for comments field or -1 if no rows
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return int
	 * @since 3.0.153
	 * 
	 */
	protected function getMaxSort(Page $page, Field $field) {
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->getTable());
		$sql = "SELECT MAX(sort) FROM `$table` WHERE pages_id=:pages_id";
		$query = $database->prepare($sql);
		$query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
		$query->execute();
		$value = $query->fetchColumn();
		$value = $value === null ? -1 : (int) $value;
		$query->closeCursor();
		return $value; 
	}

	/**
	 * Get number of comments for Page, optionally limited by specific $options
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param array $options 
	 *  - `status` (int): Specify Comment::status* constant to include only this status
	 *  - `minStatus` (int): Specify Comment::status* constant to include only comments with at least this status
	 *  - `maxStatus` (int): Specify Comment::status* constant or include only comments up to this status
	 *  - `parent` (int|Comment): Specify parent comment ID, 0 for root-only, or omit for no parent limitation
	 *  - `minCreated` (int): Minimum created unix timestamp
	 *  - `maxCreated` (int): Maximum created unix timestamp
	 *  - `stars` (int): Number of stars to match (1-5)
	 *  - `minStars` (int): Minimum number of stars to match (1-5)
	 *  - `maxStars` (int): Maximum number of stars to match (1-5)
	 * @return int
	 * @since 3.0.153
	 * 
	 */
	public function getNumComments(Page $page, Field $field, array $options = array()) {
		
		$defaults = array(
			'status' => null,
			'minStatus' => null, 
			'maxStatus' => null, 
			'parent' => null,
			'minCreated' => null,
			'maxCreated' => null,
			'stars' => null,
			'minStars' => null,
			'maxStars' => null,
		);
		
		$options = array_merge($defaults, $options);
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->getTable());
		$sql = "SELECT COUNT(*) FROM `$table` WHERE pages_id=:page "; 
		$binds = array(':page' => $page->id); 
		
		$cols = array(
			'status' => 'status=:status',
			'minStatus' => 'status>=:minStatus', 
			'maxStatus' => 'status<=:maxStatus', 
			'parent' => 'parent_id=:parent',
			'minCreated' => 'created>=:minCreated',
			'maxCreated' => 'created<=:maxCreated',
			'stars' => 'stars=:stars',
			'minStars' => 'stars>=:minStars',
			'maxStars' => 'stars<=:maxStars',
		);
		
		foreach($cols as $name => $stmt) {
			$value = $options[$name];
			if($value === null) continue;
			if($name === 'parent' && $value instanceof Comment) $value = $value->id;
			$sql .= "AND $stmt ";
			$binds[":$name"] = (int) $value;
		}
		
		$query = $database->prepare($sql);
		foreach($binds as $key => $value) {
			$query->bindValue($key, $value, \PDO::PARAM_INT);
		}
		
		$query->execute();
		$numComments = (int) $query->fetchColumn();
		$query->closeCursor();
		
		return $numComments;
	}
	
	/**
	 * Trigger hooks for Pages::saveFieldReady() and pages::savePageOrFieldReady()
	 *
	 * @param Page $page
	 * @param Field $field
	 *
	 */
	protected function triggerPageFieldSaveReady(Page $page, Field $field) {
		$pages = $this->wire()->pages;
		$pages->saveFieldReady($page, $field);
		$pages->savePageOrFieldReady($page, $field->name);
	}

	/**
	 * Trigger hooks for Pages::savedField() and pages::savedPageOrField()
	 * 
	 * @param Page $page
	 * @param Field $field
	 * 
	 */
	protected function triggerPageFieldSaved(Page $page, Field $field) {
		$pages = $this->wire()->pages;
		$pages->savedField($page, $field); 
		$pages->savedPageOrField($page, array($field->name)); 
	}

	/**
	 * Hook called after comment has been deleted
	 * 
	 * #pw-hooker
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param string $notes
	 *
	 */
	public function ___commentDeleted(Page $page, Field $field, Comment $comment, $notes = '') {
	}

	/**
	 * Hook called when a comment goes from un-approved to approved
	 * 
	 * #pw-hooker
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param string $notes
	 * 
	 */
	public function ___commentApproved(Page $page, Field $field, Comment $comment, $notes = '') {
		
		$this->wire()->log->message("Approved comment $comment->id - $notes"); 
		
		if(!$field->get('useNotify')) return; // notifications not in use
		if($comment->flags & Comment::flagNotifyQueue) return; // comment queued for 3rd party notifications
		
		$emailsData = $this->getNotifyEmails($page, $field, $comment); 

		// emails array contains email address => subcode to send notifications to
		if(count($emailsData)) {
			$qty = $this->sendNotifyEmails($page, $field, $comment, $emailsData);
			if($qty > 0) {
				// emails sent
			}
		}
	}


	/**
	 * Hook called when a comment goes from approved to pending or spam
	 * 
	 * #pw-hooker
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param string $notes
	 *
	 */
	public function ___commentUnapproved(Page $page, Field $field, Comment $comment, $notes = '') {
	}

	/**
	 * Hook called when new comment about to be added to DB
	 * 
	 * #pw-hooker
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * 
	 */
	public function ___commentAddReady(Page $page, Field $field, Comment $comment) {
		$text = $comment->text; 
		if(strtolower($this->wire()->config->dbCharset) !== 'utf8mb4') {
			$comment->text = $this->wire()->sanitizer->removeMB4($text, array('replaceWith' => ' ')); 
		}
	}

	/**
	 * Hook called when new comment has successfully been added to DB
	 *
	 * #pw-hooker
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 *
	 */
	public function ___commentAdded(Page $page, Field $field, Comment $comment) {
		$no = $this->commentNotifications($page, $field); 
		$no->sendAdminNotificationEmail($comment);
		$this->commentMaintenance($field);

		if($comment->status == Comment::statusApproved || $comment->status == Comment::statusFeatured) {
			$this->commentApproved($page, $field, $comment, $comment->approvalNote);
		}
	}
	
	/**
	 * Get emails (and other data) of people that should be notified for a given Page’s comments
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment|null $comment
	 * @return array
	 * @throws WireException
	 * @since 3.0.169
	 *
	 */
	public function getNotifyEmails(Page $page, Field $field, ?Comment $comment = null) {

		if(!$field->get('useNotify')) return array();

		$emails = array();
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);

		$sql =
			"SELECT id, cite, flags, email, subcode " . 
			"FROM `$table` " .
			"WHERE pages_id=:pages_id " .
			"AND status>0 " .
			"AND ((flags & 2) OR (flags & 4) OR (flags & 8)) " . // 2=replies, 4=all, 8=confirmed
			"AND id!=:id " .
			"AND email!=:email " .
			"ORDER BY created DESC";

		$query = $database->prepare($sql);
		$query->bindValue(':pages_id', $page->id, \PDO::PARAM_INT);
		$query->bindValue(':id', ($comment ? $comment->id : 0), \PDO::PARAM_INT);
		$query->bindValue(':email', ($comment ? strtolower($comment->email) : ''), \PDO::PARAM_STR);
		$query->execute();

		while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
			$id = (int) $row['id'];
			$flags = (int) $row['flags'];
			$subcode = $row['subcode'];
			$email = strtolower($row['email']);

			if(empty($subcode)) continue;

			/*
			 * @todo this should be ready to use, but needs more testing before enabling it
			 * 
			if($flags & Comment::flagNotifyConfirmed) {
				// notifications have been confirmed by double opt-in
			} else {
				continue;
			}
			*/

			if($flags & Comment::flagNotifyAll) {
				// OK
			} else if(($flags & Comment::flagNotifyReply) && $comment && $comment->parent_id == $id) {
				// OK
			} else {
				continue; // SKIP
			}

			$emails[$email] = array(
				'id' => $id,
				'email' => $email,
				'cite' => $row['cite'],
				'subcode' => $subcode,
				'flags' => $flags,
			);
		}

		$query->closeCursor();

		return $emails;
	}
	
	/**
	 * Get emails (and other data) of people that should be notified for a given Page’s comments
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param array $emailsData
	 * @return int
	 * @since 3.0.175
	 *
	 */
	public function ___sendNotifyEmails(Page $page, Field $field, Comment $comment, array $emailsData) {
		
		$no = $this->commentNotifications($page, $field); 
		$cnt = 0;
		
		foreach($emailsData as $data) {
			$subcode = $data['subcode'];
			$email = $data['email']; 
			$cnt += $no->sendNotificationEmail($comment, $email, $subcode);
		}
		
		return $cnt;
	}

	/**
	 * Add a vote to the current comment from the current user/IP
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @param Comment $comment
	 * @param bool $up Specify true for upvote, or false for downvote
	 * @return bool Returns true on success, false on failure or duplicate
	 * 
	 */
	public function voteComment(Page $page, Field $field, Comment $comment, $up = true) {
		
		$database = $this->wire()->database; 
		$table = $database->escapeTable($field->getTable()) . '_votes';
		
		if(!$field->get('useVotes')) return false;
		if(!$up && $field->get('useVotes') != self::useVotesAll) return false; // downvotes not allowed
		
		$sql = "INSERT INTO `$table` SET comment_id=:comment_id, vote=:vote, ip=:ip, user_id=:user_id";
		$query = $database->prepare($sql);
		$query->bindValue(':comment_id', $comment->id, \PDO::PARAM_INT); 
		$query->bindValue(':vote', $up ? 1 : -1, \PDO::PARAM_INT); 
		$query->bindValue(':ip', $this->wire()->session->getIP(), \PDO::PARAM_STR); 
		$query->bindValue(':user_id', $this->wire()->user->id, \PDO::PARAM_INT); 
		
		$result = false;
		
		try {
			if($query->execute()) {
				if($up) {
					$comment->upvotes++;
					$result = $this->updateComment($page, $field, $comment, array('upvotes' => $comment->upvotes));
				} else {
					$comment->downvotes++;
					$result = $this->updateComment($page, $field, $comment, array('downvotes' => $comment->downvotes));
				}
			}
		} catch(\Exception $e) {
			// duplicate or fail
			$error = $e->getMessage();
			if(stripos($error, 'duplicate entry')) {
				$this->error($this->_('You have already voted for this comment')); 
			} else {
				if($this->wire()->config->debug && !$this->wire()->user->isLoggedin()) {
					$this->error($e->getMessage());
				} else {
					$this->error($this->_('Error recording vote')); 
				}
			}
		}
		
		return $result;
	}

	/**
	 * Check the request for a vote action
	 * 
	 * @param Page|HookEvent $page
	 * @return array
	 * @throws WireException
	 * 
	 */
	public function checkVoteAction($page) {
		
		$input = $this->wire()->input;
		
		$result = array(
			'success' => false, 
			'valid' => false, 
			'message' => 'Invalid vote', 
			'upvotes' => 0,
			'downvotes' => 0, 
			'pageID' => 0, 
			'fieldName' => '', 
			'commentID' => 0, 
		);

		$action = $input->get('comment_success');
		if($action !== 'upvote' && $action !== 'downvote') return $result;

		$commentID = (int) $input->get('comment_id');
		$fieldID = (int) $input->get('field_id');
		if(!$commentID || !$fieldID) return $result;

		$field = $this->wire()->fields->get($fieldID);
		if(!$field || !$field->type instanceof FieldtypeComments) return $result;
		
		if($page instanceof HookEvent) $page = $page->object; 
		if(!$page || !$page->template->fieldgroup->hasField($field)) return $result;

		$comment = $this->getCommentByID($page, $field, $commentID);
		if(!$comment || $comment->getPage()->id != $page->id || $comment->getField()->id != $field->id) return $result;

		$success = $this->voteComment($page, $field, $comment, $action == 'upvote');
		$message = $success ? '' : $this->errors('clear string'); 
		
		$result = array(
			'success' => $success,
			'valid' => true, 
			'message' => $message,
			'upvotes' => $comment->upvotes,
			'downvotes' => $comment->downvotes,
			'pageID' => $page->id, 
			'fieldName' => $field->name, 
			'commentID' => $comment->id
		);
		
		if($this->wire()->config->ajax) {
			header("Content-type: application/json");
			echo json_encode($result);
			exit;
		}

		return $result;
	}

	/**
	 * Delete the given field, which implies: drop the table $field->table
	 *
	 * This should only be called by the Fields class since fieldgroups_fields lookup entries must be deleted before this method is called.
	 *
	 * @param Field $field Field object
	 * @return bool True on success, false on DB delete failure.
	 *
	 */
	public function ___deleteField(Field $field) {
		$database = $this->wire()->database;
		$table = $database->escapeTable($field->table);
		try {
			$result = $database->exec("DROP TABLE `$table`"); // QA
		} catch(\Exception $e) {
			$result = false;
			$this->error($e->getMessage());
		}
		if($result !== false) try {
			$database->exec("DROP TABLE `{$table}_votes`"); // QA
		} catch(\Exception $e) {
			// ok to ignore, as table may not exist
		}
		return $result;
	}

	/**
	 * Hook called by Fields::save() after a field using this type has been renamed
	 *
	 * Note that PW already takes care of renaming the field_[name] table.
	 * Most Fieldtypes don't need to do anything here, but this exists just in case.
	 *
	 * #pw-internal
	 *
	 * @param Field $field
	 * @param string $prevName Previous name (current name can be found in $field->name)
	 *
	 */
	public function ___renamedField(Field $field, $prevName) {
	
		$database = $this->wire()->database;
		$table = $database->escapeTable(Field::tablePrefix . $field->name . '_votes');
		$prevTable = $database->escapeTable(Field::tablePrefix . $prevName . '_votes');
		
		try {
			// double rename ensures MySQL doesn't skip case changes
			$database->exec("RENAME TABLE `$prevTable` TO `tmp_$table`"); // QA
			$database->exec("RENAME TABLE `tmp_$table` TO `$table`"); // QA
		} catch(\Exception $e) {
			// ok to ignore, as table may not exist
		}
		
		parent::___renamedField($field, $prevName);
	}
	
	/**
	 * Delete the given Field from the given Page.
	 *
	 * Must delete entries from field's database table that belong to the Page.
	 *
	 * #pw-internal
	 *
	 * @param Page $page
	 * @param Field $field Field object
	 * @return bool True on success, false on DB delete failure.
	 * @throws WireException
	 *
	 */
	public function ___deletePageField(Page $page, Field $field) {
		// @todo
		return parent::___deletePageField($page, $field);
	}

	/**
	 * Empty out the DB table data for page field, but leave everything else in tact.
	 *
	 * In most cases this may be nearly identical to `Fieldtype::deletePageField()`, but would be different
	 * for things like page references where we wouldn't want relational data deleted.
	 *
	 * #pw-group-deleting
	 *
	 * @param Page $page
	 * @param Field $field Field object
	 * @return bool True on success, false on DB delete failure.
	 * @throws WireException
	 *
	 */
	public function ___emptyPageField(Page $page, Field $field) {
		// @todo
		return parent::___emptyPageField($page, $field);
	}

	/**
	 * Move this field’s data from one page to another.
	 *
	 * #pw-internal
	 *
	 * @param Page $src Source Page
	 * @param Page $dst Destination Page
	 * @param Field $field
	 * @return bool
	 *
	 */
	public function ___replacePageField(Page $src, Page $dst, Field $field) {
		// @todo
		return parent::___replacePageField($src, $dst, $field);
	}

	/**
	 * Export Comment to array
	 * 
	 * @param Comment|array $comment
	 * @return array
	 * 
	 */
	protected function exportComment($comment) {
		if(is_object($comment)) $comment = $comment->getArray();
		if(isset($comment['created_users_id'])) {
			$u = $this->wire()->users->get((int) $comment['created_users_id']);
			$comment['created_user'] = $u->name;
			unset($comment['created_users_id']); 
		}
		return $comment;
	}

	/**
	 * Export Comments to array
	 * 
	 * @param CommentArray|array $comments 
	 * @return array
	 * 
	 */
	protected function exportComments($comments) {
		
		$commentsArray = array();
		$exportComments = array();
		
		foreach($comments as $comment) {
			if($comment['status'] == Comment::statusSpam) continue; // don't export spam
			$commentsArray[(int) $comment['id']] = $comment; // index by comment id
		}

		foreach($commentsArray as /* $id => */ $comment) {

			$key = $this->getCommentExportKey($comment);
			$comment = $this->exportComment($comment);

			if(!empty($comment['parent_id'])) {
				$parentID = (int) $comment['parent_id'];
				$parent = isset($commentsArray[$parentID]) ? $commentsArray[$parentID] : null;
				if($parent) $comment['parent_key'] = $this->getCommentExportKey($parent); 
			}

			$exportComments[$key] = $comment;
		}

		return $exportComments; 
	}
	
	/**
	 * Get key used for exporting a comment
	 *
	 * @param Comment|array $comment
	 * @return string
	 *
	 */
	protected function getCommentExportKey($comment) {
		return "$comment[created] $comment[email]";
	}

	/**
	 * Export value
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array|int|object|string $value
	 * @param array $options
	 * @return array
	 *
	 */
	public function ___exportValue(Page $page, Field $field, $value, array $options = array()) {
		return array_values($this->exportComments($value));
	}
	
	/**
	 * Import value
	 * 
	 * Note: importValue does not delete comments, only insert or update. 
	 *
	 * @param Page $page
	 * @param Field $field
	 * @param array $value
	 * @param array $options
	 * @return array|string
	 *
	 */
	public function ___importValue(Page $page, Field $field, $value, array $options = array()) {
		
		$users = $this->wire()->users;
		$sanitizer = $this->wire()->sanitizer;
		
		$value = $this->exportComments($value);
		$comments = $page->get($field->name);
		$commentsArray = array();
		$addComments = array();
		$updateComments = array();
		$skipImportComments = array();
		$updatePropertyCounts = array();
		$skipUpdateProperties = array('id', 'created_user', 'created_users_id'); 
		
		if(!$comments) return null;
		
		foreach($comments as $comment) {
			if($comment->status == Comment::statusSpam) continue; 
			$key = $this->getCommentExportKey($comment); 
			$commentsArray[$key] = $comment;
		}
		
		foreach($value as $key => $importCommentArray) {
			
			if(!empty($importCommentArray['parent_key'])) {
				$parentKey = $importCommentArray['parent_key'];
				if(!isset($commentsArray[$parentKey])) {
					$skipImportComments[$key] = $importCommentArray;
					continue;
				}
				$importCommentArray['parent_id'] = $commentsArray[$parentKey]['id'];
				unset($importCommentArray['parent_key']); 
			}
			
			if(!isset($commentsArray[$key])) {
				$addComments[$key] = $importCommentArray;
				continue;
			}
			
			$comment = $commentsArray[$key];
			$commentArray = $this->exportComment($comment); 
			
			foreach($skipUpdateProperties as $property) {
				unset($commentArray[$property]); 
				unset($importCommentArray[$property]); 
			}
		
			if($commentArray == $importCommentArray) continue; // no changes
			
			foreach($importCommentArray as $k => $v) {
				if(isset($commentArray[$k]) && $commentArray[$k] == $v) continue;
				$comment->set($k, $v); 
				$comment->quiet(true);
				$updateComments[$key] = $comment;
				if(!isset($updatePropertyCounts[$k])) $updatePropertyCounts[$k] = 0;
				$updatePropertyCounts[$k]++;
			}
		}
		
		foreach($addComments as $commentArray) {
			unset($commentArray['id']); 	
			$u = $users->get("name=" . $sanitizer->pageName($commentArray['created_user']));
			$commentArray['created_users_id'] = $u->id;	
			unset($commentArray['created_user']); 
			$comment = $this->makeComment($page, $field, $commentArray); 
			$comment->quiet(true);
			$comments->add($comment); 
		}

		$numAddComments = count($addComments); 
		$numUpdateComments = count($updateComments); 
		$numSkipComments = count($skipImportComments); 
		
		if($numAddComments) {
			$comments->message("$field->name: $numAddComments new added"); 
		}
		
		if($numUpdateComments) {
			$counts = array();
			foreach($updatePropertyCounts as $property => $count) {
				$counts[] = "$property ($count)";
			}
			$comments->message("$field->name: $numUpdateComments updated - " . implode(', ', $counts));
			$comments->trackChange('value');
		}
		
		if($numSkipComments) {
			$comments->warning(
				"$field->name: $numSkipComments skipped because parent comment(s) not yet present (run import again)"
			); 
		}
		
		return $comments; 
		
	}

	/**
	 * Get associative array of options (name => default) that Fieldtype supports for importValue
	 * 
	 * #pw-internal
	 *
	 * @param Field $field
	 * @return array
	 *
	 */
	public function getImportValueOptions(Field $field) {
		$options = parent::getImportValueOptions($field);
		$options['test'] = true;
		return $options;
	}

	/**
	 * Get instance of CommentNotifications class
	 * 
	 * #pw-internal
	 * 
	 * @param Page $page
	 * @param Field $field
	 * @return CommentNotifications
	 * @since 3.0.175
	 * 
	 */
	public function commentNotifications(Page $page, Field $field) {
		require_once(dirname(__FILE__) . '/CommentNotifications.php');
		$no = new CommentNotifications($page, $field);
		$this->wire($no);
		return $no;
	}
	
}
