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

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\IndexShard;
use seekquarry\yioop\library\IndexDocumentBundle;
use seekquarry\yioop\library\IndexManager;
use seekquarry\yioop\library\PartitionDocumentBundle;
use seekquarry\yioop\models\ParallelModel;

/**
 * Used to iterate through the documents associated with a word in
 * an IndexArchiveBundle. It also makes it easy to get the summaries
 * of these documents.
 *
 * A description of how words and the documents containing them are stored
 * is given in the documentation of IndexArchiveBundle.
 *
 * @author Chris Pollett
 * @see IndexArchiveBundle
 */
class WordIterator extends IndexBundleIterator
{
    /**
     * Host Key position + 1 (first char says doc, inlink or external link)
     */
    const HOST_KEY_POS = 17;
    /**
     *  Length of a doc key part
     */
    const KEY_LEN = 8;
    /**
     * Word key above in our modified base 64 encoding
     * @var string
     */
    public $base64_word_key;
    /**
     * The current value of the doc_offset of current posting if known
     * @var int
     */
    public $current_doc_offset;
    /**
     * Numeric number of current shard
     * @var int
     */
    public $current_generation;
    /**
     * The current byte offset in the IndexShard (if older index)
     * @var int
     */
    public $current_offset;
    /**
     * An array of shard generation and posting list offsets, lengths, and
     * numbers of documents
     * @var array
     */
    public $dictionary_info;
    /**
     * Keeps track of whether the word_iterator list is empty because the
     * word does not appear in the index shard
     * @var int
     */
    public $empty;
    /**
     * Index into dictionary_info corresponding to the current shard
     * @var int
     */
    public $generation_pointer;
    /**
     * Whether word key corresponds to a meta word
     * @var string
     */
    public $is_meta;
    /**
     * Last Offset of word occurrence in the IndexShard
     * @var int
     */
    public $last_offset;
    /**
     * The next byte offset in the IndexShard
     * @var int
     */
    public $next_offset;
    /**
     * The total number of shards that have data for this word
     * @var int
     */
    public $num_generations;
    /**
     * @var int
     */
    public $max_items_per_partition;
    /**
     * @var int
     */
    public $avg_items_per_partition;
    /**
     * @var int
     */
    public $total_number_of_partitions;
    /**
     * @var int
     */
    public $num_occurrences;
    /**
     * @var int
     */
    public $threshold_exceeded;
    /**
     * @var int
     */
    public $archive_file;
    /**
     * @var int
     */
    public $term_info_computed;
    /**
     * @var int
     */
    public $total_num_docs_and_links;
    /**
     * How url, keywords, and title words should influence relevance
     * and doc rank calculations
     * @var array
     */
    public $ranking_factors;
    /**
     * First shard generation that word info was obtained for
     * @var int
     */
    public $start_generation;
    /**
     * Starting Offset of word occurrence in the IndexShard
     * @var int
     */
    public $start_offset;
    /**
     * hash of word or phrase that the iterator iterates over
     * @var string
     */
    public $word_key;
    /**
     * Creates a word iterator with the given parameters.
     *
     * @param string $word_key hash of word or phrase to iterate docs
     *      of
     * @param string $index_name the timestamp of the index this
     *      iterator is associated with
     * @param bool $raw whether the $word_key is our variant of base64
     *      encoded
     * @param SearchfiltersModel $filter model responsible for keeping
     *      track of edited and deleted search results
     * @param int $results_per_block the maximum number of results
     *      that can be returned by a findDocsWithWord call
     * @param int $direction when results are accessed from
     *      $index_name, in which order they should be presented.
     *      self::ASCENDING is from first added to last added,
     *      self::DESCENDING is from last added to first added. Note:
     *      this value is not saved permanently, so you could in
     *      theory open two read only versions of the same bundle but
     *      reading the results in different directions
     * @param array $ranking_factors field says how url, keywords, and
     *      title words should influence relevance and doc rank
     *      calculations
     * @param bool $retrieve_latest whether the latest indexed
     *      instance of a document should be returned or not (might
     *      have multiple instances if crawl indexes document more
     *      than once) (@see PhraseModel::lookupSummaryOffsetGeneration())
     */
    public function __construct($word_key, public $index_name, $raw = false,
        public $filter = null, $results_per_block =
        IndexBundleIterator::RESULTS_PER_BLOCK,
        public $direction = self::ASCENDING,
        $ranking_factors = [], public $retrieve_latest = true)
    {
        if ($raw == false) {
            //get rid of our modified base64 encoding
            $word_key = L\unbase64Hash($word_key);
        }
        $this->word_key = $word_key;
        $this->is_meta = L\PhraseParser::checkMetaTerm($this->word_key);
        $this->base64_word_key = L\base64Hash($word_key);
        $this->termInfoIteratorFields($index_name, $word_key);
        $this->current_doc_offset = null;
        $this->results_per_block = $results_per_block;
        $this->current_block_fresh = false;
        $this->start_generation = ($direction == self::ASCENDING) ? 0 :
            "ACTIVE";
        foreach (["CLD_URL_BONUS" => C\p('CLD_URL_BONUS'),
            "HOST_URL_BONUS" => C\p('HOST_URL_BONUS'),
            "HOST_KEYWORD_BONUS" => C\p('HOST_KEYWORD_BONUS'),
            "PATH_KEYWORD_BONUS" => C\p('PATH_KEYWORD_BONUS'),
            "TITLE_BONUS" => C\p('TITLE_BONUS'),
            "WIKI_BONUS" => C\p('WIKI_BONUS'),
            "NUM_SLASHES_BONUS" => C\p('NUM_SLASHES_BONUS'),
            ] as $factor => $default) {
            $this->ranking_factors[$factor] = $ranking_factors[$factor] ??
                $default;
        }
        if (!$this->empty) {
            $this->reset();
        }
    }
    /**
     * Returns CrawlConstants::ASCENDING or CrawlConstants::DESCENDING
     * depending on the direction in which this iterator ttraverse the
     * underlying index archive bundle.
     *
     * @return int direction traversing underlying archive bundle
     */
    public function getDirection()
    {
        return $this->direction;
    }
    /**
     * Resets the iterator to the first document block that it could iterate
     * over
     */
    public function reset()
    {
        if (!$this->empty) {//we shouldn't be called when empty - but to be safe
            $this->termInfoIteratorFields($this->index_name,
                    $this->word_key);
            $info = ($this->direction == self::ASCENDING) ?
                $this->dictionary_info[0] : $this->dictionary_info[
                $this->num_generations - 1];
            $this->current_generation = $info['PARTITION'];
            $this->start_offset = 0;
            $this->last_offset = $info['NUM_DOCS'] - 1;
        } else {
            $this->start_offset = 0;
            $this->last_offset = -1;
            $this->num_generations = -1;
        }
        if ($this->direction == self::ASCENDING) {
            $this->current_offset = $this->start_offset;
            $this->generation_pointer = 0;
        } else {
            $this->current_offset = $this->last_offset;
            /*  reset pointer to the number of gens, which in reverse is the
               first one we want
             */
            $this->generation_pointer = $this->num_generations - 1;
        }
        $this->count_block = 0;
        $this->seen_docs = 0;
        $this->current_doc_offset = null;
    }
    /**
     * Used to compute fields such as $this->total_num_docs for this iterator on
     * term $word_key for index $index_name
     *
     * @param string $index_name name of index to compute statistics with
     *      respect to
     * @param string $word_key term to compute statics with respect to
     */
    protected function termInfoIteratorFields($index_name, $word_key)
    {
        if (!empty($this->term_info_computed)) {
            return;
        }
        $word_info = IndexManager::getWordInfo($index_name, $word_key, -1, -1,
            C\NUM_DISTINCT_GENERATIONS, true);
        $this->total_num_docs = $word_info['TOTAL_NUM_DOCS'] ?? 0;
        $this->total_num_docs_and_links =
            $word_info['TOTAL_NUM_LINKS_AND_DOCS'] ?? 0;
        $this->max_items_per_partition =
            $word_info['MAX_ITEMS_PER_PARTITION'] ??
            PartitionDocumentBundle::MAX_ITEMS_PER_FILE;
        $this->avg_items_per_partition =
            $word_info['AVG_ITEMS_PER_PARTITION'] ??
            PartitionDocumentBundle::MAX_ITEMS_PER_FILE;
        $this->total_number_of_partitions =
            $word_info['TOTAL_NUMBER_OF_PARTITIONS'] ?? 0;
        $this->num_docs = $word_info['TOTAL_COUNT'] ?? 0;
        $this->num_occurrences = $word_info['TOTAL_OCCURRENCES'] ?? 0;
        $this->dictionary_info = $word_info['ROWS'] ?? [];
        /* A partition holds at most max_items_per_partition documents, so a
           term cannot occur in more of a partition's documents than that. A
           dictionary entry whose NUM_DOCS is larger is corrupt; left as read
           it sets last_offset to a wild value and sends the posting walk
           through that many nearly empty blocks, pinning the server in a
           near-endless loop. Clamp each entry's count to the ceiling, which
           leaves every healthy entry unchanged. */
        $partition_ceiling = (int)$this->max_items_per_partition;
        if ($partition_ceiling > 0) {
            foreach ($this->dictionary_info as $row_index => $row) {
                if (isset($row['NUM_DOCS']) &&
                    $row['NUM_DOCS'] > $partition_ceiling) {
                    $this->dictionary_info[$row_index]['NUM_DOCS'] =
                        $partition_ceiling;
                }
            }
        }
        $this->threshold_exceeded = $word_info['THESHOLD_EXCEEDED'] ??
            false;
        $this->archive_file = $word_info['ARCHIVE_FILE'] ?? "";
        if (empty($this->dictionary_info)) {
            $this->empty = true;
            $this->num_generations = 0;
        } else {
            $this->num_generations = count($this->dictionary_info);
            $this->empty = ($this->num_generations == 0);
        }
        $this->term_info_computed = true;
    }
    /**
     * Hook function used by currentDocsWithWord to return the current block
     * of docs if it is not cached
     *
     * @return mixed doc ids and score if there are docs left, -1 otherwise
     */
    public function findDocsWithWord()
    {
        if ($this->empty) {
            return -1;
        }
        $ascending = ($this->direction == self::ASCENDING);
        if ($ascending) {
            if (($this->generation_pointer >= $this->num_generations) ||
                $this->generation_pointer == $this->num_generations - 1 &&
                $this->current_offset > $this->last_offset) {
                return -1;
            }
        } else {
            if (($this->generation_pointer < 0)
                || ($this->generation_pointer == 0 &&
                $this->current_offset < $this->start_offset)) {
                return -1;
            }
        }
        $pre_results = [];
        if (!$this->empty) {
            $pre_results = $this->getPostingsSliceResults();
        }
        $results = [];
        $doc_key_len = self::KEY_LEN;
        foreach ($pre_results as $keys => $data) {
            $host_key = substr($keys, self::HOST_KEY_POS, self::KEY_LEN);
            if (!empty($this->filter) && $this->filter->isFiltered($host_key)) {
                continue;
            }
            // inlinks is the domain of the inlink
            $key_parts = str_split($keys, $doc_key_len);
            $data[self::KEY] = $keys;
            if (isset($key_parts[2])) {
                list(, $data[self::HASH], $data[self::INLINKS]) =
                    $key_parts;
            } else {
                continue;
            }
            $data[self::CRAWL_TIME] = $this->index_name;
            $results[$keys] = $data;
        }
        $this->count_block = count($results);
        if ($this->generation_pointer == $this->num_generations - 1 &&
            empty($pre_results)) {
            $results = -1;
        }
        $this->pages = $results;
        return $results;
    }
    /**
     * Given the current_offset, result_per_block, and index used get the
     * result_per_block postings starting from current_offset in the current
     * direction (ascending or descending) for the term word iterator
     * iterates over from the index.
     *
     * @return array list of posting docs read from the index for the
     *      current slice; empty array when the iterator has walked past
     *      either end of the posting list
     */
    public function getPostingsSliceResults()
    {
        $this->next_offset = $this->current_offset;
        if ($this->direction == self::ASCENDING) {
            if ($this->current_offset < $this->start_offset) {
                $this->current_offset = $this->start_offset;
                $this->next_offset = $this->current_offset;
            }
            if ($this->next_offset > $this->last_offset) {
                return [];
            }
            $start_slice = $this->next_offset;
            $num_slice = min($this->results_per_block,
                $this->last_offset - $this->next_offset + 1);
            $this->next_offset += $num_slice;
        } else {
            if ($this->current_offset > $this->last_offset) {
                $this->current_offset = $this->last_offset;
                $this->next_offset = $this->current_offset;
            }
            if ($this->next_offset < $this->start_offset) {
                return [];
            }
            $num_slice = max($this->results_per_block,
                $this->start_offset);
            $this->next_offset -= $num_slice;
            $start_slice = $this->next_offset + 1;
        }
        $postings = $this->getGenerationPostings($this->generation_pointer);
        $postings = array_slice($postings, $start_slice, $num_slice);
        $key_postings = $this->getDocKeyPositionsScoringInfo($postings,
            $this->current_generation);
        return $key_postings;
    }
    /**
     * Get the positions file specs for the given base index folder.
     *
     * @param string $base_folder of index folder
     * @return array [file handle, file size]
     */
    public function getPositionsFile($base_folder)
    {
        $positions_filename = $base_folder . "/" .
            IndexDocumentBundle::POSITIONS_FILENAME;
        if (file_exists($positions_filename)) {
            $fh = fopen($positions_filename, "r");
            $file_size = filesize($positions_filename);
        } else {
            $fh = false;
            $file_size = 0;
        }
        return [$fh, $file_size];
    }
    /**
     * Get the positions entry associated with a particular posting.
     *
     * @param array $posting of positions entry
     * @param int $positions_file_size of positions file
     * @param resource $positions_fh of positions file
     * @return array of positions
     */
    public function getPositionsList($posting,
        $positions_file_size, $positions_fh)
    {
        if ($posting['POSITIONS_LEN'] > 0 &&
            $posting['POSITIONS_LEN'] < $positions_file_size &&
            !empty($positions_fh)) {
            if ($posting['POSITIONS_LEN'] > C\MAX_POSTING_READ_LEN) {
                L\crawlLog("getPositionsList: positions length " .
                    $posting['POSITIONS_LEN'] . " exceeds the maximum " .
                    "readable posting length " . C\MAX_POSTING_READ_LEN .
                    "; treating the entry as corrupt and skipping the read");
                $position_list = [];
            } else if (fseek($positions_fh,
                $posting['POSITIONS_OFFSET']) >= 0) {
                $encoded_positions = fread($positions_fh,
                    $posting['POSITIONS_LEN']);
                $position_list = L\decodePositionList(
                    $encoded_positions, $posting['FREQUENCY']);
            } else {
                $position_list = [];
            }
        } else {
            $position_list = [];
        }
        return $position_list;
    }
    /**
     * Add to a set of postings from a partition scoring information, position
     * list information and info about the relative weights of given position
     * based on the position list file and doc_map file.
     *
     *  @param array $postings posting data to add scoring information to
     *  @param int $partition which partition from the PartitionDocumentBundle
     *    postings a re related to
     *  @return array $postings augmented with KEY/POSITION_LIST/SCORE/
     *    PROXIMITY values pulled from the position list file and
     *    doc_map for the given partition
     */
    public function getDocKeyPositionsScoringInfo($postings, $partition)
    {
        $key_postings = [];
        $index = IndexManager::getIndex($this->index_name);
        $base_folder = $index->getPartitionBaseFolder($partition);
        $doc_map_filename = $base_folder . "/" .
            IndexDocumentBundle::DOC_MAP_FILENAME;
        $doc_map_tools = $index->doc_map_tools;
        list($fh, $file_size) = $this->getPositionsFile($base_folder);
        $number_of_partitions = $this->total_number_of_partitions;
        $num_doc_keys = $doc_map_tools->countTableEntries($doc_map_filename);
        $is_ascending = ($this->direction == self::ASCENDING);
        $num_seen_partitions = ($is_ascending) ?
            $partition + 1 : $number_of_partitions - $partition;
        $occurrences_per_doc = $this->num_occurrences /
            max($this->total_num_docs, 1);
        foreach ($postings as $posting) {
            $posting[self::GENERATION] = $partition;
            $posting[self::POSITION_LIST] = $this->getPositionsList($posting,
                $file_size, $fh);
            $doc_map_index = $posting['DOC_MAP_INDEX'];
            $entry = $doc_map_tools->findEntryAtIndexTableName(
                $doc_map_filename, $doc_map_index);
            $docid_len = IndexDocumentBundle::DOCID_LEN;
            $termsfilter_len = IndexDocumentBundle::TERMSFILTER_LEN;
            if (strlen($entry) < $docid_len) {
                continue;
            }
            $doc_key = substr($entry, 0, $docid_len);
            $is_text = IndexDocumentBundle::isType($doc_key, "text");
            /**
             * For backward compatibility: only check for the latest
             * crawled version of a page if $entry[24] == 't'
             * (the beginning character of the term bloom
             * filter string attached to doc_map entries).
             */
            $values = (strlen($entry) >= ($docid_len + $termsfilter_len + 1) &&
                $entry[$docid_len] == 't') ?
                substr($entry, $docid_len + $termsfilter_len + 1) :
                substr($entry, $docid_len);
            if ($this->retrieve_latest && $entry[$docid_len] == 't' &&
                $is_text) {
                $url_hash = substr($doc_key, 0, 8);
                $latest_version_info =
                    IndexManager::lookupLatestVersionPage($url_hash,
                    $this->index_name);
                if ($latest_version_info != null) {
                    list($latest_partition, $latest_posting) =
                        $latest_version_info;
                    /**
                     * Ensure that the discovered latest version
                     * isn't the same as the current posting.
                     */
                    if ($partition != $latest_partition ||
                        $latest_posting['DOC_MAP_INDEX'] !=
                        $doc_map_index) {
                        $latest_base_folder = $index->
                            getPartitionBaseFolder($latest_partition);
                        $latest_doc_map_filename = $latest_base_folder .
                            "/" . IndexDocumentBundle::DOC_MAP_FILENAME;
                        $latest_doc_map_index =
                            $latest_posting['DOC_MAP_INDEX'];
                        $latest_doc_map_entry =
                            $doc_map_tools->findEntryAtIndexTableName(
                                $latest_doc_map_filename,
                                $latest_doc_map_index);
                        if (strlen($latest_doc_map_entry) < $docid_len) {
                            continue;
                        }
                        $latest_doc_key = substr($latest_doc_map_entry, 0,
                            $docid_len);
                        $terms_filter = substr($latest_doc_map_entry,
                            $docid_len + 1, $termsfilter_len);
                        if (!IndexDocumentBundle::checkTermExists(
                                $this->word_key, $terms_filter)) {
                            continue;
                        } else {
                            /**
                             * The current term id exists in the most recent
                             * version of the document; replace the current
                             * posting entries with the latest entry.
                             */
                            $posting[self::GENERATION] = $latest_partition;
                            $posting['DOC_MAP_INDEX'] =
                                $latest_doc_map_index;
                            $doc_key = $latest_doc_key;
                            $values = substr($latest_doc_map_entry,
                                $docid_len + $termsfilter_len + 1);
                            $latest_term_postings = $this->
                                getGenerationPostings($latest_partition);
                            $target_posting =
                                array_filter($latest_term_postings,
                                    function ($p) use ($latest_doc_map_index)
                                    {
                                        return $p['DOC_MAP_INDEX'] ==
                                            $latest_doc_map_index;
                                    });
                            if (count($target_posting) > 0) {
                                $posting['POSITIONS_LEN'] =
                                    $target_posting[0]['POSITIONS_LEN'];
                                $posting['POSITIONS_OFFSET'] =
                                    $target_posting[0]['POSITIONS_OFFSET'];
                                $posting['FREQUENCY'] =
                                    $target_posting[0]['FREQUENCY'];
                                $latest_base_folder = $index->
                                    getPartitionBaseFolder($partition);
                                list($latest_positions_fh,
                                    $latest_positions_file_size) = $this->
                                getPositionsFile($latest_base_folder);
                                $posting[self::POSITION_LIST] =
                                    $this->getPositionsList($posting,
                                        $latest_positions_file_size,
                                        $latest_positions_fh);
                            }
                        }
                    }
                }
            }
            if (IndexDocumentBundle::isType($doc_key, "doc")) {
                $posting[self::IS_DOC] = true;
            }
            $doc_info = $doc_map_tools->unpack($values);
            if (empty($doc_info)) {
                continue;
            }
            $time = time();
            $posting[self::KEY] = $doc_key;
            list($posting[self::DOC_LEN], $original_score) =
                array_values(array_shift($doc_info));
            $is_timestamp_score = ($original_score <= $time &&
                $original_score > ($time >> 1));
            /*
               DOC_RANK calculate is a computes a document quality measure
               either based on time item was added (freshness) or a
               sum of signals (how early or late it was added to index),
               whether the url was a CLD or HOST, whether page was a wiki
               page, and the number of slashes in the url path
             */
            if ($is_timestamp_score) {
                $posting[self::DOC_RANK] = $time /
                    (max(1, $time - $original_score)) *
                    $this->getMaxDocQualityScore();
            } else {
                $posting[self::DOC_RANK] = $this->computeDocRank($doc_key,
                    $doc_map_index, $num_seen_partitions, $number_of_partitions,
                    $num_doc_keys, $this->avg_items_per_partition,
                    $this->max_items_per_partition,
                    $this->ranking_factors, $is_ascending);
            }
            list($preface_positions, $num_description_scores) =
                array_values(array_shift($doc_info));
            $num_description_scores = intval($num_description_scores);
            $posting["PATH_KEYWORDS_END_POS"] = ($preface_positions & 255);
            $preface_positions = $preface_positions >> 8;
            $posting["TITLE_END_POS"] = ($preface_positions & 255);
            $preface_positions = $preface_positions >> 8;
            $posting["HOST_KEYWORDS_END_POS"] = ($preface_positions & 255);
            $posting[self::DESCRIPTION_SCORES] = array_slice($doc_info, 0,
                $num_description_scores);
            if ($posting['FREQUENCY'] > 0) {
                list($bonuses, $frequency) =
                    $this->frequencyScoring(
                    $occurrences_per_doc,
                    $posting[self::POSITION_LIST],
                    $posting[self::DOC_LEN],
                    $posting["HOST_KEYWORDS_END_POS"],
                    $posting["TITLE_END_POS"],
                    $posting["PATH_KEYWORDS_END_POS"],
                    $posting[self::DESCRIPTION_SCORES]);
                // Divergence-from-randomness + preface score
                $nonzero_occurrences_per_doc = ($occurrences_per_doc > 0) ?
                    $occurrences_per_doc : 1;
                $posting[self::RELEVANCE] = $bonuses *
                    log(1 + 1/$nonzero_occurrences_per_doc, 2) /
                    ($bonuses + 1) +
                    ((log(1 + $occurrences_per_doc, 2) + $frequency *
                    log(1 + 1/$nonzero_occurrences_per_doc, 2)) /
                    ($frequency + 1));
            } else {
                 /*
                   this will typically be the relaveance score for a meta word
                   As will always be frequency 1 and have no position info
                   set close to 0. (Not zero to avoid div by 0's)
                  */
                 $posting[self::RELEVANCE] = 0.01;
            }
            $posting[self::SCORE] = $posting[self::DOC_RANK] +
                $posting[self::RELEVANCE];
            $posting[self::USER_RANKS] = array_slice($doc_info,
                $num_description_scores);
            $key_postings[$doc_key] = $posting;
        }
        if (!empty($fh)) {
            fclose($fh);
        }
        return $key_postings;
    }
    /**
     * Computes weighted frequencies of a term within a document with respect to
     * the length of the document, the positions of the term with the document
     * and the overall importance score for a given position within the document
     * Also computes the score of the posting for the host keywords,
     * title keywords, and path keywords bonuses.
     *
     * @param float $occurrences_per_doc expected number of occurrence of term
     *  per/doc.
     * @param array $positions positions of this iterators term in the document
     * @param int $num_words number of terms in the document
     * @param int $host_keywords_end_pos term offset into the document summary
     *  that demarks the end of the host keywords portion of the summary
     * @param int $title_end_pos absolute term offset into the document summary
     *  that demarks the end of the title portion of the summary
     * @param int $path_keywords_end_pos absolute term offset into the document
     *  summary that demarks the end of the title portion of the summary
     * @param array $descriptions_scores boundaries and scores of different
     *  regions with document
     * @return array [score for host title path keywords bonuses, frequency]
     */
    public function frequencyScoring(
        $occurrences_per_doc, $positions, $num_words, $host_keywords_end_pos,
        $title_end_pos, $path_keywords_end_pos, $descriptions_scores)
    {
        $num_words = max($num_words, 1);
        /*
         * Amati and van Rijsbergen suggest a normalization of
         * log_2(1 + l_avg/l_d) for divergence-from-randomness
         * Here l_avg = average num words in a document, l_d = num words
         * current document. C\MAX_DESCRIPTION_LEN is the max number
         * of characters in a document. Assuming the average word is
         * around 5 chars + whitespace char + punctuation, and most documents
         * are summarized to close to the max character length, we
         * approximate l_avg as C\MAX_DESCRIPTION_LEN/7 in the below.
         */
        $pseudo_doc_length = 7 * $num_words;
        $length_normalization = log(1 + C\MAX_DESCRIPTION_LEN/(7 * $num_words),
            2);
        if (empty($descriptions_scores)) {
            /*
                No per-section description scores were supplied,
                so there's nothing to compute section-overlap
                bonuses against; fall back to count-of-positions
                * length-normalization as the frequency signal
                and zero out the bonuses. Returning a 2-element
                tuple here matches the contract above so the
                caller's list() destructure works.
             */
            return [0, count($positions) * $length_normalization];
        }
        $host_bonus = $this->ranking_factors["HOST_KEYWORD_BONUS"];
        $path_bonus = $this->ranking_factors["PATH_KEYWORD_BONUS"];
        $title_bonus = $this->ranking_factors["TITLE_BONUS"];
        $len_term = strlen($this->word_key);
        $first_index = 0;
        $old_pos = 0;
        /*
           Sum of description scores without bonus scores we add below
           is 1. So with the scores we add below is $max_doc_norm_score.
           The foreach loop that follows measures what fraction of this
           comes from $this->word_key occurrences, so will be a number
           less than $max_doc_norm_score;
         */
        $descriptions_scores = array_merge(
            [['POS' => - $path_keywords_end_pos - 1,
             'SCORE' => $host_bonus],
             ['POS' => $host_keywords_end_pos - $path_keywords_end_pos - 1,
             'SCORE' => $title_bonus],
             ['POS' => $title_end_pos - $path_keywords_end_pos - 1,
              'SCORE' => $path_bonus],
           ], $descriptions_scores);
        $num_scores = count($descriptions_scores);
        $weighted_frequency = 0;
        $bonuses = 0;
        foreach ($positions as $position) {
            $last_index = $num_scores - 1;
            /* description score offsets are with respect to the description
               only so we subtract from the term position the offset of the
               non-description
             */
            $position -= ($path_keywords_end_pos + 1);
            while ($first_index < $last_index) {
                $mid_index = ceil(($first_index + $last_index)/2.0);
                if ($descriptions_scores[$mid_index]['POS'] > $position) {
                    $last_index = $mid_index - 1;
                } else {
                    $first_index = $mid_index;
                }
            }
            $weight = $descriptions_scores[$first_index]['SCORE'];
            $start_description_pos = $descriptions_scores[$first_index]['POS'];
            $len_description = max(abs(($first_index == $num_scores - 1) ?
                $pseudo_doc_length - $start_description_pos :
                $descriptions_scores[$first_index + 1]['POS'] -
                $start_description_pos), $len_term, 1);
            $frequency_term = $weight * $len_term / $len_description;
            if ($position <= 0) {
                $bonuses += $weight; //$frequency_term;
            } else {
                $weighted_frequency += $frequency_term;
            }
        }
        $frequency = $weighted_frequency * $length_normalization;
        return [$bonuses, $frequency];
    }
    /**
     * Updates the seen_docs count during an advance() call
     */
    public function advanceSeenDocs()
    {
        if ($this->current_block_fresh != true) {
            if ($this->direction == self::ASCENDING) {
                $remaining_postings = $this->last_offset - $this->next_offset;
                $num_docs = min($this->results_per_block, $remaining_postings);
                $delta_sign = 1;
            } else {
                $remaining_postings = $this->next_offset -
                    $this->start_offset + 1;
                $num_docs = min($this->results_per_block,
                    $remaining_postings);
                $delta_sign = -1;
            }
            $this->next_offset = $this->current_offset;
            $this->next_offset += $delta_sign * $num_docs;
            if ($num_docs <= 0) {
                return;
            }
        } else {
            $num_docs = $this->count_block;
        }
        $this->current_block_fresh = false;
        $this->seen_docs += $num_docs;
    }
    /**
     * Forwards the iterator one group of docs
     * @param array $gen_doc_offset a generation, doc_offset pair. If not null,
     *     (in the ascending search case opposite for descending), the pair
     *     must be of greater than or equal generation, and if equal the
     *     next block must all have $doc_offsets larger than or equal to
     *     this value.
     */
    public function advance($gen_doc_offset = null)
    {
        if ($gen_doc_offset == null) {
            $this->plainAdvance();
            return;
        }
        $is_ascending = ($this->direction == self::ASCENDING);
        $cur_gen_doc_offset = $this->currentGenDocOffsetWithWord();
        if ($cur_gen_doc_offset == -1 ||
            $this->genDocOffsetCmp($cur_gen_doc_offset,
            $gen_doc_offset, $this->direction) >= 0) {
            return;
        }
        $advance_check = ($is_ascending) ?
            ($this->current_generation < $gen_doc_offset[0]) :
            ($this->current_generation > $gen_doc_offset[0]);
        if ($advance_check) {
            $this->advanceGeneration($gen_doc_offset[0]);
            $this->next_offset = $this->current_offset;
        }
        if ($this->current_generation == $gen_doc_offset[0]) {
            $offset_pair = $this->nextDocIndexOffsetPair(
                $gen_doc_offset[1]);
            if ($offset_pair === false) {
                $this->advanceGeneration();
                $this->next_offset = $this->current_offset;
            } else {
                list($this->current_offset, $this->current_doc_offset) =
                    $offset_pair;
                $this->next_offset = $this->current_offset;
            }
        }
        if ($is_ascending) {
            $this->seen_docs = ($this->current_offset - $this->start_offset);
        } else {
            $this->seen_docs = ($this->last_offset - $this->current_offset);
        }
        $this->current_block_fresh = false;
    }
    /**
     * Computes a pair [posting_slice_offset, $doc_index], such that
     * the $doc_index when shift to make a doc_offset is greater than
     * $doc_offset and posting_slice_offset is the offset of the first
     * posting with this property.
     * @param int $doc_offset that we are try to find a posting whose
     *  doc_index has a bigger doc_offset
     * @return array [posting_slice_offset, $doc_index]
     */
    public function nextDocIndexOffsetPair($doc_offset)
    {
        $is_ascending = ($this->direction == self::ASCENDING);
        $end_offset = ($is_ascending)? $this->last_offset : $this->start_offset;
        $postings = $this->getGenerationPostings($this->generation_pointer);
        if (empty($postings[$end_offset]) ) {
            return false;
        }
        $last_doc = $postings[$end_offset]["DOC_MAP_INDEX"];
        if (($is_ascending && $last_doc < $doc_offset) ||
           (!$is_ascending && $last_doc > $doc_offset)) {
              return false;
        }
        $next_offset = ($this->next_offset ?? $this->current_offset);
        $last_offset = $next_offset;
        $next_doc = $postings[$next_offset]["DOC_MAP_INDEX"] ?? $doc_offset;
        $cmp = ($is_ascending) ?
            ($next_doc < $doc_offset && $next_offset <= $end_offset):
            ($next_doc > $doc_offset && $next_offset >= $end_offset);
        $delta = ($is_ascending) ? 1 : -1;
        while ($cmp)  {
            $last_offset = $next_offset;
            $next_offset += $delta;
            $delta *= 2;
            $next_doc = $postings[$next_offset]["DOC_MAP_INDEX"] ?? $doc_offset;
            $cmp = ($is_ascending) ?
                ($next_doc < $doc_offset && $next_offset <= $end_offset):
                ($next_doc > $doc_offset && $next_offset >= $end_offset);
        }
        if (($is_ascending && $next_offset > $end_offset) ||
            (!$is_ascending && $next_offset < $end_offset)) {
            $next_offset = $end_offset;
        }
        while(abs($next_offset - $last_offset) > 1) {
            $mid_offset = ($next_offset + $last_offset) >> 1;
            $mid_doc = $postings[$mid_offset]["DOC_MAP_INDEX"];
            $cmp = ($is_ascending) ?
                ($mid_doc < $doc_offset) : ($mid_doc > $doc_offset);
            if ($cmp) {
                $last_offset = $mid_offset;
            } else {
                $next_offset = $mid_offset;
                $next_doc = $mid_doc;
            }
        }
        if (abs($next_offset - $last_offset) == 1) {
            $next_doc = $postings[$next_offset]["DOC_MAP_INDEX"];
        }
        return [$next_offset, $next_doc];
    }
    /**
     * Forwards the iterator one group of docs. This is what's called
     * by @see advance($gen_doc_offset) if $gen_doc_offset is null
     */
    public function plainAdvance()
    {
        $is_ascending = ($this->direction == self::ASCENDING);
        $this->advanceSeenDocs();
        $this->current_doc_offset = null;
        $update_check = ($is_ascending) ?
            ($this->current_offset < $this->next_offset) :
            ($this->current_offset > $this->next_offset);
        if ($update_check) {
            $this->current_offset = $this->next_offset;
            $update_check = ($is_ascending) ?
                ($this->current_offset > $this->last_offset) :
                ($this->current_offset < $this->start_offset);
            if ($update_check) {
                $this->advanceGeneration();
                $this->next_offset = $this->current_offset;
            }
        } else {
            $this->advanceGeneration();
            $this->next_offset = $this->current_offset;
        }
    }
    /**
     * Switches which index shard is being used to return occurrences of
     * the word to the next shard containing the word
     *
     * @param int $generation generation to advance beyond
     */
    public function advanceGeneration($generation = null)
    {
        if ($generation === null) {
            $generation = $this->current_generation;
        }
        $this->generation_pointer ??= 0; //if not set set to 0
        $is_ascending = ($this->direction == self::ASCENDING);
        do {
            $gen_check = ($is_ascending) ?
                ($this->generation_pointer < $this->num_generations) :
                ($this->generation_pointer >= 0);
            if ($gen_check) {
                if ($is_ascending) {
                    $this->generation_pointer++;
                } else {
                    $this->generation_pointer--;
                }
            }
            $gen_check = ($is_ascending) ?
                $this->generation_pointer < $this->num_generations :
                $this->generation_pointer >= 0;
            if ($gen_check) {
                $partition_info =
                    $this->dictionary_info[$this->generation_pointer];
                $this->current_generation = $partition_info['PARTITION'];
                $this->start_offset = 0;
                $this->last_offset = ($partition_info['NUM_DOCS'] ?? 1) - 1;
                $this->current_offset = ($is_ascending) ? $this->start_offset:
                    $this->last_offset;
            }
            $gen_check = ($is_ascending) ?
                ($this->current_generation < $generation &&
                $this->generation_pointer < $this->num_generations) :
                ($this->current_generation > $generation &&
                $this->generation_pointer >= 0);
        } while($gen_check);
    }
    /**
     * Given a partition number in the the index's PartitionDocumentBundle
     * retrieves all the posting for the word iterator's term in that
     * partition.
     *
     * @param int $generation partition to get  postings for
     * @return array of posting items
     */
    public function getGenerationPostings($generation)
    {
        static $test_time = 0;
        if (empty($this->dictionary_info[$generation])) {
            return [];
        }
        $generation_info = $this->dictionary_info[$generation];
        if (!empty($generation_info['POSTINGS']) &&
            is_array($generation_info['POSTINGS'])) {
            return $generation_info['POSTINGS']; //already loaded
        }
        $index = IndexManager::getIndex($this->index_name);
        if (empty($generation_info['POSTINGS_OFFSET']) ||
            empty($generation_info['POSTINGS_LEN'])) {
            $postings_entry = "";
        } else {
            $postings_entry = $index->getPostingsString(
                $generation_info['PARTITION'],
                $generation_info['POSTINGS_OFFSET'],
                $generation_info['POSTINGS_LEN']);
        }
        $postings = [];
        if (!empty($postings_entry)) {
            $postings_info = $index->unpackPostings($postings_entry);
            if (!empty($postings_info)) {
                list($postings,) = $postings_info;
            }
        }
        $this->dictionary_info[$generation]['POSTINGS'] = $postings;
        return $postings;
    }
    /**
     * Gets the doc_offset and generation for the next document that
     * would be return by this iterator
     *
     * @return mixed an array with the desired document offset
     * and generation; -1 on fail
     */
    public function currentGenDocOffsetWithWord()
    {
        if ($this->current_doc_offset !== null) {
            return [$this->current_generation, $this->current_doc_offset];
        }
        $is_ascending = ($this->direction == self::ASCENDING);
        $offset_check = ($is_ascending) ?
            ($this->current_offset > $this->last_offset ||
            $this->generation_pointer >= $this->num_generations) :
            ($this->current_offset < $this->start_offset||
            $this->generation_pointer < -1);
        if ($offset_check ||
            empty($this->dictionary_info[$this->generation_pointer])) {
            return -1;
        } else {
            $partition_info = $this->dictionary_info[$this->generation_pointer];
            $this->current_generation = $partition_info['PARTITION'];
            $postings = $this->getGenerationPostings($this->generation_pointer);
            $this->current_doc_offset =
                $postings[$this->current_offset]['DOC_MAP_INDEX'] ?? -1;
        }
        return [$this->current_generation, $this->current_doc_offset];
    }
}
