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

use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\FetchUrl;
use seekquarry\yioop\library\Bzip2BlockIterator;

/** For webencode */
require_once __DIR__.'/../Utility.php';
/**
 * Used to iterate through the records of a collection of text or compressed
 * text-oriented records
 *
 * @author Chris Pollett
 * @see WebArchiveBundle
 */
class TextArchiveBundleIterator extends ArchiveBundleIterator
{
    /**
     * The path to the directory containing the archive partitions to be
     * iterated over.
     * @var string
     */
    public $iterate_dir;
    /**
     * The number of arc files in this arc archive bundle
     * @var int
     */
    public $num_partitions;
    /**
     * Counting in glob order for this arc archive bundle directory, the
     * current active file number of the arc file being process.
     *
     * @var int
     */
    public $current_partition_num;
    /**
     * current number of pages into the current arc file
     * @var int
     */
    public $current_page_num;
    /**
     * current byte offset into the current arc file
     * @var int
     */
    public $current_offset;
    /**
     * Array of filenames of arc files in this directory (glob order)
     * @var array
     */
    public $partitions;
    /**
     * File handle for current archive file
     * @var resource
     */
    public $fh;
    /**
     * Used to buffer data from the currently opened file
     * @var string
     */
    public $buffer;

    /**
     * Starting delimiters for records
     * @var string
     */
    public $start_delimiter;

    /**
     * Ending delimiters for records
     * @var string
     */
    public $end_delimiter;

    /**
     * File name to write this archive iterator status messages to
     * @var string
     */
    public $status_filename;

    /**
     * If gzip is being used a buffer file is also employed to
     * try to reduce the number of calls to gzseek. $buffer_fh is a
     * filehandle for the buffer file
     *
     * @var resource
     */
    public $buffer_fh;
    /**
     * Which block of self::BUFFER_SIZE from the current archive
     * file is stored in the file $this->buffer_filename
     *
     * @var int
     */
    public $buffer_block_num;
    /**
     * Name of a buffer file to be used to reduce gzseek calls in the
     * case where gzip compression is being used
     *
     * @var string
     */
    public $buffer_filename;
    /**
     * Name of function to be call whenever the partition is changed
     * that the iterator is reading. The point of the callback is to
     * read meta information at the start of the new partition
     *
     * @var string
     */
    public $switch_partition_callback_name = null;
    /**
     * Contains basic parameters of how this iterate works: compression,
     * start and stop delimiter. Typically, this data is read from the
     * arc_description.ini file
     *
     * @var array
     */
    public $ini;
    /**
     * @var string
     */
    public $remainder;
    /**
     * How many bytes at a time should be read from the current archive
     * file into the buffer file. 8192 = BZip2BlockIteraror::BlOCK_SIZE
     */
    const BUFFER_SIZE = 16384000;
    /**
     * Estimate of the maximum size of a record stored in a text archive
     * Data in archives is split into chunk of buffer size plus two record
     * sizes. This is used to provide a two record overlap between successive
     * chunks. This si further used to ensure that records that go over
     * the basic chunk boundary of BUFFER_SIZE will be processed.
     */
    const MAX_RECORD_SIZE = 49152;
    /**
     * Creates an text archive iterator with the given parameters.
     *
     * @param string $iterate_timestamp timestamp of the arc archive bundle to
     *     iterate  over the pages of
     * @param string $iterate_dir folder of files to iterate over. If this
     *     iterator is used in a fetcher and the data is on a name server
     *     set this to false
     * @param string $result_timestamp timestamp of the arc archive bundle
     *     results are being stored in
     * @param string $result_dir where to write last position checkpoints to
     * @param array $ini describes start_ and end_delimiter, file_extension,
     *     encoding, and compression method used for pages in this archive
     */
    public function __construct($iterate_timestamp, $iterate_dir,
        $result_timestamp, $result_dir, $ini = [])
    {
        $this->iterate_timestamp = $iterate_timestamp;
        $this->iterate_dir = $iterate_dir;
        $this->result_timestamp = $result_timestamp;
        $this->result_dir = $result_dir;
        if (!file_exists($result_dir)) {
            mkdir($result_dir);
        }
        $this->partitions = [];
        if ($this->iterate_dir != false) { // false = network/fetcher iterator
            if ($ini == []) {
                $ini = L\parse_ini_with_fallback(
                    "{$this->iterate_dir}/arc_description.ini");
            }
            $extension = $ini['file_extension'];
        }
        $this->setIniInfo($ini);
        if ($this->start_delimiter == "" && $this->end_delimiter == "" &&
            $this->iterate_dir != false) {
            L\crawlLog("At least one of start or end delimiter must be set!!");
            exit();
        }
        if ($this->iterate_dir != false) {
            foreach (glob("{$this->iterate_dir}/*.$extension", GLOB_BRACE)
                as $filename) {
                $this->partitions[] = $filename;
            }
        }
        $this->num_partitions = count($this->partitions);
        $this->status_filename = "{$this->result_dir}/iterate_status.txt";
        $this->buffer_filename = $this->result_dir . "/buffer.txt";
        if (file_exists($this->status_filename)) {
            $this->restoreCheckpoint();
        } else {
            $this->reset();
        }
    }
    /**
     * Mutator Method for controller how this text archive iterator behaves
     * Normally, data, on compression, start, stop delimiter read from an ini
     * file. This reads it from the supplied array.
     *
     * @param array $ini configuration settings for this archive iterator
     */
    public function setIniInfo($ini)
    {
        $this->ini = $ini;
        if (isset($ini['compression'])) {
            $this->compression = $ini['compression'];
        } else {
            $this->compression = "plain";
        }
        if (isset($ini['start_delimiter'])) {
            $this->start_delimiter = L\addRegexDelimiters(
                $ini['start_delimiter']);
        } else {
            $this->start_delimiter = "";
        }
        if (isset($ini['end_delimiter'])) {
            $this->end_delimiter = L\addRegexDelimiters($ini['end_delimiter']);
        } else {
            $this->end_delimiter = "";
        }
        if ($this->end_delimiter == "") {
            $this->delimiter = $this->start_delimiter;
        } else {
            $this->delimiter = $this->end_delimiter;
        }
        if (isset($ini['encoding'])) {
            $this->encoding = $ini['encoding'];
        } else {
            $this->encoding = "UTF-8";
        }
    }
    /**
     * Estimates the important of the site according to the weighting of
     * the particular archive iterator
     * @param $site an associative array containing info about a web page
     * @return bool false we assume arc files were crawled according to
     *     OPIC and so we use the default doc_depth to estimate page importance
     */
    public function weight(&$site)
    {
        return false;
    }
    /**
     * Resets the iterator to the start of the archive bundle
     */
    public function reset()
    {
        $this->current_partition_num = -1;
        $this->end_of_iterator = false;
        $this->current_offset = 0;
        $this->fh = null;
        $this->buffer_fh = null;
        $this->buffer_block_num = 0;
        $this->bz2_iterator = null;
        $this->buffer = "";
        $this->header = [];
        $this->remainder = "";
        if (file_exists($this->status_filename)) {
            unlink($this->status_filename);
        }
        if (file_exists($this->buffer_filename)) {
            unlink($this->buffer_filename);
        }
    }
    /**
     * Called to get the next chunk of BUFFER_SIZE + 2 MAX_RECORD_SIZE bytes
     * of data from the text archive. This data is returned unprocessed in
     * self::ARC_DATA together with ini and header information about the
     * archive. This method is typically called in the name server setting
     * from FetchController.
     *
     * @return array with contents as described above
     */
    public function nextChunk()
    {
        $info = [];
        $info[self::START_PARTITION] = false;
        if (!$this->checkFileHandle() || $this->checkEof()) {
            $this->updatePartition($info);
        }
        $info[self::INI] = $this->ini;
        $info[self::HEADER] = $this->header;
        $info[self::ARC_DATA] = $this->updateBuffer("", true);
        if (!$info[self::ARC_DATA]) {
            $this->updatePartition($info);
            $info[self::ARC_DATA] = $this->updateBuffer("", true);
        }
        $info[self::NUM_PARTITIONS] = $this->num_partitions;
        $info[self::PARTITION_NUM] = $this->current_partition_num;
        $this->saveCheckpoint();
        return $info;
    }
    /**
     * Helper function for nextChunk to advance the partition if we are
     * at the end of the current archive file
     *
     * @param array &$info a struct with data about current chunk. will up start
     *     partition flag
     * @return bool false when all partitions have been consumed and the
     *     iterator is marked end_of_iterator; void on the success path
     */
    public function updatePartition(&$info)
    {
        $this->current_partition_num++;
        if ($this->current_partition_num >= $this->num_partitions) {
            $this->end_of_iterator = true;
            return false;
        }
        $this->fileOpen(
            $this->partitions[$this->current_partition_num]);
        if ($this->switch_partition_callback_name != null) {
            $callback_name = $this->switch_partition_callback_name;
            $result = $this->$callback_name();
        }
        $info[self::START_PARTITION] = true;
    }
    /**
     * Gets the next at most $num many docs from the iterator. It might return
     * less than $num many documents if the partition changes or the end of the
     * bundle is reached.
     *
     * @param int $num number of docs to get
     * @param bool $no_process if true then just an array of page strings found
     *     not any additional meta data.
     * @return array associative arrays for $num pages
     */
    public function nextPages($num, $no_process = false)
    {
        $pages = [];
        $page_count = 0;
        for ($i = 0; $i < $num; $i++) {
            L\crawlTimeoutLog("..Still getting pages from archive iterator. ".
                "At %s of %s", $i, $num);
            $page = $this->nextPage($no_process);
            if (!$page) {
                if ($this->checkFileHandle()) {
                    $this->fileClose();
                }
                if (!$this->iterate_dir) { //fetcher local case
                    $this->current_offset = self::BUFFER_SIZE +
                        self::MAX_RECORD_SIZE;
                    break;
                }
                $this->current_partition_num++;
                if ($this->current_partition_num >= $this->num_partitions) {
                    $this->end_of_iterator = true;
                    break;
                }
                $this->fileOpen(
                    $this->partitions[$this->current_partition_num]);
                if ($this->switch_partition_callback_name != null) {
                    $callback_name = $this->switch_partition_callback_name;
                    $result = $this->$callback_name();
                    if (!$result) { break; }
                }
                $page = $this->nextPage($no_process);
                if (!$page) {continue; }
            }
            $pages[] = $page;
            $this->current_offset = ftell($this->buffer_fh);
            $this->current_page_num++;
        }
        $this->saveCheckpoint();
        return $pages;
    }
    /**
     * Gets the next doc from the iterator
     * @param bool $no_process if true then just return page string found
     *     not any additional meta data.
     * @return mixed associative array for doc or just string of doc
     */
    public function nextPage($no_process = false)
    {
        if (!$this->checkFileHandle()) { return null; }
        $matches = [];
        while((preg_match($this->delimiter, $this->buffer, $matches,
            PREG_OFFSET_CAPTURE)) != 1) {
            L\crawlTimeoutLog("..still looking for a page in local buffer");
            $block = $this->getFileBlock();
            if (!$block ||
                !$this->checkFileHandle() || $this->checkEof()) {
                return null;
            }
            $this->buffer .= $block;
        }
        $delim_len = strlen($matches[0][0]);
        $pos = $matches[0][1] + $delim_len;
        $page_pos = ($this->start_delimiter == "") ? $pos : $pos - $delim_len;
        $page = substr($this->buffer, 0, $page_pos);
        if ($this->end_delimiter == "") {
            $page = $this->remainder . $page;
            $this->remainder = $matches[0][0];
        }
        $this->buffer = substr($this->buffer, $pos + $delim_len);
        if ($this->start_delimiter != "") {
            $matches = [];
            if ((preg_match($this->start_delimiter, $this->buffer, $matches,
                PREG_OFFSET_CAPTURE)) != 1) {
                if (isset($matches[0][1])) {
                    $page = substr($page, $matches[0][1]);
                }
            }
        }
        if ($no_process == true) {return $page; }
        $site = [];
        $site[self::HEADER] = "TextArchiveBundleIterator extractor";
        $site[self::IP_ADDRESSES] = ["0.0.0.0"];
        $site[self::TIMESTAMP] = date("U", time());
        $site[self::TYPE] = "text/plain";
        $site[self::PAGE] = $page;
        $site[self::HASH] = FetchUrl::computePageHash($page, "text");
        $site[self::URL] = "record:" . L\webencode($site[self::HASH]);
        $site[self::HTTP_CODE] = 200;
        $site[self::ENCODING] = $this->encoding;
        $site[self::SERVER] = "unknown";
        $site[self::SERVER_VERSION] = "unknown";
        $site[self::OPERATING_SYSTEM] = "unknown";
        $site[self::WEIGHT] = 1;
        $site[self::DOC_DEPTH] = 1;
        return $site;
    }
    /**
     * Reads and return the block of data from the current partition
     * @return mixed a uncompressed string from the current partitin
     *     or null if iterator not set up, or false if EOF reached.
     */
    public function getFileBlock()
    {
        $block = null;
        return $this->fileGets();
    }
    /**
     * Acts as gzread($num_bytes, $archive_file), hiding the fact that
     * buffering of the archive_file is being done to a buffer file
     *
     * @param int $num_bytes to read from archive file
     * @return string of length up to $num_bytes (less if eof occurs)
     */
    public function fileRead($num_bytes)
    {
        $len = 0;
        $read_string = "";
        do {
            $read_string .= fread($this->buffer_fh, $num_bytes - $len);
            $len += strlen($read_string);
        } while($len < $num_bytes && $this->updateBuffer());
        return $read_string;
    }
    /**
     * Acts as gzgets(), hiding the fact that
     * buffering of the archive_file is being done to a buffer file
     *
     * @return string from archive file up to next line ending or eof
     */
    public function fileGets()
    {
        $len = 0;
        $read_string = "";
        do {
            $read_string .= fgets($this->buffer_fh);
        } while(feof($this->buffer_fh) && $this->updateBuffer());
        return $read_string;
    }
    /**
     * If reading from a gzbuffer file goes off the end of the current
     * buffer, reads in the next block from archive file.
     * @param string $buffer optional in-memory buffer to read into;
     *      empty falls back to using $this->fh directly
     * @param bool $return_string when true the read block is returned
     *      instead of being stored in $this->buffer; when false
     *      (default) it's stored
     * @return bool whether successfully read in next block or not
     */
    public function updateBuffer($buffer= "", $return_string = false)
    {
        if (!$this->iterate_dir) { //network case
            return false;
        }
        $this->buffer_block_num++;
        return $this->makeBuffer($buffer, $return_string);
    }
    /**
     * Reads in block $this->buffer_block_num of size self::BUFFER_SIZE from
     * the archive file
     *
     * @param string $buffer optional in-memory buffer to read into;
     *      empty falls back to using $this->fh directly
     * @param bool $return_string when true the read block is returned
     *      instead of being stored in $this->buffer; when false
     *      (default) it's stored
     * @return mixed whether successfully read in block or not
     */
    public function makeBuffer($buffer= "", $return_string = false)
    {
        if ($buffer == "") {
            if (!$this->checkFileHandle()) { return false; }
            $success = 1;
            $seek_pos = $this->buffer_block_num * self::BUFFER_SIZE;
            if ($this->compression == "gzip") {
                $success = gzseek($this->fh, $seek_pos);
            }
            if ($this->compression == "plain") {
                $success = fseek($this->fh, $seek_pos);
            }
            if ($success == -1 || !$this->checkFileHandle()
                || $this->checkEof()) { return false; }
            if (is_resource($this->buffer_fh)) {
                fclose($this->buffer_fh);
            }
            $padded_buffer_size = self::BUFFER_SIZE + 2*self::MAX_RECORD_SIZE;
            switch ($this->compression) {
                case 'bzip2':
                    $buffer = "";
                    while(strlen($buffer) < $padded_buffer_size) {
                        while(!is_string($block =
                            $this->bz2_iterator->nextBlock())) {
                            if ($this->bz2_iterator->eof()) {
                                break;
                            }
                        }
                        $buffer .= $block;
                        if ($this->bz2_iterator->eof()) {
                            break;
                        }
                    }
                    if ($buffer == "") {
                        return false;
                    }
                    break;
                case 'gzip':
                    $buffer = gzread($this->fh, $padded_buffer_size);
                    break;
                case 'plain':
                    $buffer = fread($this->fh, $padded_buffer_size);
                    break;
            }
        }
        if ($return_string) {
            return $buffer;
        }
        file_put_contents($this->buffer_filename, $buffer);
        $this->buffer_fh = fopen($this->buffer_filename, "rb");
        return true;
    }

    /**
     * Checks if have a valid handle to object's archive's current partition
     *
     * @return bool whether it has or not (true -it has)
     */
    public function checkFileHandle()
    {
        if (!$this->iterate_dir) { //network mode
            return is_resource($this->buffer_fh);
        } else if ($this->compression != "bzip2") {
            return is_resource($this->fh);
        } else {
            return !is_null($this->bz2_iterator);
        }
    }
    /**
     * Checks if this object's archive's current partition is at an end of file
     *
     * @return bool whether end of file has been reached (true -it has)
     */
    public function checkEof()
    {
        if (!$this->iterate_dir) {
            return feof($this->buffer_fh);
        }
        return match ($this->compression) {
            'bzip2' => $this->bz2_iterator->eof(),
            'gzip'  => gzeof($this->fh),
            'plain' => feof($this->fh),
        };
    }
    /**
     * Wrapper around particular compression scheme fopen function
     *
     * @param string $filename name of file to open
     * @param bool $make_buffer_if_needed when true (default), prime
     *      $this->buffer with the first BUFFER_SIZE block immediately
     *      after opening so subsequent reads can come from RAM
     */
    public function fileOpen($filename, $make_buffer_if_needed = true)
    {
        if ($this->iterate_dir) {
            switch ($this->compression) {
                case 'bzip2':
                    $this->bz2_iterator = new BZip2BlockIterator($filename);
                    break;
                case 'gzip':
                    $this->fh = gzopen($filename, "rb");
                    break;
                case 'plain':
                    $this->fh = fopen($filename, "rb");
                    break;
            }
        }
        if ($make_buffer_if_needed) {
            if (!file_exists($this->buffer_filename)) {
                $this->makeBuffer();
            } else {
                $this->buffer_fh = fopen($this->buffer_filename, "rb");
            }
        }
        $this->buffer_block_num = -1;
        $this->current_offset = 0;
    }
    /**
     * Wrapper around particular compression scheme fclose function
     */
    public function fileClose()
    {
        if ($this->iterate_dir) {
            switch ($this->compression) {
                case 'bzip2':
                    $this->bz2_iterator->close();
                    break;
                case 'gzip':
                    gzclose($this->fh);
                    break;
                case 'plain':
                    fclose($this->fh);
                    break;
            }
        }
        fclose($this->buffer_fh);
    }
    /**
     * Stores the current progress to the file iterate_status.txt in the result
     * dir such that a new instance of the iterator could be constructed and
     * return the next set of pages without having to process all of the pages
     * that came before. Each iterator should make a call to saveCheckpoint
     * after extracting a batch of pages.
     * @param array $info any extra info a subclass wants to save
     */
    public function saveCheckPoint($info = [])
    {
        $info['end_of_iterator'] = $this->end_of_iterator;
        $info['buffer_block_num'] = $this->buffer_block_num;
        $info['current_partition_num'] = $this->current_partition_num;
        $info['current_page_num'] = $this->current_page_num;
        $info['buffer'] = $this->buffer;
        $info['remainder'] = $this->remainder;
        $info['header'] = $this->header;
        $info['bz2_iterator'] = $this->bz2_iterator;
        $info['current_offset'] = $this->current_offset;
        if (!file_exists($this->result_dir)) {
            mkdir($this->result_dir);
        }
        file_put_contents($this->status_filename,
            serialize($info));
    }
    /**
     * Restores  the internal state from the file iterate_status.txt in the
     * result dir such that the next call to nextPages will pick up from just
     * after the last checkpoint. Text archive bundle iterator takes
     * the unserialized data from the last check point and calls the
     * compression specific restore checkpoint to further set up the iterator
     * according to the given compression scheme.
     *
     * @return array the data serialized when saveCheckpoint was called
     */
    public function restoreCheckPoint()
    {
        $info = unserialize(file_get_contents($this->status_filename));
        $this->end_of_iterator = $info['end_of_iterator'];
        $this->current_partition_num = $info['current_partition_num'];
        $this->current_page_num = (isset($info['current_page_num'])) ?
            $info['current_page_num'] : 0;
        $this->buffer = (isset($info['buffer'])) ? $info['buffer'] : "";
        $this->remainder = (isset($info['remainder'])) ? $info['remainder']:"";
        $this->header = (isset($info['header'])) ? $info['header']: [];
        $this->bz2_iterator = $info['bz2_iterator'];
        if (!$this->end_of_iterator && !$this->bz2_iterator) {
            if (isset($this->partitions[$this->current_partition_num])) {
                $filename = $this->partitions[$this->current_partition_num];
            } else if (!$this->iterate_dir) { // fetcher case
                $filename = ""; // will only use buffer so don't need
            }
            $this->fileOpen($filename);
            $success = fseek($this->buffer_fh, $info['current_offset']);
            if ($success == -1) { $this->buffer_fh = null; }
        }
        $this->buffer_block_num = $info['buffer_block_num'];
        $this->current_offset = $info['current_offset'];
        return $info;
    }
    /**
     * Used to extract data between two tags. After operation $this->buffer has
     * contents after the close tag.
     *
     * @param string $tag tag name to look for
     *
     * @return string data start tag contents close tag of name $tag
     */
    public function getNextTagData($tag)
    {
        $info = $this->getNextTagsData([$tag]);
        if (!isset($info[1])) {
            return $info;
        }
        return $info[0];
    }
    /**
     * Used to extract data between two tags for the first tag found
     * amongst the array of tags $tags. After operation $this->buffer has
     * contents after the close tag.
     *
     * @param array $tags array of tagnames to look for
     *
     * @return array of two elements: the first element is a string consisting
     *     of start tag contents close tag of first tag found, the second
     *     has the name of the tag amongst $tags found
     */
    public function getNextTagsData($tags)
    {
        $close_regex = '@</('.implode('|', $tags).')[^>]*?>@';

        $offset = 0;
        while(!preg_match($close_regex, $this->buffer, $matches,
                    PREG_OFFSET_CAPTURE, $offset)) {
            if (!$this->checkFileHandle() || $this->checkEof()) {
                return false;
            }
            /*
               Get the next block; the block iterator can very occasionally
               return a bad block if a block header pattern happens to show up
               in compressed data, in which case decompression will fail. We
               want to skip over these false blocks and get back to real
               blocks.
            */
            while(!is_string($block = $this->getFileBlock())) {
                L\crawlTimeoutLog("..still getting next tags data..");
                if ($this->checkEof())
                    return false;
            }
            $this->buffer .= $block;
        }
        $tag = $matches[1][0];
        $start_info = strpos($this->buffer, "<$tag");
        $this->remainder = substr($this->buffer, 0, $start_info);
        $pre_end_info = strpos($this->buffer, "</$tag", $start_info);
        $end_info = strpos($this->buffer, ">", $pre_end_info) + 1;
        $tag_info = substr($this->buffer, $start_info,
            $end_info - $start_info);
        $this->buffer = substr($this->buffer, $end_info);
        return [$tag_info, $tag];
    }
}
