<?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\library;

use seekquarry\yioop\configs as C;

/**  For remoteAddress and crawlHash function and Yioop constants */
require_once __DIR__ . "/../library/Utility.php";
/**
 * Encapsulates the temporary storage of messages sent between QueueServers
 * and Fetchers during the course of a Crawl
 *
 * @author Chris Pollett
 */
class MessagesBundle implements CrawlConstants
{
    /**
    * Reference to a database object. Used since has directory manipulation
    * functions
    * @var object
     */
    public $db;
    /**
     * Largest sequence number currently in use for sliding window protocol
     * messages used for fetch batches of urls and downloaded web page
     * for those urls  responses
     * @var int
     */
    public $max_sequence_number;
    /**
     * Contains sequence_number => filename associations about which fetch
     * batches have been successfully  downloaded, received and should be
     * moved for further processing. These will be moved once all earlier
     * sequence numbers have been similarly received.
     * @var array
     */
    public $receive;
    /**
     * Contains sequence_number => [filename, schedule_time, tries] associations
     * for fetch batches of urls to crawl which have been produced. Here
     * filename is the name of the file that has the data for the batch,
     * schedule_time is the last time the batch was scheduled to a Fetcher,
     * and tries is the number of times it has been scheduled.
     * @var array
     */
    public $send;
    /**
     * String prefix to be used before filenames of message files in the
     * ETAG_EXPIRES_FOLDER, INDEX_DATA_FOLDER, ROBOTS_FOLDER, and
     * SCHEDULES_FOLDER folders.
     */
    const MESSAGE_PREFIX = "At";
    /**
     * Folder name for the folder within the MessageBundle folder used to
     * contain files with etag and expires header information waiting to be
     * processed by the scheduler
     */
    const ETAG_EXPIRES_FOLDER = "etag_expires";
    /**
    * Folder name for the folder within the MessageBundle folder used to
    * contain files with web page summaries and downloaded page information
    * waiting to be by the indexer (i.e., to make an inverted index)
    * processed
    */
    const INDEX_DATA_FOLDER = "index_data";
    /**
     * Folder name for the folder within the MessageBundle folder used to
     * contain files with robots.txt information waiting to be
     * processed by the scheduler
     */
    const ROBOTS_FOLDER = "robots";
    /**
    * Folder name for the folder within the MessageBundle folder used to
    * contain files with to crawl url information waiting to be
    * processed by the scheduler
    */
    const SCHEDULES_FOLDER = "schedules";
    /**
     * Makes a MessagesBundle with the provided parameters. This method
     * does not initialize send and receive properties related to
     * the fetch batch window.
     *
     * @param string $dir_name folder name used by this MessagesBundle
     * @param int $window_size number of outstanding url fetch batch
     *      files to allow before stop accepting new ones; used in a
     *      crawl to set the maximum number of schedules generated at
     *      a time. In the QueueServer the window size is set to be
     *      the total number of currently active fetchers
     */
    public function __construct(public $dir_name,
        public $window_size = 1)
    {
        if (!file_exists($dir_name)) {
            mkdir($dir_name);
            set_error_handler(null);
            @chmod($dir_name, 0777);
            restore_error_handler();
        }
        $db_class = C\NS_DATASOURCES . ucfirst(C\p('DBMS')) . "Manager";
        $this->db = new $db_class();
    }
    /**
     * Use to intialize the send and receiver property arrays for the
     * send and receive fetch batch window.
     *
     * @param bool $reset_schedule_times whether to reset the schedule
     * times of each batch current in the send folder, but not acknowledged
     * to 0, forcing them to eb rescheduled.
     */
    public function initWindows($reset_schedule_times = false)
    {
        $receive_dir = $this->dir_name . "/receive";
        $send_dir = $this->dir_name . "/send";
        $max_sequence_number = 0;
        foreach (['receive' => $receive_dir, 'send' => $send_dir] as
            $window_name => $dir) {
            if (!file_exists($dir)) {
                mkdir($dir);
                set_error_handler(null);
                @chmod($dir, 0777);
                restore_error_handler();
            }
            $files = glob("$dir/*.txt");
            $window_data = [];
            $sequence_number = 0;
            $len_prefix = strlen("$dir/");
            $len_suffix = strlen(".txt");
            foreach ($files as $file) {
                $base_name = substr($file, $len_prefix, -$len_suffix);
                $base_parts = explode("-", $base_name);
                $expected_num_parts = ($window_name == 'receive') ? 1 : 3;
                if (count($base_parts) != $expected_num_parts) {
                    continue;
                }
                $sequence_number = intval($base_parts[0]);
                if ($sequence_number > $max_sequence_number) {
                    $max_sequence_number = $sequence_number;
                }
                if ($window_name == 'send') {
                    list(, $schedule_time, $tries) = $base_parts;
                    $item = ['file' => $file,
                        'schedule_time' => intval($schedule_time),
                        'tries' => intval($tries)];
                    if ($reset_schedule_times) {
                        $schedule_time = 0;
                        $item['file'] = $this->makeSendWindowFilename(
                            $sequence_number, $schedule_time, $tries);
                        rename($file, $item['file']);
                    }
                } else {
                    $item = $file;
                }
                $window_data[$sequence_number] = $item;
            }
            $this->$window_name = $window_data;
        }
        $this->max_sequence_number = $max_sequence_number;
    }
    /**
     * Returns where the send folder (containing fetch batches which have been
     * received  but not yet downloaded) can hold another fetch batch given
     * the window size.
     * @return bool true if it can hold another fetch batch, fall otherwise
     */
    public function isSendFull()
    {
        return (count($this->receive) + count($this->send) >=
            $this->window_size);
    }
    /**
     * Returns the data from the least sequence numbered file in the send
     * window that either has not previously been returned (tries 0) or
     * that has been previously returned but more than timeout seconds earlier
     * In both cases the try count of the file is bumped. If a file is
     * found in the search process with a try count above
     * MAX_RESCHEDULE_ATTEMPTS, it is moved instead to the receive window
     * but with no data received.
     *
     * @return string file contents of found file
     */
    public function extractSendWindow()
    {
        ksort($this->send);
        $data = false;
        $time = time();
        foreach($this->send as $sequence_number => $item) {
            $tries = $item['tries'];
            $timeout = ($item['schedule_time'] + C\SCHEDULE_TIMEOUT) < $time;
            if($item['tries'] == 0 || ($timeout &&
                $tries < C\MAX_RESCHEDULE_ATTEMPTS)) {
                $data = file_get_contents($item['file']);
                $tries++;
                $this->send[$sequence_number]['tries'] = $tries;
                $schedule_time = ($timeout) ? $time : $item['schedule_time'];
                $filename = $this->makeSendWindowFilename(
                    $sequence_number, $schedule_time, $tries);
                clearstatcache();
                if (file_exists($item['file'])) {
                    rename($item['file'], $filename);
                }
                $this->send[$sequence_number]['file'] = $filename;
                break;
            } else if ($timeout) {
                $this->updateReceiveWindow($sequence_number, "");
            }
        }
        return $data;
    }
    /**
     * Add a fetch batch to the send folder of the messages bundle if there
     * space in the folder given the current window size
     * @param string $tmp_send_filename filename of the file with the
     *  fetch batch. If this operation succeeds, the file at this location
     *  will be moved to the send folder
     * @return bool whether operation was successful
     */
    public function insertSendWindow($tmp_send_filename)
    {
        if ($this->isSendFull()) {
            return false;
        }
        $schedule_time = time();
        $tries = 0;
        $this->max_sequence_number++;
        $sequence_number = $this->max_sequence_number;
        $filename = $this->makeSendWindowFilename($sequence_number,
            $schedule_time, $tries);
        rename($tmp_send_filename, $filename);
        set_error_handler(null);
        @chmod($filename, 0777);
        restore_error_handler();
        $this->send[$sequence_number] = ['file' => $filename,
            'schedule_time' => $schedule_time, 'tries' => $tries];
        return true;
    }
    /**
     * Used to output the filename of a file storing a fetch batch of urls in
     * the  send folder
     * @param int $sequence_number sequence number used for sliding window
     *  algorithm
     * @param int $schedule_time time at which the given file was last scheduled
     *  for download
     * @param int $tries number of times a fetcher has been scheduled to try
     *  to download the given list of urls
     * @return string the filename to use for the fetch batch with the given
     *  parameters
     */
    public function makeSendWindowFilename($sequence_number, $schedule_time,
        $tries)
    {
        return $this->dir_name .
            "/send/{$sequence_number}-$schedule_time-$tries.txt";
    }
    /**
     * Used to output the filename of a file returned from a fetcher containing
     * downloaded pages for a set of urls
     *
     * @param int $sequence_number sequence number used for sliding window
     *  algorithm
     * @return string the filename to use for the fetch batch with the given
     *  parameters
     */
    public function makeReceiveWindowFilename($sequence_number)
    {
        return $this->dir_name . "/receive/{$sequence_number}.txt";
    }
    /**
     * Adds a new messages file to the received subfolder of the
     * MessagesBundle with the given sequence number and containing the
     * passed data
     *
     * @param int $sequence_number sequence number to use in making the
     *  filename of the new file to add to the receive folder
     * @param string $data message contents of the file to add
     */
    public function updateReceiveWindow($sequence_number, $data)
    {
        if (empty($this->send[$sequence_number])) {
            return;
        }
        $target_name = $this->makeReceiveWindowFilename($sequence_number);
        file_put_contents($target_name, $data);
        set_error_handler(null);
        @chmod($target_name, 0777);
        restore_error_handler();
        $this->receive[$sequence_number] = $target_name;
        unlink($this->send[$sequence_number]['file']);
        unset($this->send[$sequence_number]);
    }
    /**
     * Searches for a file is in the receive subfolder of this MessagesBundle
     * of least sequence number. If there is no file in the send subfolder
     * of the MessagesBundle with smaller sequence number, then the file
     * is read, deleted and its contents returned. Otherwise, this function
     * returns false.
     *
     * @return string|bool Data of found file or file
     */
    public function extractReceiveWindow()
    {
        ksort($this->send);
        $first_send_number = array_key_first($this->send);
        ksort($this->receive);
        $first_receive_number = array_key_first($this->receive);
        if ($first_send_number !== null &&
            $first_send_number < $first_receive_number) {
            return false;
        }
        $filename = $this->receive[$first_receive_number] ?? "";
        clearstatcache();
        if (!empty($filename) && file_exists($filename)) {
            $data = file_get_contents($filename);
            set_error_handler(null);
            @unlink($filename);
            restore_error_handler();
        } else {
            $data = false;
        }
        unset($this->receive[$first_receive_number]);
        return $data;
    }
    /**
     * Adds a file with contents $data and with name containing $address and
     * $time to a subfolder $day in the folder $messages_type subfolder of
     * the MessagesBundle
     *
     * @param string $messages_type the kind of messages being saved
     * @param string &$data_string encoded, compressed, serialized data the
     *     schedule is to contain
     * @param int $time Unix timestamp used to derive the day-bucket
     *     subfolder and the per-file timestamp; 0 substitutes time()
     */
    public function addMessages($messages_type, &$data_string, $time = 0)
    {
        $dir = $this->dir_name . "/$messages_type";
        $address = strtr(remoteAddress(), ["." => "-", ":" => "_"]);
        $time = ($time == 0) ? time() : $time;
        $day = floor($time/C\ONE_DAY);
        if (!file_exists($dir)) {
            mkdir($dir);
            chmod($dir, 0777);
        }
        $dir .= "/$day";
        if (!file_exists($dir)) {
            mkdir($dir);
            chmod($dir, 0777);
        }
        $data_hash = crawlHash($data_string);
        file_put_contents($dir . "/" . self::MESSAGE_PREFIX . $time .
            "From" . $address . "WithHash$data_hash.txt", $data_string);
    }
    /**
     * Get the next messages file by timestamp stored in the MessagesBundle
     * subfolder of of the given type (etag_expires, robots, schedules,
     * index_data) and either return it as a string or as a deserialized array
     * or object. This subfolder is typically organized into days subsubfolders
     * with the appropriately timestamped files in a given days folder.
     * After reading its data, but before returning it from
     * this method the file itself is deleted.
     *
     * @param string $type kind of data to look for messages about. This
     *  kind should correspond to a folder in the messages bundle. Typically,
     *  these folders will be (etag_expires, robots, schedules, index_data)
     * @param bool $unpack whether to unpack (decode/gzunzip/wddx_deserialize)
     *  the first found file and return its data, or just return its string
     *  contents after webdecode them.
     * @param string $message_prefix prefix string of filenames in
     *  given folder (etag_expires, robots, schedules, index_data) that have
     *  messages data
     * @return (string|array|object)?
     */
    public function nextMessages($type, $unpack = true, $message_prefix =
        self::MESSAGE_PREFIX)
    {
        $dirs = glob("{$this->dir_name}/$type/*", GLOB_ONLYDIR);
        $len_prefix =  strlen($message_prefix);
        foreach ($dirs as $dir) {
            $files = glob($dir . '/*.txt');
            if (isset($old_dir)) {
                crawlLog("Deleting $old_dir\n");
                $this->db->unlinkRecursive($old_dir);
                /* The idea is that only go through outer loop more than once
                   if earlier data directory empty.
                   Note: older directories should only have data dirs or
                   deleting like this might cause problems!
                 */
            }
            /* the code below returns the data
               from the lex-first file beginning with self::MESSAGE_PREFIX
             */
            foreach ($files as $file) {
                $path_parts = pathinfo($file);
                $base_name = $path_parts['basename'];
                $file_root_name = substr($base_name, 0, $len_prefix);
                if (strcmp($file_root_name, $message_prefix) == 0) {
                    if ($unpack) {
                        $message_data = $this->unpackMessages($file);
                    } else {
                        $message_data = webdecode(file_get_contents($file));
                    }
                    unlink($file);
                    return $message_data;
                }
            }
            $old_dir = $dir;
        }
        return null;
    }
    /**
     * Returns whether they are any as yet to be produced to crawl schedules
     * for the crawl queue in the given MessagesBundle's scheduler folder.
     * @param string $messages_bundle_dir folder to check from schedules
     * @return bool whether any found (true, if yes)
     */
    public static function isResumable($messages_bundle_dir)
    {
        $schedules_dir_name = "$messages_bundle_dir/" . self::SCHEDULES_FOLDER;
        if (!is_dir($schedules_dir_name)) {
            return false;
        }
        $schedules_dir = opendir($schedules_dir_name);
        $schedule_prefix = self::MESSAGE_PREFIX;
        $len_prefix = strlen(self::MESSAGE_PREFIX);
        while (($name = readdir($schedules_dir)) !==  false) {
            $sub_path = "$schedules_dir_name/$name";
            if (!is_dir($sub_path) || $name == '.' ||
                $name == '..') {
                continue;
            }
            $sub_dir = opendir($sub_path);
            while (($sub_name = readdir($sub_dir)) !== false) {
                if (substr($sub_name, 0, $len_prefix) == $schedule_prefix) {
                    return true;
                }
            }
            closedir($sub_dir);
        }
        closedir($schedules_dir);
        return false;
    }
    /**
     * Decodes the web-string safety encoding and then gunzips and deserializes
     * the result for a messages file used for communication between a Fetcher
     * and the QueueServer
     * @param string $file name of the
     * @return array|object unserialized data array or object contained in the
     *  messages file to be processed
     */
    public function unpackMessages($file)
    {
        crawlLog("Processing File: $file");
        $decode = file_get_contents($file);
        $decode = webdecode($decode);
        set_error_handler(null);
        $decode = @gzuncompress($decode);
        $sites = @unserialize($decode);
        restore_error_handler();
        return $sites;
    }
}
