<?php
/**
 * Class Image
 *
 * An object representing an entry in the images table.
 *
 * As of 2.2, this no longer necessarily represents an
 * image per se, but could be a video, sound file, or any
 * other supported upload type.
 */
class Image {
	private static $tag_n = 0; // temp hack
	public static $order_sql = null; // this feels ugly

	/** @var null|int */
	public $id = null;

	/** @var int */
	public $height;

	/** @var int */
	public $width;

	/** @var string */
	public $hash;

	public $filesize;

	/** @var string */
	public $filename;

	/** @var string */
	public $ext;

	/** @var string[]|null */
	public $tag_array;

	/** @var int */
	public $owner_id;
	
	/** @var string */
	public $owner_ip;
	
	/** @var string */
	public $posted;
	
	/** @var string */
	public $source;
    
	/** @var boolean */
	public $locked = false;

	/**
	 * One will very rarely construct an image directly, more common
	 * would be to use Image::by_id, Image::by_hash, etc.
	 *
	 * @param null|mixed[] $row
	 */
	public function __construct(array $row=null) {
		if(!is_null($row)) {
			foreach($row as $name => $value) {
				// some databases use table.name rather than name
				$name = str_replace("images.", "", $name);
				$this->$name = $value; // hax, this is likely the cause of much scrutinizer-ci complaints.
			}
			$this->locked = bool_escape($this->locked);

			assert(is_numeric($this->id));
			assert(is_numeric($this->height));
			assert(is_numeric($this->width));
		}
	}

	public static function by_id(int $id) {
		global $database;
		$row = $database->get_row("SELECT * FROM images WHERE images.id=:id", array("id"=>$id));
		return ($row ? new Image($row) : null);
	}

	public static function by_hash(string $hash) {
		global $database;
		$row = $database->get_row("SELECT images.* FROM images WHERE hash=:hash", array("hash"=>$hash));
		return ($row ? new Image($row) : null);
	}

	public static function by_random(array $tags=array()) {
		$max = Image::count_images($tags);
		if ($max < 1) return null;		// From Issue #22 - opened by HungryFeline on May 30, 2011.
		$rand = mt_rand(0, $max-1);
		$set = Image::find_images($rand, 1, $tags);
		if(count($set) > 0) return $set[0];
		else return null;
	}

	/**
	 * Search for an array of images
	 *
	 * @param int $start
	 * @param int $limit
	 * @param string[] $tags
	 * @throws SCoreException
	 * @return Image[]
	 */
	public static function find_images(int $start, int $limit, array $tags=array()): array {
		global $database, $user, $config;

		$images = array();

		if($start < 0) $start = 0;
		if($limit < 1) $limit = 1;

		if(SPEED_HAX) {
			if(!$user->can("big_search") and count($tags) > 3) {
				throw new SCoreException("Anonymous users may only search for up to 3 tags at a time");
			}
		}

		$result = null;
		if(SEARCH_ACCEL) {
			$result = Image::get_accelerated_result($tags, $start, $limit);
		}

		if(!$result) {
			$querylet = Image::build_search_querylet($tags);
			$querylet->append(new Querylet(" ORDER BY ".(Image::$order_sql ?: "images.".$config->get_string("index_order"))));
			$querylet->append(new Querylet(" LIMIT :limit OFFSET :offset", array("limit"=>$limit, "offset"=>$start)));
			#var_dump($querylet->sql); var_dump($querylet->variables);
			$result = $database->execute($querylet->sql, $querylet->variables);
		}

		while($row = $result->fetch()) {
			$images[] = new Image($row);
		}
		Image::$order_sql = null;
		return $images;
	}

	/*
	 * Accelerator stuff
	 */
	public static function get_acceleratable(array $tags) {
		$ret = array(
			"yays" => array(),
			"nays" => array(),
		);
		$yays = 0;
		$nays = 0;
		foreach($tags as $tag) {
			if(!preg_match("/^-?[a-zA-Z0-9_-]+$/", $tag)) {
				return false;
			}
			if($tag[0] == "-") {$nays++; $ret["nays"][] = substr($tag, 1);}
			else {$yays++; $ret["yays"][] = $tag;}
		}
		if($yays > 1 || $nays > 0) {
			return $ret;
		}
		return false;
	}

	public static function get_accelerated_result(array $tags, int $offset, int $limit) {
		global $database;

		$req = Image::get_acceleratable($tags);
		if(!$req) {return null;}
		$req["offset"] = $offset;
		$req["limit"] = $limit;

		$response = Image::query_accelerator($req);
		$list = implode(",", $response);
		if($list) {
			$result = $database->execute("SELECT * FROM images WHERE id IN ($list) ORDER BY images.id DESC");
		}
		else {
			$result = $database->execute("SELECT * FROM images WHERE 1=0 ORDER BY images.id DESC");
		}
		return $result;
	}

	public static function get_accelerated_count(array $tags) {
		$req = Image::get_acceleratable($tags);
		if(!$req) {return null;}
		$req["count"] = true;

		return Image::query_accelerator($req);
	}

	public static function query_accelerator($req) {
		$fp = @fsockopen("127.0.0.1", 21212);
		if (!$fp) {
			return null;
		}
		fwrite($fp, json_encode($req));
		$data = fgets($fp, 1024);
		fclose($fp);
		return json_decode($data);
	}

	/*
	 * Image-related utility functions
	 */

	/**
	 * Count the number of image results for a given search
	 *
	 * @param string[] $tags
	 * @return int
	 */
	public static function count_images(array $tags=array()): int {
		global $database;
		$tag_count = count($tags);

		if($tag_count === 0) {
			$total = $database->cache->get("image-count");
			if(!$total) {
				$total = $database->get_one("SELECT COUNT(*) FROM images");
				$database->cache->set("image-count", $total, 600);
			}
		}
		else if($tag_count === 1 && !preg_match("/[:=><\*\?]/", $tags[0])) {
			$total = $database->get_one(
				$database->scoreql_to_sql("SELECT count FROM tags WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)"),
				array("tag"=>$tags[0]));
		}
		else {
			$total = Image::get_accelerated_count($tags);
			if(is_null($total)) {
				$querylet = Image::build_search_querylet($tags);
				$total = $database->get_one("SELECT COUNT(*) AS cnt FROM ($querylet->sql) AS tbl", $querylet->variables);
			}
		}
		if(is_null($total)) return 0;
		return $total;
	}

	/**
	 * Count the number of pages for a given search
	 *
	 * @param string[] $tags
	 * @return float
	 */
	public static function count_pages(array $tags=array()): float {
		global $config;
		return ceil(Image::count_images($tags) / $config->get_int('index_images'));
	}

	/*
	 * Accessors & mutators
	 */

	/**
	 * Find the next image in the sequence.
	 *
	 * Rather than simply $this_id + 1, one must take into account
	 * deleted images and search queries
	 *
	 * @param string[] $tags
	 * @param bool $next
	 * @return Image
	 */
	public function get_next(array $tags=array(), bool $next=true) {
		global $database;

		if($next) {
			$gtlt = "<";
			$dir = "DESC";
		}
		else {
			$gtlt = ">";
			$dir = "ASC";
		}

		if(count($tags) === 0) {
			$row = $database->get_row('
				SELECT images.*
				FROM images
				WHERE images.id '.$gtlt.' '.$this->id.'
				ORDER BY images.id '.$dir.'
				LIMIT 1
			');
		}
		else {
			$tags[] = 'id'. $gtlt . $this->id;
			$querylet = Image::build_search_querylet($tags);
			$querylet->append_sql(' ORDER BY images.id '.$dir.' LIMIT 1');
			$row = $database->get_row($querylet->sql, $querylet->variables);
		}

		return ($row ? new Image($row) : null);
	}

	/**
	 * The reverse of get_next
	 *
	 * @param string[] $tags
	 * @return Image
	 */
	public function get_prev(array $tags=array()) {
		return $this->get_next($tags, false);
	}

	/**
	 * Find the User who owns this Image
	 *
	 * @return User
	 */
	public function get_owner() {
		return User::by_id($this->owner_id);
	}

	/**
	 * Set the image's owner.
	 *
	 * @param User $owner
	 */
	public function set_owner(User $owner) {
		global $database;
		if($owner->id != $this->owner_id) {
			$database->execute("
				UPDATE images
				SET owner_id=:owner_id
				WHERE id=:id
			", array("owner_id"=>$owner->id, "id"=>$this->id));
			log_info("core_image", "Owner for Image #{$this->id} set to {$owner->name}", false, array("image_id" => $this->id));
		}
	}

	/**
	 * Get this image's tags as an array.
	 *
	 * @return string[]
	 */
	public function get_tag_array() {
		global $database;
		if(!isset($this->tag_array)) {
			$this->tag_array = $database->get_col("
				SELECT tag
				FROM image_tags
				JOIN tags ON image_tags.tag_id = tags.id
				WHERE image_id=:id
				ORDER BY tag
			", array("id"=>$this->id));
		}
		return $this->tag_array;
	}

	/**
	 * Get this image's tags as a string.
	 *
	 * @return string
	 */
	public function get_tag_list() {
		return Tag::implode($this->get_tag_array());
	}

	/**
	 * Get the URL for the full size image
	 *
	 * @return string
	 */
	public function get_image_link() {
		return $this->get_link('image_ilink', '_images/$hash/$id%20-%20$tags.$ext', 'image/$id.$ext');
	}

	/**
	 * Get the URL for the thumbnail
	 *
	 * @return string
	 */
	public function get_thumb_link() {
		return $this->get_link('image_tlink', '_thumbs/$hash/thumb.jpg', 'thumb/$id.jpg');
	}

	/**
	 * Check configured template for a link, then try nice URL, then plain URL
	 *
	 * @param string $template
	 * @param string $nice
	 * @param string $plain
	 * @return string
	 */
	private function get_link($template, $nice, $plain) {
		global $config;

		$image_link = $config->get_string($template);

		if(!empty($image_link)) {
			if(!(strpos($image_link, "://") > 0) && !startsWith($image_link, "/")) {
				$image_link = make_link($image_link);
			}
			return $this->parse_link_template($image_link);
		}
		else if($config->get_bool('nice_urls', false)) {
			return $this->parse_link_template(make_link($nice));
		}
		else {
			return $this->parse_link_template(make_link($plain));
		}
	}

	/**
	 * Get the tooltip for this image, formatted according to the
	 * configured template.
	 *
	 * @return string
	 */
	public function get_tooltip() {
		global $config;
		$tt = $this->parse_link_template($config->get_string('image_tip'), "no_escape");

		// Removes the size tag if the file is an mp3
		if($this->ext === 'mp3'){
			$iitip = $tt;
			$mp3tip = array("0x0");
			$h_tip = str_replace($mp3tip, " ", $iitip);

			// Makes it work with a variation of the default tooltips (I.E $tags // $filesize // $size)
			$justincase = array("   //", "//   ", "  //", "//  ", "  ");
			if(strstr($h_tip, "  ")) {
				$h_tip = html_escape(str_replace($justincase, "", $h_tip));
			}else{
				$h_tip = html_escape($h_tip);
			}
			return $h_tip;
		}
		else {
			return $tt;
		}
	}

	/**
	 * Figure out where the full size image is on disk.
	 *
	 * @return string
	 */
	public function get_image_filename() {
		return warehouse_path("images", $this->hash);
	}

	/**
	 * Figure out where the thumbnail is on disk.
	 *
	 * @return string
	 */
	public function get_thumb_filename() {
		return warehouse_path("thumbs", $this->hash);
	}

	/**
	 * Get the original filename.
	 *
	 * @return string
	 */
	public function get_filename() {
		return $this->filename;
	}

	/**
	 * Get the image's mime type.
	 *
	 * @return string
	 */
	public function get_mime_type() {
		return getMimeType($this->get_image_filename(), $this->get_ext());
	}

	/**
	 * Get the image's filename extension
	 *
	 * @return string
	 */
	public function get_ext() {
		return $this->ext;
	}

	/**
	 * Get the image's source URL
	 *
	 * @return string
	 */
	public function get_source() {
		return $this->source;
	}

	/**
	 * Set the image's source URL
	 *
	 * @param string $new_source
	 */
	public function set_source(string $new_source) {
		global $database;
		$old_source = $this->source;
		if(empty($new_source)) $new_source = null;
		if($new_source != $old_source) {
			$database->execute("UPDATE images SET source=:source WHERE id=:id", array("source"=>$new_source, "id"=>$this->id));
			log_info("core_image", "Source for Image #{$this->id} set to: $new_source (was $old_source)", false, array("image_id" => $this->id));
		}
	}

	/**
	 * Check if the image is locked.
	 * @return bool
	 */
	public function is_locked() {
		return $this->locked;
	}

	/**
	 * @param bool $tf
	 * @throws SCoreException
	 */
	public function set_locked($tf) {
		global $database;
		$ln = $tf ? "Y" : "N";
		$sln = $database->scoreql_to_sql('SCORE_BOOL_'.$ln);
		$sln = str_replace("'", "", $sln);
		$sln = str_replace('"', "", $sln);
		if(bool_escape($sln) !== $this->locked) {
			$database->execute("UPDATE images SET locked=:yn WHERE id=:id", array("yn"=>$sln, "id"=>$this->id));
			log_info("core_image", "Setting Image #{$this->id} lock to: $ln", false, array("image_id" => $this->id));
		}
	}

	/**
	 * Delete all tags from this image.
	 *
	 * Normally in preparation to set them to a new set.
	 */
	public function delete_tags_from_image() {
		global $database;
		if($database->get_driver_name() == "mysql") {
			//mysql < 5.6 has terrible subquery optimization, using EXISTS / JOIN fixes this
			$database->execute("
				UPDATE tags t
				INNER JOIN image_tags it ON t.id = it.tag_id
				SET count = count - 1
				WHERE it.image_id = :id",
				array("id"=>$this->id)
			);
		} else {
			$database->execute("
				UPDATE tags
				SET count = count - 1
				WHERE id IN (
					SELECT tag_id
					FROM image_tags
					WHERE image_id = :id
				)
			", array("id"=>$this->id));
		}
		$database->execute("
			DELETE
			FROM image_tags
			WHERE image_id=:id
		", array("id"=>$this->id));
	}

	/**
	 * Set the tags for this image.
	 */
	public function set_tags(array $unfiltered_tags) {
		$tags = [];
		foreach ($unfiltered_tags as $tag) {
			if(mb_strlen($tag, 'UTF-8') > 255){
				flash_message("Can't set a tag longer than 255 characters");
				continue;
			}
			if(startsWith($tag, "-")) {
				flash_message("Can't set a tag which starts with a minus");
				continue;
			}

			$tags[] = $tag;
		}
		assert('count($tags) > 0', var_export($tags, true));
		global $database;

		if(count($tags) <= 0) {
			throw new SCoreException('Tried to set zero tags');
		}

		if(implode(" ", $tags) != $this->get_tag_list()) {
			// delete old
			$this->delete_tags_from_image();
			// insert each new tags
			foreach($tags as $tag) {
				$id = $database->get_one(
					$database->scoreql_to_sql("
						SELECT id
						FROM tags
						WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)
					"),
					array("tag"=>$tag)
				);
				if(empty($id)) {
					// a new tag
					$database->execute(
							"INSERT INTO tags(tag) VALUES (:tag)",
							array("tag"=>$tag));
					$database->execute(
							"INSERT INTO image_tags(image_id, tag_id)
							VALUES(:id, (SELECT id FROM tags WHERE tag = :tag))",
							array("id"=>$this->id, "tag"=>$tag));
				}
				else {
					// user of an existing tag
					$database->execute("
						INSERT INTO image_tags(image_id, tag_id)
						VALUES(:iid, :tid)
					", array("iid"=>$this->id, "tid"=>$id));
				}
				$database->execute(
					$database->scoreql_to_sql("
						UPDATE tags
						SET count = count + 1
						WHERE SCORE_STRNORM(tag) = SCORE_STRNORM(:tag)
					"),
					array("tag"=>$tag)
				);
			}

			log_info("core_image", "Tags for Image #{$this->id} set to: ".implode(" ", $tags), null, array("image_id" => $this->id));
			$database->cache->delete("image-{$this->id}-tags");
		}
	}

	/**
	 * Send list of metatags to be parsed.
	 *
	 * @param string[] $metatags
	 * @param int $image_id
	 */
	public function parse_metatags($metatags, $image_id) {
		foreach($metatags as $tag) {
			$ttpe = new TagTermParseEvent($tag, $image_id, TRUE);
			send_event($ttpe);
		}
	}

	/**
	 * Delete this image from the database and disk
	 */
	public function delete() {
		global $database;
		$this->delete_tags_from_image();
		$database->execute("DELETE FROM images WHERE id=:id", array("id"=>$this->id));
		log_info("core_image", 'Deleted Image #'.$this->id.' ('.$this->hash.')', false, array("image_id" => $this->id));

		unlink($this->get_image_filename());
		unlink($this->get_thumb_filename());
	}

	/**
	 * This function removes an image (and thumbnail) from the DISK ONLY.
	 * It DOES NOT remove anything from the database.
	 */
	public function remove_image_only() {
		log_info("core_image", 'Removed Image File ('.$this->hash.')', false, array("image_id" => $this->id));
		@unlink($this->get_image_filename());
		@unlink($this->get_thumb_filename());
	}

	/**
	 * Someone please explain this
	 *
	 * @param string $tmpl
	 * @param string $_escape
	 * @return string
	 */
	public function parse_link_template($tmpl, $_escape="url_escape", $n=0) {
		global $config;

		// don't bother hitting the database if it won't be used...
		$tags = "";
		if(strpos($tmpl, '$tags') !== false) { // * stabs dynamically typed languages with a rusty spoon *
			$tags = $this->get_tag_list();
			$tags = str_replace("/", "", $tags);
			$tags = preg_replace("/^\.+/", "", $tags);
		}

		$base_href = $config->get_string('base_href');
		$fname = $this->get_filename();
		$base_fname = strpos($fname, '.') ? substr($fname, 0, strrpos($fname, '.')) : $fname;

		$tmpl = str_replace('$id',   $this->id,   $tmpl);
		$tmpl = str_replace('$hash_ab', substr($this->hash, 0, 2), $tmpl);
		$tmpl = str_replace('$hash_cd', substr($this->hash, 2, 2), $tmpl);
		$tmpl = str_replace('$hash', $this->hash, $tmpl);
		$tmpl = str_replace('$tags', $_escape($tags),  $tmpl);
		$tmpl = str_replace('$base', $base_href,  $tmpl);
		$tmpl = str_replace('$ext',  $this->ext,  $tmpl);
		$tmpl = str_replace('$size', "{$this->width}x{$this->height}", $tmpl);
		$tmpl = str_replace('$filesize', to_shorthand_int($this->filesize), $tmpl);
		$tmpl = str_replace('$filename', $_escape($base_fname), $tmpl);
		$tmpl = str_replace('$title', $_escape($config->get_string("title")), $tmpl);
		$tmpl = str_replace('$date', $_escape(autodate($this->posted, false)), $tmpl);

		// nothing seems to use this, sending the event out to 50 exts is a lot of overhead
		if(!SPEED_HAX) {
			$plte = new ParseLinkTemplateEvent($tmpl, $this);
			send_event($plte);
			$tmpl = $plte->link;
		}

		static $flexihash = null;
		static $fh_last_opts = null;
		$matches = array();
		if(preg_match("/(.*){(.*)}(.*)/", $tmpl, $matches)) {
			$pre = $matches[1];
			$opts = $matches[2];
			$post = $matches[3];

			if($opts != $fh_last_opts) {
				$fh_last_opts = $opts;
				$flexihash = new Flexihash\Flexihash();
				foreach(explode(",", $opts) as $opt) {
					$parts = explode("=", $opt);
					$parts_count = count($parts);
					$opt_val = "";
					$opt_weight = 0;
					if($parts_count === 2) {
						$opt_val = $parts[0];
						$opt_weight = $parts[1];
					}
					elseif($parts_count === 1) {
						$opt_val = $parts[0];
						$opt_weight = 1;
					}
					$flexihash->addTarget($opt_val, $opt_weight);
				}
			}

			// $choice = $flexihash->lookup($pre.$post);
			$choices = $flexihash->lookupList($this->hash, $n+1);  // hash doesn't change
			$choice = $choices[$n];
			$tmpl = $pre.$choice.$post;
		}

		return $tmpl;
	}

	/**
	 * @param string[] $terms
	 * @return \Querylet
	 */
	private static function build_search_querylet(array $terms): Querylet {
		global $database;

		$tag_querylets = array();
		$img_querylets = array();
		$positive_tag_count = 0;
		$negative_tag_count = 0;

		/*
		 * Turn a bunch of strings into a bunch of TagQuerylet
		 * and ImgQuerylet objects
		 */
		$stpe = new SearchTermParseEvent(null, $terms);
		send_event($stpe);
		if ($stpe->is_querylet_set()) {
			foreach ($stpe->get_querylets() as $querylet) {
				$img_querylets[] = new ImgQuerylet($querylet, true);
			}
		}

		foreach ($terms as $term) {
			$positive = true;
			if (is_string($term) && !empty($term) && ($term[0] == '-')) {
				$positive = false;
				$term = substr($term, 1);
			}
			if (strlen($term) === 0) {
				continue;
			}

			$stpe = new SearchTermParseEvent($term, $terms);
			send_event($stpe);
			if ($stpe->is_querylet_set()) {
				foreach ($stpe->get_querylets() as $querylet) {
					$img_querylets[] = new ImgQuerylet($querylet, $positive);
				}
			}
			else {
				// if the whole match is wild, skip this;
				// if not, translate into SQL
				if(str_replace("*", "", $term) != "") {
					$term = str_replace('_', '\_', $term);
					$term = str_replace('%', '\%', $term);
					$term = str_replace('*', '%', $term);
					$tag_querylets[] = new TagQuerylet($term, $positive);
					if ($positive) $positive_tag_count++;
					else $negative_tag_count++;
				}
			}
		}

		/*
		 * Turn a bunch of Querylet objects into a base query
		 *
		 * Must follow the format
		 *
		 *   SELECT images.*
		 *   FROM (...) AS images
		 *   WHERE (...)
		 *
		 * ie, return a set of images.* columns, and end with a WHERE
		 */

		// no tags, do a simple search
		if($positive_tag_count === 0 && $negative_tag_count === 0) {
			$query = new Querylet("
				SELECT images.*
				FROM images
				WHERE 1=1
			");
		}

		// one positive tag (a common case), do an optimised search
		else if($positive_tag_count === 1 && $negative_tag_count === 0) {
			# "LIKE" to account for wildcards
			$query = new Querylet($database->scoreql_to_sql("
				SELECT *
				FROM (
					SELECT images.*
					FROM images
					JOIN image_tags ON images.id=image_tags.image_id
					JOIN tags ON image_tags.tag_id=tags.id
					WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag)
					GROUP BY images.id
				) AS images
				WHERE 1=1
			"), array("tag"=>$tag_querylets[0]->tag));
		}

		// more than one positive tag, or more than zero negative tags
		else {
			if($database->get_driver_name() === "mysql")
				$query = Image::build_ugly_search_querylet($tag_querylets);
			else
				$query = Image::build_accurate_search_querylet($tag_querylets);
		}

		/*
		 * Merge all the image metadata searches into one generic querylet
		 * and append to the base querylet with "AND blah"
		 */
		if(!empty($img_querylets)) {
			$n = 0;
			$img_sql = "";
			$img_vars = array();
			foreach ($img_querylets as $iq) {
				if ($n++ > 0) $img_sql .= " AND";
				if (!$iq->positive) $img_sql .= " NOT";
				$img_sql .= " (" . $iq->qlet->sql . ")";
				$img_vars = array_merge($img_vars, $iq->qlet->variables);
			}
			$query->append_sql(" AND ");
			$query->append(new Querylet($img_sql, $img_vars));
		}

		return $query;
	}

	/**
	 * WARNING: this description is no longer accurate, though it does get across
	 * the general idea - the actual method has a few extra optimisations
	 *
	 * "foo bar -baz user=foo" becomes
	 *
	 * SELECT * FROM images WHERE
	 *           images.id IN (SELECT image_id FROM image_tags WHERE tag='foo')
	 *   AND     images.id IN (SELECT image_id FROM image_tags WHERE tag='bar')
	 *   AND NOT images.id IN (SELECT image_id FROM image_tags WHERE tag='baz')
	 *   AND     images.id IN (SELECT id FROM images WHERE owner_name='foo')
	 *
	 * This is:
	 *   A) Incredibly simple:
	 *      Each search term maps to a list of image IDs
	 *   B) Runs really fast on a good database:
	 *      These lists are calculated once, and the set intersection taken
	 *   C) Runs really slow on bad databases:
	 *      All the subqueries are executed every time for every row in the
	 *      images table. Yes, MySQL does suck this much.
	 *
	 * @param TagQuerylet[] $tag_querylets
	 * @return Querylet
	 */
	private static function build_accurate_search_querylet(array $tag_querylets): Querylet {
		global $database;

		$positive_tag_id_array = array();
		$negative_tag_id_array = array();

		foreach ($tag_querylets as $tq) {
			$tag_ids = $database->get_col(
				$database->scoreql_to_sql("
					SELECT id
					FROM tags
					WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag)
				"),
				array("tag" => $tq->tag)
			);
			if ($tq->positive) {
				$positive_tag_id_array = array_merge($positive_tag_id_array, $tag_ids);
				if (count($tag_ids) == 0) {
					# one of the positive tags had zero results, therefor there
					# can be no results; "where 1=0" should shortcut things
					return new Querylet("
						SELECT images.*
						FROM images
						WHERE 1=0
					");
				}
			} else {
				$negative_tag_id_array = array_merge($negative_tag_id_array, $tag_ids);
			}
		}

		assert('$positive_tag_id_array || $negative_tag_id_array', @$_GET['q']);
		$wheres = array();
		if (!empty($positive_tag_id_array)) {
			$positive_tag_id_list = join(', ', $positive_tag_id_array);
			$wheres[] = "tag_id IN ($positive_tag_id_list)";
		}
		if (!empty($negative_tag_id_array)) {
			$negative_tag_id_list = join(', ', $negative_tag_id_array);
			$wheres[] = "tag_id NOT IN ($negative_tag_id_list)";
		}
		$wheres_str = join(" AND ", $wheres);
		return new Querylet("
			SELECT images.*
			FROM images
			WHERE images.id IN (
				SELECT image_id
				FROM image_tags
				WHERE $wheres_str
				GROUP BY image_id
				HAVING COUNT(image_id) >= :search_score
			)
		", array("search_score"=>count($positive_tag_id_array)));
	}

	/**
	 * this function exists because mysql is a turd, see the docs for
	 * build_accurate_search_querylet() for a full explanation
	 *
	 * @param TagQuerylet[] $tag_querylets
	 * @return Querylet
	 */
	private static function build_ugly_search_querylet($tag_querylets) {
		global $database;

		$positive_tag_count = 0;
		foreach($tag_querylets as $tq) {
			if($tq->positive) $positive_tag_count++;
		}

		// only negative tags - shortcut to fail
		if($positive_tag_count == 0) {
			// TODO: This isn't currently implemented.
			// SEE: https://github.com/shish/shimmie2/issues/66
			return new Querylet("
				SELECT images.*
				FROM images
				WHERE 1=0
			");
		}

		// merge all the tag querylets into one generic one
		$sql = "0";
		$terms = array();
		foreach($tag_querylets as $tq) {
			$sign = $tq->positive ? "+" : "-";
			$sql .= ' '.$sign.' IF(SUM(tag LIKE :tag'.Image::$tag_n.'), 1, 0)';
			$terms['tag'.Image::$tag_n] = $tq->tag;
			Image::$tag_n++;
		}
		$tag_search = new Querylet($sql, $terms);

		$tag_id_array = array();

		foreach($tag_querylets as $tq) {
			$tag_ids = $database->get_col(
				$database->scoreql_to_sql("
					SELECT id
					FROM tags
					WHERE SCORE_STRNORM(tag) LIKE SCORE_STRNORM(:tag)
				"),
				array("tag" => $tq->tag)
			);
			$tag_id_array = array_merge($tag_id_array, $tag_ids);

			if($tq->positive && count($tag_ids) == 0) {
				# one of the positive tags had zero results, therefor there
				# can be no results; "where 1=0" should shortcut things
				return new Querylet("
					SELECT images.*
					FROM images
					WHERE 1=0
				");
			}
		}

		Image::$tag_n = 0;
		return new Querylet('
			SELECT *
			FROM (
				SELECT images.*, ('.$tag_search->sql.') AS score
				FROM images
				LEFT JOIN image_tags ON image_tags.image_id = images.id
				JOIN tags ON image_tags.tag_id = tags.id
				WHERE tags.id IN (' . join(', ', $tag_id_array) . ')
				GROUP BY images.id
				HAVING score = :score
			) AS images
			WHERE 1=1
		', array_merge(
			$tag_search->variables,
			array("score"=>$positive_tag_count)
		));
	}
}