<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\models;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\UrlParser;

/**
 * This class manages the editing of search verticals. This includes
 * allowing one to specify a search result should be filtered from
 * the results of a query, it also includes alterning the title and description
 * of a result from how it is stored in a particular index and it finally
 * includes creating, updating, deleting knowledge wiki results
 * To handle these activities this class leverages the existing group
 * wiki system of Yioop. Edited and filtered search results correspond
 * to group feed entries in a Search Group. Edited knowledge wiki entries
 * correspond to wiki entries in the Search Group.
 *
 * @author Chris Pollett
 */
class SearchverticalsModel extends GroupModel
{
    /**
     * Used to hold an in_memory cache of what search results are to be
     * filtered
     *
     * @var array
     */
    public $memory_filter = [];
    /**
     * Used to hold an in memory copy of the timestamp of the last time
     * a search result was altered
     *
     * @var int
     */
    public $last_change = null;
    /**
     * Check if a URL is supposed to be filtered from search results.
     *
     * @param string $url to see if filtered
     * @param bool $compute_hash_key flag to control hashing before computing
     *     GROUP_ITEM lookup. Sometimes $url might be already the hash of
     *      a URL to check if filtered
     * @return bool whether the url should be filtered from search results or
     *      not
     */
    public function isFiltered($url, $compute_hash_key = false)
    {
        if (!empty($this->query_filter)) {
            if (in_array($url, $this->query_filter)) {
                return false;
            }
        }
        if ($compute_hash_key) {
            $host = UrlParser::getHost($url);
        } else {
            $host = $url;
        }
        if (isset($this->memory_filter[$host])) {
            return $this->memory_filter[$host];
        } else {
            if (count($this->memory_filter) > 2 * C\p('MIN_RESULTS_TO_GROUP')) {
                $this->memory_filter = [];
            }
            list($parent_id, $user_id) = $this->hashIntPair($host,
                $compute_hash_key);
            $db = $this->db;
            $sql = "SELECT * FROM GROUP_ITEM WHERE PARENT_ID=? AND " .
                "USER_ID = ? AND GROUP_ID = " . C\SEARCH_GROUP_ID .
                " AND TYPE = ". C\SEARCH_FILTER_GROUP_ITEM . " " .
                $db->limitOffset(1);
            $result = $db->execute($sql, [$parent_id, $user_id]);
            $this->memory_filter[$host] = true;
            if (!$result) {
                $this->memory_filter[$host] = false;
            } else {
                $row = $db->fetchArray($result);
                if (!$row) {
                    $this->memory_filter[$host] = false;
                }
            }
            return $this->memory_filter[$host];
        }
    }
    /**
     * Returns the timestamp of the last time any of the search results
     * were edited.
     *
     * @return int timestamp of last edited search results
     */
    public function lastChange()
    {
        if ($this->last_change != null) {
            return $this->last_change;
        }
        $db = $this->db;
        $sql = "SELECT MAX(PUBDATE) AS LAST_CHANGE FROM GROUP_ITEM";
        $result = $db->execute($sql);
        if (!$result) {
            return 0;
        } else {
            $row = $db->fetchArray($result);
            if (!$row) {
                return 0;
            }
        }
        $this->last_change = $row['LAST_CHANGE'];
        return $this->last_change;
    }
    /**
     * Computes a hash value as an ordered pair of ints. Used to store
     * filtered urls into the GROUP_ITEM table. In this situation,
     * the ordered pair is used later for the PARENT_ID and USER_ID in the
     * (both of which have indexes) Search group look-up.
     *
     * @param string $input to be hash to a pair of integers
     * @param bool $compute_hash flag to chheck if a crawlHash is done
     *  before converting the result to an ordered pair. In some situations
     *  the url or host of the url has already been hashed so don't want
     *  to hash it again.
     * @return array [int, int] that corresponds to the hash of the input
     *  to keep postgres happy (no unsigned ints) we make the value
     *  of this function signed
     */
    public function hashIntPair($input, $compute_hash = true)
    {
        if ($compute_hash) {
            $input = rtrim($input, "/");
            $hash = substr(L\crawlHash($input . "/", true), 1);
        } else {
            $hash = $input;
        }
        $front = abs(L\unpackInt(substr($hash, 0, 4))) - 2147483648;
        $back = abs(L\unpackInt(substr($hash, 4, 4))) - 2147483648;
        return [$front, $back];
    }
    /**
     * Given a $query and a $locale_tag returns a ordered sets
     * of urls to put at the top of the search results for that query
     * if such a map has been defined.
     *
     * @param string $query user supplied query
     * @param string $locale_tag language that the lookup of urls should
     *  be done for
     * @return array of urls that correspond to the query
     */
    public function getQueryMap($query, $locale_tag)
    {
        $db = $this->db;
        list($parent_id, $user_id) =  $this->hashIntPair("$query $locale_tag");
        $sql = "SELECT URL FROM GROUP_ITEM " .
            "WHERE GROUP_ID = " . C\SEARCH_GROUP_ID . " AND PARENT_ID=? AND " .
            "USER_ID = ? AND TYPE= '" . C\QUERY_MAP_GROUP_ITEM . "' ".
            "ORDER BY TITLE";
        $result = $db->execute($sql, [$parent_id, $user_id]);
        if (!$result) {
            return [];
        }
        $map_urls = [];
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                $map_urls[] = trim($row['URL']);
            }
        }
        return $map_urls;
    }
    /**
     * Stores a query map into the public database.
     * A query map associate a $query in a $locale_tag language to a set of
     * urls desired to be at the top of the search results.
     * @param string $query that triggers the mapping
     * @param array $map_urls urls that should appear at the top of the search
     *      results
     * @param string $locale_tag for the language the map should apply to
     */
    public function setQueryMap($query, $map_urls, $locale_tag)
    {
        $db = $this->db;
        list($parent_id, $user_id) =  $this->hashIntPair("$query $locale_tag");
        $delete_sql = "DELETE FROM GROUP_ITEM WHERE GROUP_ID = " .
            C\SEARCH_GROUP_ID . " AND PARENT_ID=? AND " .
            "USER_ID = ? AND TYPE= '" . C\QUERY_MAP_GROUP_ITEM . "'";
        $time = time();
        $db->execute($delete_sql, [$parent_id, $user_id]);
        $i = 0;
        foreach($map_urls as $url) {
            $out_num = sprintf("%'.04d", $i);
            $id = $this->addGroupItem($parent_id, C\SEARCH_GROUP_ID, $user_id,
                "$out_num", "", C\QUERY_MAP_GROUP_ITEM, $time, trim($url));
            $i++;
        }
    }
    /**
     * Get the knowledge wiki page corresponding to a search query.
     * This is used in wiki read mode for search result verticals or edit mode
     * (the wiki info is not pre-parsed) for editing the knowledge wiki page.
     *
     * @param string $query to get knowledge wiki results for
     * @param string $locale_tag the locale tag language that one want the
     *  results for
     * @param bool $edit_mode whether the wiki page should be pre-parsed
     *  suitable for display in query results) or left unparsed (suitable
     *   for editing).
     * @return array knowledge wiki page info
     */
    public function getKnowledgeWiki($query, $locale_tag, $edit_mode = false)
    {
        $db = $this->db;
        $query = mb_strtolower(preg_replace("/-|\s/u", "_", $query));
        if ($edit_mode) {
            $row = $this->getPageInfoByName(C\SEARCH_GROUP_ID, $query,
                $locale_tag, "edit");
        } else {
            $sql = "SELECT ID, PAGE, DISCUSS_THREAD FROM GROUP_PAGE " .
                "WHERE GROUP_ID = " . C\SEARCH_GROUP_ID . " AND " .
                "TITLE = ? AND LOCALE_TAG = ? ". $db->limitOffset(0, 1);
            $result = $db->execute($sql, ["$query", $locale_tag]);
            if (!$result) {
                return false;
            }
            $row = $db->fetchArray($result);
            if (!$row) {
                return false;
            }
        }
        return $row;
    }
    /**
     * Delete the knowledge wiki pages associated with a given locale
     *
     * @param string $locale_tag locale to delete knowledge wiki pages for
     */
    public function deleteKnowledgeWiki($locale_tag)
    {
        $db = $this->db;
        $sql = "SELECT ID FROM GROUP_PAGE WHERE GROUP_ID=" . C\SEARCH_GROUP_ID .
            " AND LOCALE_TAG=?";
        $params = [$locale_tag];
        $result = $db->execute($sql, $params);
        if ($result) {
            while ($row = $db->fetchArray($result)) {
                list($folder, $thumb_folder, ) =
                    $this->getGroupPageResourcesFolders(C\SEARCH_GROUP_ID,
                    $row['ID'], "", false, false);
                if (!empty($folder) && file_exists($folder)) {
                    $db->unlinkRecursive($folder);
                }
                if (!empty($thumb_folder) && file_exists($thumb_folder)) {
                    $db->unlinkRecursive($thumb_folder);
                }
            }
        }
        $sql = "DELETE FROM GROUP_PAGE WHERE GROUP_ID=" . C\SEARCH_GROUP_ID .
            " AND LOCALE_TAG=?";
        $db->execute($sql, $params);
        $sql = "DELETE FROM GROUP_PAGE_HISTORY WHERE GROUP_ID=" .
            C\SEARCH_GROUP_ID . " AND LOCALE_TAG=?";
        $db->execute($sql, $params);
    }
    /**
     * Updates the title and description text that will be presented
     * when a given url appears in search results
     * @param int $id if the url has been edited previous then the id of the
     *      group item with the edit. If this is 0/empty then a new group item
     *      for the edit is created. If -1 then deletes the entry
     * @param int $type either SEARCH_FILTER_GROUP_ITEM or
     *  SEARCH_EDIT_GROUP_ITEM
     * @param string $url to change search result for
     * @param string $title new title for search result
     * @param string $description new snippet text for search result
     * @return mixed integer id of edited/created result or if used
     *      to delete then false
     */
    function updateUrlResult($id, $type, $url, $title, $description)
    {
        $db = $this->db;
        $this->last_change = time();
        if ($type == C\SEARCH_FILTER_GROUP_ITEM) {
            $url =  UrlParser::getHost($url);
        }
        list($parent_id, $user_id) = $this->hashIntPair($url);
        if (empty($id)) {
            $id = $this->addGroupItem($parent_id, C\SEARCH_GROUP_ID, $user_id,
                $title, $description, $type, $this->last_change, $url);
        } else {
            $item = $this->getEditedPageResult($url);
            $sql = "DELETE FROM GROUP_ITEM  WHERE ID = ?";
            $db->execute($sql, [$id]);
            unset($id);
            if (!empty($item['TYPE']) &&
                $item['TYPE'] == C\SEARCH_FILTER_GROUP_ITEM) {
                $sql = "DELETE FROM GROUP_ITEM  WHERE URL = ?";
                $db->execute($sql, [$url . "/"]);
            }
            $id = $this->addGroupItem($parent_id, C\SEARCH_GROUP_ID, $user_id,
                $title, $description, $type, $this->last_change, $url);
        }
        return $id ?? false;
    }
    /**
     * Returns any edited search result associated with a url
     *
     * @param string $url url to get edit search results for
     * @return mixed either false if no edited search results or an
     *  associative array containing edited results if they exist
     */
    function getEditedPageResult($url)
    {
        $db = $this->db;
        $host = UrlParser::getHost($url);
        list($parent_id, $user_id) = $this->hashIntPair($host);
        $sql = "SELECT ID, TITLE, URL, DESCRIPTION, PUBDATE, EDIT_DATE, TYPE " .
            "AS URL_ACTION FROM GROUP_ITEM " .
            "WHERE GROUP_ID = " . C\SEARCH_GROUP_ID . " AND PARENT_ID=? AND " .
            "USER_ID = ? ". $db->limitOffset(0, 1);
        $result = $db->execute($sql, [$parent_id, $user_id]);
        if ($result) {
            $row = $db->fetchArray($result);
            if ($row && $row['URL_ACTION'] == C\SEARCH_FILTER_GROUP_ITEM) {
                return $row;
            }
        }
        list($parent_id, $user_id) = $this->hashIntPair($url);
        $result = $db->execute($sql, [$parent_id, $user_id]);
        if (!$result) {
            return false;
        }
        $row = $db->fetchArray($result);
        if (!$row) {
            return false;
        }
        return $row;
    }
    /**
     * Given an array page summaries, for each summary check if the
     * url corresponds to a search result that was human edited, if so,
     * replace and format it.
     *
     * @param array $results web pages summaries (these in turn are
     *     arrays!)
     * @param array $words keywords (typically what was searched on)
     * @param int $description_length length of the description
     * @return array summaries which have been snippified and bold faced
     */
    public function incorporateEditedPageResults($results, $words = null,
        $description_length = self::DEFAULT_DESCRIPTION_LENGTH)
    {
        if (isset($results['PAGES'])) {
            $pages = $results['PAGES'];
            $num_pages = count($pages);
        } else {
            $output['TOTAL_ROWS'] = 0;
            $output['PAGES'] = null;
            return;
        }
        $deleted_a_page = false;
        for ($i = 0; $i < $num_pages; $i++) {
            $page = $pages[$i];
            if (!isset($page[self::URL])) {
                unset($pages[$i]);
                $deleted_a_page = true;
                continue;
            }
            $url_parts = explode("|", $page[self::URL]);
            if (count($url_parts) > 1) {
                $url = trim($url_parts[1]);
            } else {
                $url = $page[self::URL];
            }
            if (($summary = $this->getEditedPageResult($url)) &&
                !empty($summary["URL_ACTION"]) && $summary["URL_ACTION"] > 0) {
                $page[self::URL] = $url;
                foreach ([self::TITLE => "TITLE",
                    self::DESCRIPTION => "DESCRIPTION"] as
                    $result_field => $edit_field) {
                    if (isset($summary[$edit_field])) {
                        $page[$result_field] = $summary[$edit_field];
                    }
                }
                $page = $this->formatSinglePageResult($page, $words,
                    self::DEFAULT_DESCRIPTION_LENGTH);
                $pages[$i] = $page;
            }
        }
        $output['TOTAL_ROWS'] = $results['TOTAL_ROWS'];
        $output['PAGES'] = ($deleted_a_page) ? $pages : array_values($pages);
        return $output;
    }
}
