<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core_feedback_features
 */

/*

This is a software sub-system (not specifically Conversr!) for both threaded and non-threaded topic rendering.

API interfaces with...
 Feedback system (calls 'render_as_comment_topic' method which knows about content types and content IDs)
  Or can be called standalone using topic IDs
 Forum driver system (which knows about topic_identifier's [a derivative of content types and content IDs] and plain topic IDs)

This API does not handle posting, although it can render a posting form. The feedback system and the cns_forum addon handle posting separately.


The chatrooms and activity feeds are NOT topics, and not handled through this system.
The non-threaded cns_forum view has its own rendering.

*/

/**
 * Manage threaded topics / comment topics.
 *
 * @package core_feedback_features
 */
class CMS_Topic
{
    // Settable...
    //Influences comment form
    public $reviews_rating_criteria = [];
    //Influences spacer post detection (usually only on first render, and only in the Conversr topicview)
    public $first_post_id = null;
    public $topic_description = null;
    public $topic_description_link = null;
    public $topic_info = null;
    public $rendering_context = 'cns';

    // Will be filled up during processing
    public $all_posts_ordered = null;
    public $is_threaded = null;
    public $topic_id = null; // May need setting, if posts were loaded in manually rather than letting the class load them; may be left as null but functionality degrades somewhat
    public $topic_last_read = null;
    public $reverse = false;

    // Will be filled up like 'return results'
    public $error = false;
    public $replied = false;
    public $total_posts = null;
    public $topic_title = null;

    /**
     * Constructor.
     */
    public function __construct()
    {
    }

    /**
     * Set a rendering context.
     *
     * @param  ID_TEXT $rendering_context Rendering context
     */
    public function set_rendering_context(string $rendering_context)
    {
        $this->rendering_context = $rendering_context;
    }

    /**
     * Render a comment topic.
     *
     * @param  ID_TEXT $content_type Content type to show topic for
     * @param  ID_TEXT $content_id Content ID of content type to show topic for
     * @param  boolean $invisible_if_no_comments Whether the comment box will be invisible if there are not yet any comments (and you're not staff)
     * @param  ?string $forum_name The name of the forum to use (null: default comment forum)
     * @param  ?string $post_warning The default post to use (null: standard courtesy warning)
     * @param  ?mixed $preloaded_comments The raw comment array (null: lookup). This is useful if we want to pass it through a filter
     * @param  ?boolean $reverse Whether to show in reverse date order (affects default search order only) (null: read config)
     * @param  ?MEMBER $highlight_by_member Member to highlight the posts of (null: none)
     * @param  boolean $allow_reviews Whether to allow ratings along with the comment (like reviews)
     * @param  ?integer $num_to_show_limit Maximum to load (null: default)
     * @param  ?Tempcode $hidden Hidden form fields for commenting form (null: none)
     * @return Tempcode The Tempcode for the comment topic
     */
    public function render_as_comment_topic(string $content_type, string $content_id, bool $invisible_if_no_comments, ?string $forum_name, ?string $post_warning, $preloaded_comments, ?bool $reverse, ?int $highlight_by_member, bool $allow_reviews, ?int $num_to_show_limit, ?object $hidden = null) : object
    {
        if ((get_forum_type() == 'cns') && (!addon_installed('cns_forum'))) {
            return new Tempcode();
        }
        if (get_forum_type() == 'none') {
            return new Tempcode();
        }

        if ($forum_name === null) {
            $forum_name = get_option('comments_forum_name');
        }

        $forum_id = $GLOBALS['FORUM_DRIVER']->forum_id_from_name($forum_name);
        if ($forum_id === null) {
            return new Tempcode(); // Could not even find forum
        }

        $topic_id = $GLOBALS['FORUM_DRIVER']->find_topic_id_for_topic_identifier($forum_name, $content_type . '_' . $content_id, do_lang('COMMENT'));

        // Settings we need
        $max_thread_depth = get_param_integer('max_thread_depth', intval(get_option('max_thread_depth')));
        if ($num_to_show_limit === null) {
            $num_to_show_limit = get_param_integer('max_comments', intval(get_option('comments_to_show_in_thread')));
        }
        $start = get_param_integer('start_comments', 0);

        // Load up posts from DB
        if ($preloaded_comments === null) {
            if (!$this->load_from_topic($topic_id, $num_to_show_limit, $start, $reverse)) {
                attach_message(do_lang_tempcode('MISSING_FORUM', escape_html($forum_name)), 'warn', false, true);
            }
        } else {
            $this->inject_posts_for_scoring_algorithm($preloaded_comments);
        }

        if (!$this->error) {
            if ((empty($this->all_posts_ordered)) && ($invisible_if_no_comments)) {
                return new Tempcode();
            }

            $may_reply = has_privilege(get_member(), 'comment', get_page_name());

            // Prepare review titles
            global $REVIEWS_STRUCTURE;
            if ($allow_reviews) {
                if (array_key_exists($content_type, $REVIEWS_STRUCTURE)) {
                    $this->set_reviews_rating_criteria($REVIEWS_STRUCTURE[$content_type]);
                } else {
                    $this->set_reviews_rating_criteria(['']);
                }
            }

            // Load up reviews
            if ((get_forum_type() == 'cns') && ($allow_reviews) && ($topic_id !== null)) {
                $all_individual_review_ratings = $GLOBALS['SITE_DB']->query_select('review_supplement', ['*'], ['r_topic_id' => $topic_id]);
            } else {
                $all_individual_review_ratings = [];
            }

            $topic_info = null;
            if ((get_forum_type() == 'cns') && ($topic_id !== null)) {
                $_topic_info = $GLOBALS['FORUM_DB']->query_select('f_topics', ['*'], ['id' => $topic_id], '', 1);
                if (array_key_exists(0, $_topic_info)) {
                    $topic_info = $_topic_info[0];
                }
            }

            // Posts
            list($posts, $serialized_options, $hash) = $this->render_posts($num_to_show_limit, $max_thread_depth, $may_reply, $highlight_by_member, $all_individual_review_ratings, $forum_id, $topic_info);

            // Pagination
            $pagination = null;
            if ((!$this->is_threaded) && ($preloaded_comments === null)) {
                if ($this->total_posts > $num_to_show_limit) {
                    require_code('templates_pagination');
                    $pagination = pagination(do_lang_tempcode('COMMENTS'), $start, 'start_comments', $num_to_show_limit, 'max_comments', $this->total_posts);
                }
            }

            // Environment metadata
            $this->inject_feed_url($forum_name, $content_type, $content_id);
            $this->inject_metadata();

            // Make-a-comment form
            if ($may_reply) {
                $post_url = get_self_url();
                $form = $this->get_posting_form($content_type, $content_id, $allow_reviews, $post_url, $post_warning, $hidden);
            } else {
                $form = new Tempcode();
            }

            // Existing review ratings
            $reviews_rating_criteria = [];
            if ((get_forum_type() == 'cns') && ($allow_reviews)) {
                foreach ($this->reviews_rating_criteria as $review_title) {
                    $rating = $GLOBALS['SITE_DB']->query_select_value('review_supplement', 'AVG(r_rating)', ['r_rating_type' => $review_title, 'r_topic_id' => $topic_id]);
                    $rating_count = $GLOBALS['SITE_DB']->query_select_value('review_supplement', 'COUNT(r_rating)', ['r_rating_type' => $review_title, 'r_topic_id' => $topic_id]);
                    $reviews_rating_criteria[] = [
                        'REVIEW_TITLE' => $review_title,
                        '_REVIEW_RATING' => ($rating === null) ? '' : float_to_raw_string($rating),
                        'REVIEW_RATING' => ($rating === null) ? '' : float_format($rating),
                        '_NUM_REVIEW_RATINGS' => ($rating === null) ? '' : strval($rating_count),
                        'NUM_REVIEW_RATINGS' => ($rating === null) ? '' : integer_format($rating_count, 0),
                    ];
                    if ($rating !== null) {
                        set_extra_request_metadata([
                            'rating' => float_to_raw_string($rating),
                        ]);
                    }
                }
            }

            // Direct links to forum
            $forum_url = ($topic_id === null) ? '' : $GLOBALS['FORUM_DRIVER']->topic_url($topic_id, $forum_name, true);
            if (($GLOBALS['FORUM_DRIVER']->is_staff(get_member())) || ($forum_name == get_option('comments_forum_name'))) {
                $authorised_forum_url = $forum_url;
            } else {
                $authorised_forum_url = '';
            }

            // Show it all
            $sort = $this->_get_sort_order($reverse);
            return do_template('COMMENTS_WRAPPER', [
                '_GUID' => 'a89cacb546157d34vv0994ef91b2e707',
                'PAGINATION' => $pagination,
                'TYPE' => $content_type,
                'ID' => $content_id,
                'REVIEW_RATING_CRITERIA' => $reviews_rating_criteria,
                'FORUM_LINK' => $forum_url,
                'AUTHORISED_FORUM_URL' => $authorised_forum_url,
                'FORM' => $form,
                'COMMENTS' => $posts,
                'HASH' => $hash,
                'SERIALIZED_OPTIONS' => $serialized_options,
                'SORT' => $sort,
                'TOTAL_POSTS' => ($this->total_posts === null) ? '0' : strval($this->total_posts),
                'IS_THREADED' => $this->is_threaded,
            ]);
        }

        return new Tempcode();
    }

    /**
     * Render posts from a topic (usually tied into AJAX, to get iterative results).
     *
     * @param  AUTO_LINK $topic_id The topic ID
     * @param  integer $num_to_show_limit Maximum to load
     * @param  boolean $allow_comments Whether this resource allows comments (if not, this function does nothing - but it's nice to move out this common logic into the shared function)
     * @param  boolean $invisible_if_no_comments Whether the comment box will be invisible if there are not yet any comments (and you're not staff)
     * @param  ?string $forum_name The name of the forum to use (null: default comment forum)
     * @param  ?mixed $preloaded_comments The raw comment array (null: lookup). This is useful if we want to pass it through a filter
     * @param  boolean $reverse Whether to reverse the posts
     * @param  boolean $may_reply Whether the current user may reply to the topic (influences what buttons show)
     * @param  ?MEMBER $highlight_by_member Member to highlight the posts of (null: none)
     * @param  boolean $allow_reviews Whether to allow ratings along with the comment (like reviews)
     * @param  array $posts List of post IDs to load
     * @param  AUTO_LINK $parent_id Parent node being loaded to
     * @return Tempcode The Tempcode for the comment topic
     */
    public function render_posts_from_topic(int $topic_id, int $num_to_show_limit, bool $allow_comments, bool $invisible_if_no_comments, ?string $forum_name, $preloaded_comments, bool $reverse, bool $may_reply, ?int $highlight_by_member, bool $allow_reviews, array $posts, int $parent_id) : object
    {
        if ((get_forum_type() == 'cns') && (!addon_installed('cns_forum'))) {
            return new Tempcode();
        }
        if (get_forum_type() == 'none') {
            return new Tempcode();
        }

        $max_thread_depth = get_param_integer('max_thread_depth', intval(get_option('max_thread_depth')));
        $start = 0;

        // Load up posts from DB
        if (!$this->load_from_topic($topic_id, $num_to_show_limit, $start, $reverse, $posts)) {
            attach_message(do_lang_tempcode('MISSING_FORUM', escape_html($forum_name)), 'warn', false, true);
        }

        if (!$this->error) {
            if ((empty($this->all_posts_ordered)) && ($invisible_if_no_comments)) {
                return new Tempcode();
            }

            // Prepare review titles
            $this->set_reviews_rating_criteria(['']);

            // Load up reviews
            if ((get_forum_type() == 'cns') && ($allow_reviews)) {
                $all_individual_review_ratings = $GLOBALS['SITE_DB']->query_select('review_supplement', ['*'], ['r_topic_id' => $topic_id]);
            } else {
                $all_individual_review_ratings = [];
            }

            $forum_id = $GLOBALS['FORUM_DRIVER']->forum_id_from_name($forum_name);

            $topic_info = null;
            if (get_forum_type() == 'cns') {
                $_topic_info = $GLOBALS['FORUM_DB']->query_select('f_topics', ['*'], ['id' => $topic_id], '', 1);
                if (!array_key_exists(0, $_topic_info)) {
                    warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
                }
                $topic_info = $_topic_info[0];
            }

            // Render
            $rendered = $this->render_posts($num_to_show_limit, $max_thread_depth, $may_reply, $highlight_by_member, $all_individual_review_ratings, $forum_id, $topic_info, $parent_id, true);
            $ret = $rendered[0];
            return $ret;
        }

        return new Tempcode();
    }

    /**
     * Get the sort order.
     *
     * @param  ?boolean $reverse Whether to show in reverse date order (affects default search order only) (null: read config)
     * @return ID_TEXT Sort order
     * @set relevance rating newest oldest
     */
    protected function _get_sort_order(?bool $reverse) : string
    {
        static $sort = null;

        if ($sort === null) {
            $_default_sort_order = get_option('default_comment_sort_order');
            if ($reverse === null) {
                $default_sort_order = $_default_sort_order;
            } else {
                $default_sort_order = ($this->is_threaded && $reverse) ? 'relevance' : ($reverse ? 'newest' : 'oldest');
            }

            $sort = either_param_string('comments_sort', $default_sort_order);

            if (!in_array($sort, ['relevance', 'newest', 'oldest', 'average_rating', 'compound_rating'])) {
                $sort = $default_sort_order;
            }
        }

        return $sort;
    }

    /**
     * Load from a given topic ID.
     *
     * @param  ?AUTO_LINK $topic_id Topic ID (null: none yet, set up empty structure)
     * @param  integer $num_to_show_limit Maximum to load
     * @param  integer $start Pagination start if non-threaded
     * @param  ?boolean $reverse Whether to show in reverse date order (affects default search order only) (null: read config)
     * @param  ?array $posts List of post IDs to load (null: no filter)
     * @param  boolean $load_spacer_posts_too Whether to allow spacer posts to flow through the renderer
     * @return boolean Success status
     */
    public function load_from_topic(?int $topic_id, int $num_to_show_limit, int $start = 0, ?bool $reverse = null, ?array $posts = null, bool $load_spacer_posts_too = false) : bool
    {
        $this->topic_id = $topic_id;
        $this->topic_last_read = (is_guest() || get_forum_type() != 'cns') ? null : $GLOBALS['FORUM_DB']->query_select_value_if_there('f_read_logs', 'l_time', ['l_member_id' => get_member(), 'l_topic_id' => $this->topic_id]);
        $this->reverse = $reverse;

        if (get_param_integer('threaded', null) === 1) {
            $this->is_threaded = true;
        } else {
            $this->is_threaded = $GLOBALS['FORUM_DRIVER']->topic_is_threaded($topic_id);
        }

        $sort = $this->_get_sort_order($reverse);
        $_sort = $sort; // Passed through forum layer, we need to run a translation...
        if ($sort == 'newest') {
            $_sort = 'date';
            $reverse = true;
        } elseif ($sort == 'oldest') {
            $_sort = 'date';
            $reverse = false;
        } elseif (($sort == 'relevance') || ($sort == 'average_rating')) {
            $_sort = 'average_rating';
            $reverse = true;
        } elseif ($sort == 'compound_rating') {
            $_sort = 'compound_rating';
            $reverse = true;
        }

        require_code('users');
        if ($topic_id === null) {
            $posts = [];
        } else {
            $posts = $GLOBALS['FORUM_DRIVER']->get_forum_topic_posts(
                $topic_id,
                $this->total_posts,
                $this->is_threaded ? 5000 : $num_to_show_limit,
                $this->is_threaded ? 0 : $start,
                get_forum_type() == 'cns' && $GLOBALS['FORUM_DRIVER']->get_member_row_field(get_member(), 'm_auto_mark_read') == 1, // $mark_read,
                $reverse,
                true,
                $posts,
                $load_spacer_posts_too,
                $_sort
            );
        }

        if ($posts !== -1) {
            if ($posts === -2) {
                $posts = [];
            }

            // Check if threaded mode and post map enabled, orphan any children of the first post if so - because
            // we don't want any replies to appear before the post map
            if ($this->is_threaded && (get_option('is_on_post_map') == '1')) {
                foreach ($posts as &$post) {
                    if ($post['parent_id'] === $this->first_post_id) {
                        $post['parent_id'] = null;
                        $post['p_parent_id'] = null;
                    }
                }
            }

            $this->inject_posts_for_scoring_algorithm($posts);

            return true;
        }

        $this->error = true;
        return false;
    }

    /**
     * Put in posts to our scoring algorithm in preparation for shooting out later.
     *
     * @param  array $posts Review titles
     */
    public function inject_posts_for_scoring_algorithm(array $posts)
    {
        $all_posts_ordered = [];
        foreach ($posts as $post) {
            if ($post === null) {
                continue;
            }

            if (!isset($post['parent_id'])) {
                $post['parent_id'] = null;
            }
            if (!isset($post['id'])) {
                $post['id'] = mt_rand(0, mt_getrandmax());
            }

            $post_key = 'post_' . strval($post['id']);
            $all_posts_ordered[$post_key] = $post;
        }
        $this->all_posts_ordered = $all_posts_ordered;
    }

    /**
     * Set the particular review criteria we'll be dealing with.
     *
     * @param  array $reviews_rating_criteria Review criteria
     */
    public function set_reviews_rating_criteria(array $reviews_rating_criteria)
    {
        $this->reviews_rating_criteria = $reviews_rating_criteria;
    }

    /**
     * Render a topic's posts.
     *
     * @param  ?integer $num_to_show_limit Number of posts to show initially (null: no limit)
     * @param  integer $max_thread_depth Maximum thread depth
     * @param  boolean $may_reply Whether the current user may reply to the topic (influences what buttons show)
     * @param  ?MEMBER $highlight_by_member Member to highlight the posts of (null: none)
     * @param  array $all_individual_review_ratings Review ratings rows
     * @param  AUTO_LINK $forum_id ID of forum this topic in in
     * @param  ?array $topic_info The topic row (null: not running Conversr)
     * @param  ?AUTO_LINK $parent_post_id Only show posts under here (null: show posts from root)
     * @param  boolean $maybe_missing_links Whether to just render everything as flat (used when doing AJAX post loading). NOT actually used since we wrote better post-orphaning-fixing code.
     * @return array Tuple: Rendered topic, serialized options to render more posts, secure hash of serialized options to prevent tampering
     */
    public function render_posts(?int $num_to_show_limit, int $max_thread_depth, bool $may_reply, ?int $highlight_by_member, array $all_individual_review_ratings, int $forum_id, ?array $topic_info, ?int $parent_post_id = null, bool $maybe_missing_links = false) : array
    {
        require_code('feedback');

        if ((get_forum_type() == 'cns') && (!addon_installed('cns_forum'))) {
            return [];
        }

        $posts = [];
        $queue = $this->all_posts_ordered;
        if (($parent_post_id !== null) && (!$maybe_missing_links)) {
            $queue = $this->_grab_at_and_underneath($parent_post_id, $queue);
        }
        if ($this->is_threaded === null) {
            $this->is_threaded = false;
        }
        if (($num_to_show_limit === null) || (!$this->is_threaded)) {
            $posts = $queue;
            $queue = [];
        } else {
            $posts = $this->_decide_what_to_render($num_to_show_limit, $queue);
        }

        // Work out sequence numbers
        $post_numbers = [];
        foreach ($posts as $i => $post) {
            $post_numbers[$post['id']] = $i;
        }
        ksort($post_numbers);
        $x = 1;
        foreach ($post_numbers as $post_id => $i) {
            $posts[$i]['number'] = $x;
            $x++;
        }

        // Precache member/group details in one fell swoop
        if (get_forum_type() == 'cns') {
            require_code('cns_topicview');
            $members = [];
            foreach ($posts as $_postdetails) {
                $members[$_postdetails['p_posting_member']] = 1;
            }
            cns_cache_member_details(array_keys($members));
        }

        if ($this->topic_id !== null) { // If FALSE then Posts will have been passed in manually as full already anyway
            $posts = $this->_grab_full_post_details($posts);
        }

        if ($this->is_threaded) {
            $tree = $this->_arrange_posts_in_tree($parent_post_id, $posts/*passed by reference*/, $queue, $max_thread_depth);
            if (!empty($posts)) { // E.g. if parent was deleted at some time
                $sort = $this->_get_sort_order($this->reverse);
                switch ($sort) {
                    case 'newest':
                        sort_maps_by($posts, '!date');
                        break;
                    case 'oldest':
                        sort_maps_by($posts, 'date');
                        break;
                    case 'compound_rating':
                        sort_maps_by($posts, 'compound_rating,!date');
                        break;
                    case 'average_rating':
                    case 'relevance':
                        sort_maps_by($posts, 'average_rating,!date');
                        break;
                }

                while (!empty($posts)) {
                    $orphaned_post = array_shift($posts);

                    $tree2 = $this->_arrange_posts_in_tree($orphaned_post['id'], $posts/*passed by reference*/, $queue, $max_thread_depth);

                    $orphaned_post['parent_id'] = null;
                    $orphaned_post['children'] = $tree2;
                    $tree[0][] = $orphaned_post;
                }
            }
        } else {
            $tree = [$posts];
        }

        $ret = $this->_render_post_tree($num_to_show_limit, $tree, $may_reply, $highlight_by_member, $all_individual_review_ratings, $forum_id, $topic_info);

        $other_ids = null;
        if ($this->is_threaded) {
            $other_ids = [];
            foreach ($tree[1] as $u) {
                $other_ids[] = strval($u['id']);
            }
        }
        $ret->attach(do_template('POST_CHILD_LOAD_LINK', ['_GUID' => '79e1f3feec7a6d48cd554b41e831b287', 'NUM_TO_SHOW_LIMIT' => strval($num_to_show_limit), 'OTHER_IDS' => $other_ids, 'ID' => '', 'CHILDREN' => (($other_ids === null) || (empty($other_ids))) ? '' : '1']));

        if ($this->topic_id !== null) {
            $serialized_options = json_encode([$this->topic_id, $num_to_show_limit, true, false, strval($forum_id), $this->reverse, $may_reply, $highlight_by_member, !empty($all_individual_review_ratings)]);
            require_code('crypt');
            $hash = ratchet_hash($serialized_options, get_site_salt());
        } else {
            $serialized_options = null;
            $hash = null;
        }

        return [$ret, $serialized_options, $hash];
    }

    /**
     * Filter posts, deciding what to render.
     *
     * @param  integer $num_to_show_limit Number of posts to show initially
     * @param  array $queue Posts to choose from, in preference order
     * @return array Chosen posts
     */
    protected function _decide_what_to_render(int $num_to_show_limit, array &$queue) : array
    {
        $posts = [];
        while ((count($posts) < $num_to_show_limit) && (!empty($queue))) {
            $next = reset($queue);

            if ($next['p_posting_member'] == get_member()) {
                $this->replied = true;
            }

            $post_id = $next['id'];
            $this->_grab_at_and_above_and_remove($post_id, $queue, $posts);
        }

        $sort = $this->_get_sort_order(false);
        if ($sort == 'relevance') {
            // Any posts by current member must be grabbed too (up to 3 root ones though - otherwise risks performance), and also first post
            $num_poster_grabbed = 0;
            foreach ($queue as $i => $q) {
                if ((($q['p_posting_member'] == get_member()) && ($q['parent_id'] === null) && ($num_poster_grabbed < 3)) || ($q['id'] === $this->first_post_id)) {
                    $this->replied = true;
                    if ($q['id'] === $this->first_post_id) { // First post must go first
                        $posts_backup = $posts;
                        $posts = [];
                        $posts['post_' . strval($q['id'])] = $q;
                        $posts += $posts_backup;
                    } else {
                        $posts['post_' . strval($q['id'])] = $q;
                    }
                    if ($q['p_posting_member'] == get_member()) {
                        $num_poster_grabbed++;
                    }
                    unset($queue[$i]);
                }
            }
        }

        return $posts;
    }

    /**
     * Grab posts at or above a reference post and remove from queue.
     *
     * @param  AUTO_LINK $post_id Reference post in thread
     * @param  array $queue Posts to choose from (the queue)
     * @param  array $posts Posts picked out (passed by reference)
     */
    protected function _grab_at_and_above_and_remove(int $post_id, array &$queue, array &$posts)
    {
        if ((!isset($posts[$post_id])) && (isset($queue['post_' . strval($post_id)]))) {
            $grabbed = $queue['post_' . strval($post_id)];
            if ($post_id === $this->first_post_id) { // First post must go first
                $posts_backup = $posts;
                $posts = [];
                $posts['post_' . strval($post_id)] = $grabbed;
                $posts += $posts_backup;
            } else {
                $posts['post_' . strval($post_id)] = $grabbed;
            }
            unset($queue['post_' . strval($post_id)]);
            $parent = $grabbed['parent_id'];
            if ($parent !== null) {
                $this->_grab_at_and_above_and_remove($parent, $queue, $posts);
            }
        }
    }

    /**
     * Grab posts at or underneath a reference post.
     *
     * @param  ?AUTO_LINK $parent_post_id Reference post in thread (null: root)
     * @param  array $posts_in Posts to choose from
     * @return array Relevant posts
     */
    protected function _grab_at_and_underneath(?int $parent_post_id, array $posts_in) : array
    {
        $posts_out = [];

        if ($parent_post_id !== null) {
            if (isset($posts_in['post_' . strval($parent_post_id)])) {
                $grabbed = $posts_in['post_' . strval($parent_post_id)];
                $posts_out['post_' . strval($parent_post_id)] = $grabbed;
            }
        }

        // Underneath
        foreach ($posts_in as $x) {
            if ($x['parent_id'] === $parent_post_id) {
                $underneath = $this->_grab_at_and_underneath($x['id'], $posts_in);
                foreach ($underneath as $id => $y) {
                    $posts_out['post_' . strval($id)] = $y;
                }
            }
        }

        return $posts_out;
    }

    /**
     * Load full details for posts (we had not done so far to preserve memory).
     *
     * @param  array $posts Posts to load
     * @return array Upgraded posts
     */
    protected function _grab_full_post_details(array $posts) : array
    {
        $id_list = [];
        foreach ($posts as $p) {
            if (!isset($p['post'])) {
                $id_list[] = $p['id'];
            }
        }
        $posts_extended = list_to_map('id', $GLOBALS['FORUM_DRIVER']->get_post_remaining_details($this->topic_id, $id_list));
        foreach ($posts as $i => $p) {
            if (isset($posts_extended[$p['id']])) {
                $posts[$i] += $posts_extended[$p['id']];
            }
        }
        return $posts;
    }

    /**
     * Arrange posts underneath a post in the thread (not including the post itself).
     *
     * @param  ?AUTO_LINK $post_id Reference post in thread (null: root)
     * @param  array $posts Posts we will be rendering and have not arranged yet (only some of which will be underneath $post_id)
     * @param  array $queue Posts we won't be rendering
     * @param  integer $max_thread_depth Maximum depth to render to
     * @param  integer $depth Current depth in recursion
     * @return array Array structure of rendered posts
     */
    protected function _arrange_posts_in_tree(?int $post_id, array &$posts, array $queue, int $max_thread_depth, int $depth = 0) : array
    {
        $rendered = [];
        $non_rendered = [];

        $posts_copy = $posts; // So the foreach's array iteration pointer is not corrupted by the iterations in our recursive calls (issue on some PHP versions)
        foreach ($posts_copy as $i => $p) {
            if ($p['parent_id'] === $post_id) {
                unset($posts[$i]);

                $children = $this->_arrange_posts_in_tree($p['id'], $posts, $queue, $max_thread_depth, $depth + 1);

                if ($depth + 1 >= $max_thread_depth) { // Ones that are too deep need flattening down with post Comcode
                    foreach ($children[0] as $j => $c) {
                        if (strpos($c['message_comcode'], '[quote') === false) {
                            $c['message_comcode'] = '[quote="' . comcode_escape($p['username']) . '"]' . $p['message_comcode'] . '[/quote]' . "\n\n" . $c['message_comcode'];
                            $new = do_template('COMCODE_QUOTE_BY', ['_GUID' => 'e5c1b08d7ca2368954e9c2f032044218', 'SAIDLESS' => false, 'BY' => $p['username'], 'CONTENT' => $p['message']]);
                            $new->attach($c['message']);
                            $c['message'] = $new;
                        }
                        $c['parent_id'] = $p['parent_id'];
                        $children[0][$j] = $c;
                    }

                    $p['children'] = [[], []];
                    $rendered[] = $p;
                    $rendered = array_merge($rendered, $children[0]);
                    $non_rendered = array_merge($non_rendered, $children[1]);
                } else {
                    $p['children'] = $children;
                    $rendered[] = $p;
                }
            }
        }

        $non_rendered = array_merge($non_rendered, $this->_grab_at_and_underneath($post_id, $queue));

        return [$rendered, $non_rendered];
    }

    /**
     * Render posts.
     *
     * @param  integer $num_to_show_limit Maximum to load
     * @param  array $tree Tree structure of posts
     * @param  boolean $may_reply Whether the current user may reply to the topic (influences what buttons show)
     * @param  ?AUTO_LINK $highlight_by_member Only show posts under here (null: show posts from root)
     * @param  array $all_individual_review_ratings Review ratings rows
     * @param  AUTO_LINK $forum_id ID of forum this topic in in
     * @param  ?array $topic_info The topic row (null: not running Conversr)
     * @param  integer $depth The recursion depth
     * @return Tempcode Rendered tree structure
     */
    protected function _render_post_tree(int $num_to_show_limit, array $tree, bool $may_reply, ?int $highlight_by_member, array $all_individual_review_ratings, int $forum_id, ?array $topic_info, int $depth = 0) : object
    {
        list($rendered,) = $tree;
        $sequence = new Tempcode();
        foreach ($rendered as $post) {
            if (get_forum_type() == 'cns') {
                require_code('cns_topicview');
                require_code('cns_posts');
                $only_post = (($depth == 0) && (count($rendered) == 1) && ((!isset($post['children'])) || (empty($post['children']))));
                $post += cns_get_details_to_show_post($post, $topic_info, $only_post);
            }

            // Misc details
            $timestamp = $post['date'];
            $date = get_timezoned_date_time($post['date']);
            $poster_url = is_guest($post['member']) ? new Tempcode() : $GLOBALS['FORUM_DRIVER']->member_profile_url($post['member'], true);
            $poster_name = array_key_exists('username', $post) ? $post['username'] : $GLOBALS['FORUM_DRIVER']->get_username($post['member']);
            $highlight = ($highlight_by_member === $post['member']);

            // Find review, if there is one
            $individual_review_ratings = [];
            foreach ($all_individual_review_ratings as $potential_individual_review_rating) {
                if ($potential_individual_review_rating['r_post_id'] == $post['id']) {
                    $individual_review_ratings[$potential_individual_review_rating['r_rating_type']] = [
                        'REVIEW_TITLE' => $potential_individual_review_rating['r_rating_type'],
                        'REVIEW_RATING' => strval($potential_individual_review_rating['r_rating']),
                    ];
                }
            }

            // Edit URL
            $emphasis = new Tempcode();
            $buttons = new Tempcode();
            $last_edited = new Tempcode();
            $last_edited_raw = '';
            $not_validated = new Tempcode();
            $poster = null;
            $poster_details = new Tempcode();
            $is_spacer_post = false;
            $sig = new Tempcode();
            if (get_forum_type() == 'cns') {
                // Spacer post fiddling
                if (($this->first_post_id !== null) && ($post['id'] == $this->first_post_id) && ($this->topic_title !== null) && ($this->topic_description !== null) && ($this->topic_description_link !== null)) {
                    list($is_spacer_post, $spacer_post_lang) = is_spacer_post($post['message_comcode']);

                    if ($is_spacer_post) {
                        $c_prefix = do_lang('COMMENT', null, null, null, $spacer_post_lang) . ': #';
                        if ((substr($this->topic_description, 0, strlen($c_prefix)) == $c_prefix) && ($this->topic_description_link != '')) {
                            list($linked_type, $linked_id) = explode('_', substr($this->topic_description, strlen($c_prefix)), 2);
                            $linked_url = $this->topic_description_link;

                            require_code('cns_posts');
                            list($new_description, $new_post) = cns_display_spacer_post($linked_type, $linked_id);
                            //if ($new_description !== null) $this->topic_description=$new_description; Actually, it's a bit redundant
                            if ($new_post !== null) {
                                $post['message'] = $new_post;
                            }
                            $highlight = false;

                            $this->topic_title = do_lang('SPACER_TOPIC_TITLE_WRAP', $this->topic_title, '', '', $spacer_post_lang);
                            $post['title'] = do_lang('SPACER_TOPIC_TITLE_WRAP', $post['title'], '', '', $spacer_post_lang);
                            $this->topic_description = '';
                        }
                    }
                }

                // Misc meta details for post
                list($post_class, $emphasis) = cns_get_post_emphasis($post, $topic_info);
                $not_validated = ($post['validated'] == 0) ? do_lang_tempcode('NOT_VALIDATED') : new Tempcode();
                if (array_key_exists('last_edit_time', $post)) {
                    $last_edited = do_template('CNS_TOPIC_POST_LAST_EDITED', [
                        '_GUID' => '6301ad8d8f80948ad8270828f1bdaf33',
                        'LAST_EDIT_DATE_RAW' => ($post['last_edit_time'] === null) ? '' : strval($post['last_edit_time']),
                        'LAST_EDIT_DATE' => $post['last_edit_date'],
                        'LAST_EDIT_PROFILE_URL' => $GLOBALS['FORUM_DRIVER']->member_profile_url($post['last_edit_member'], true),
                        'LAST_EDIT_USERNAME' => $post['last_edit_username'],
                    ]);
                    $last_edited_raw = (($post['last_edit_time'] === null) ? '' : strval($post['last_edit_time']));
                }

                // Post buttons
                if (!$is_spacer_post) {
                    if ($this->topic_id !== null) {
                        if ($this->topic_info === null) {
                            $this->topic_info = cns_read_in_topic($this->topic_id, 0, 0, false, false);
                        }
                        require_lang('cns');
                        $buttons = cns_render_post_buttons($this->topic_info, $post, $may_reply, $this->rendering_context);
                    }
                }

                $is_banned = ($GLOBALS['CNS_DRIVER']->get_member_row_field($post['member'], 'm_is_perm_banned') != '0');

                // Signature
                require_code('cns_posts');
                if ((($GLOBALS['CNS_DRIVER']->get_member_row_field(get_member(), 'm_views_signatures') == 1) || (get_option('enable_views_sigs_option', true) === '0')) && (!isset($post['skip_sig'])) && ($post['skip_sig'] == 0) && (addon_installed('cns_signatures')) && (!$is_banned)) {
                    global $SIGNATURES_CACHE;

                    if (array_key_exists($post['member'], $SIGNATURES_CACHE)) {
                        $sig = $SIGNATURES_CACHE[$post['member']];
                    } else {
                        $member_row = $GLOBALS['CNS_DRIVER']->get_member_row($post['member']);
                        $just_member_row = db_map_restrict($member_row, ['id', 'm_signature']);
                        $sig = get_translated_tempcode('f_members', $just_member_row, 'm_signature', $GLOBALS['FORUM_DB']);
                        $SIGNATURES_CACHE[$post['member']] = $sig;
                    }
                }

                // Conversr renderings of poster
                if (!$is_spacer_post) {
                    if (!is_guest($post['member'])) {
                        require_code('cns_members2');
                        $poster_details = render_member_box($post['member'], false, false, [], false);
                    } else {
                        $custom_fields = new Tempcode();
                        if ((array_key_exists('ip_address', $post)) && (addon_installed('cns_forum'))) {
                            $custom_fields->attach(do_template('CNS_MEMBER_BOX_CUSTOM_FIELD', [
                                '_GUID' => 'f7e62822e879682cf1588d9f49484bfa',
                                'NAME' => do_lang_tempcode('IP_ADDRESS'),
                                'RAW' => $post['ip_address'],
                                'VALUE' => $post['ip_address'],
                                'MEMBER_ID' => strval($post['member']),
                            ]));
                            $poster_details = do_template('CNS_GUEST_DETAILS', ['_GUID' => 'df42e7d5003834a60fdb3bf476b393c5', 'CUSTOM_FIELDS' => $custom_fields]);
                        } else {
                            $poster_details = new Tempcode();
                        }
                    }
                }
                if (addon_installed('cns_forum')) {
                    require_code('users2');
                    if (!is_guest($post['member'])) {
                        $poster = do_template('CNS_POSTER_MEMBER', [
                            '_GUID' => 'da673c38b3cfbe9bf53d4334ca0eacfd',
                            'ONLINE' => member_is_online($post['member']),
                            'ID' => strval($post['member']),
                            'POSTER_DETAILS' => $poster_details,
                            'PROFILE_URL' => $GLOBALS['FORUM_DRIVER']->member_profile_url($post['member'], true),
                            'POSTER_USERNAME' => $post['poster_username'],
                            'HIGHLIGHT_NAME' => false,
                        ]);
                    } else {
                        $ip_url = ((array_key_exists('ip_address', $post)) && (has_actual_page_access(get_member(), 'admin_lookup'))) ? build_url(['page' => 'admin_lookup', 'param' => $post['ip_address']], get_module_zone('admin_lookup')) : new Tempcode();
                        $poster = do_template('CNS_POSTER_GUEST', [
                            '_GUID' => '93107543c6a0138f379e7124b72b24ff',
                            'LOOKUP_IP_URL' => $ip_url,
                            'POSTER_DETAILS' => $poster_details,
                            'POSTER_USERNAME' => $post['poster_username'],
                        ]);
                        $poster_url = $ip_url;
                    }
                } else {
                    $poster = make_string_tempcode(escape_html($post['poster_username']));
                }
            }

            // Child posts
            $children = null; // null
            $other_ids = [];
            if (array_key_exists('children', $post)) {
                foreach ($post['children'][1] as $u) {
                    $other_ids[] = strval($u['id']);
                }
                if ($this->is_threaded) {
                    $children = $this->_render_post_tree($num_to_show_limit, $post['children'], $may_reply, $highlight_by_member, $all_individual_review_ratings, $forum_id, $topic_info, $depth + 1);
                }
            }

            // Ratings
            if (get_forum_type() == 'cns') {
                require_code('feedback');
                actualise_rating(true, 'post', strval($post['id']), get_self_url(), $post['title']);
                $rating = display_rating(get_self_url(), $post['title'], 'post', strval($post['id']), 'RATING_INLINE_DYNAMIC', $post['member']);
            } else {
                $rating = new Tempcode();
            }

            // Mark read
            if (array_key_exists('whisper_to_member', $post)) {
                // Has now read
                decache_private_topics(get_member());
            }

            // Render...

            $is_unread = ($this->topic_last_read === null) || ($this->topic_last_read <= $post['date']) || ((get_forum_type() == 'cns') && ($this->topic_last_read <= $post['p_last_edit_time']));
            if ($post['member'] == get_member()) {
                $is_unread = false;
            }
            $post_tempcode = do_template('POST', [
                '_GUID' => 'eb7df038959885414e32f58e9f0f9f39',
                'INDIVIDUAL_REVIEW_RATINGS' => $individual_review_ratings,
                'HIGHLIGHT' => $highlight,
                'TITLE' => $post['title'],
                'TIME_RAW' => strval($timestamp),
                'TIME' => $date,
                'POSTER_ID' => strval($post['member']),
                'POSTER_URL' => $poster_url,
                'POSTER_NAME' => $poster_name,
                'POSTER' => $poster,
                'POSTER_DETAILS' => $poster_details,
                'ID' => strval($post['id']),
                'POST' => $post['message'],
                'POST_COMCODE' => isset($post['message_comcode']) ? $post['message_comcode'] : null,
                'POST_NUMBER' => strval($post['number']),
                'CHILDREN' => $children,
                'OTHER_IDS' => (empty($other_ids)) ? null : $other_ids,
                'RATING' => $rating,
                'EMPHASIS' => $emphasis,
                'BUTTONS' => $buttons,
                'LAST_EDITED_RAW' => $last_edited_raw,
                'LAST_EDITED' => $last_edited,
                'TOPIC_ID' => ($this->topic_id === null) ? '' : strval($this->topic_id),
                'NOT_VALIDATED' => $not_validated,
                'IS_SPACER_POST' => $is_spacer_post,
                'NUM_TO_SHOW_LIMIT' => strval($num_to_show_limit),
                'SIGNATURE' => $sig->is_empty() ? null : $sig,
                'IS_UNREAD' => $is_unread,
                'IS_THREADED' => $this->is_threaded,
            ]);

            if ($this->is_threaded && ($this->first_post_id === $post['id']) && (count($rendered) > 1) && (get_forum_type() === 'cns') && (get_option('is_on_post_map') === '1')) { // If threaded mode, first post with replies, forum type is CNS and post map feature is enabled
                $this->set_level_has_adjacent_sibling($rendered);

                $items = new Tempcode();

                foreach (array_slice($rendered, 1) as $p) { // Slice off first post because we're listing replies
                    $items->attach($this->render_post_map_item($p));
                }

                $post_tempcode->attach(do_template('CNS_POST_MAP', [
                    '_GUID' => '9fe6a70073284f7d9028c2425948e13a',
                    'ITEMS' => $items,
                ]));
            }

            $sequence->attach($post_tempcode);
        }

        return $sequence;
    }

    /**
     * Sets a level_has_adjacent_sibling property on posts, which is a single-dimensional array.
     * with a boolean value for every parent post and the post itself containing whether it has an adjacent sibling.
     *
     * @param  array $posts An array of posts
     * @param  array $level_has_adjacent_sibling For internal use only
     */
    protected function set_level_has_adjacent_sibling(array &$posts, array $level_has_adjacent_sibling = [])
    {
        foreach ($posts as $i => &$post) {
            $post['level_has_adjacent_sibling'] = $level_has_adjacent_sibling;
            $post['level_has_adjacent_sibling'][] = array_key_exists($i + 1, $posts);

            $this->set_level_has_adjacent_sibling($post['children'][0], $post['level_has_adjacent_sibling']);
        }
    }

    /**
     * Renders the post map items to a tempcode object.
     *
     * @param  array $post A post
     * @return Tempcode
     */
    protected function render_post_map_item(array $post) : object
    {
        $date = get_timezoned_date_time($post['date']);
        $poster_url = is_guest($post['member']) ? new Tempcode() : $GLOBALS['FORUM_DRIVER']->member_profile_url($post['member'], true);
        $poster_name = array_key_exists('username', $post) ? $post['username'] : $GLOBALS['FORUM_DRIVER']->get_username($post['member']);
        $is_unread = ($this->topic_last_read === null) || ($this->topic_last_read <= $post['date']) || (get_forum_type() == 'cns') && ($post['p_last_edit_time'] !== null) && ($this->topic_last_read <= $post['p_last_edit_time']);
        if ($post['p_posting_member'] == get_member()) {
            $is_unread = false;
        }

        $tempcode = do_template('CNS_POST_MAP_ITEM', [
            '_GUID' => '763f031c2c8d4af986ff38bc51c8f6f4',
            'TITLE' => $this->topic_title,
            'URL' => '#post-' . strval($post['id']),
            'POST_NUMBER' => strval($post['number']),
            'POSTER_ID' => strval($post['member']),
            'POSTER_IS_GUEST' => is_guest($post['member']),
            'POSTER_URL' => $poster_url,
            'POSTER_NAME' => $poster_name,
            'LEVEL_HAS_ADJACENT_SIBLING' => $post['level_has_adjacent_sibling'],
            'POST_LEVEL' => strval(count($post['level_has_adjacent_sibling']) - 1), // Calculate this post's level
            'DATE' => $date,
            'IS_UNREAD' => $is_unread,
        ]);

        foreach ($post['children'][0] as $child_post) {
            $tempcode->attach($this->render_post_map_item($child_post));
        }

        return $tempcode;
    }

    /**
     * Put comments RSS link into environment.
     *
     * @param  ID_TEXT $forum The forum we are working in
     * @param  ID_TEXT $type The content type the comments are for
     * @param  ID_TEXT $id The content ID the comments are for
     */
    public function inject_feed_url(string $forum, string $type, string $id)
    {
        inject_feed_url('?mode=comments&forum=' . urlencode($forum) . '&select=' . urlencode($type . '_' . $id), do_lang('COMMENTS'));
    }

    /**
     * Put posts count into environment.
     */
    public function inject_metadata()
    {
        set_extra_request_metadata([
            'numcomments' => strval(count($this->all_posts_ordered)),
        ]);
    }

    /**
     * Get a form for posting.
     *
     * @param  ID_TEXT $type The content type of what this posting will be for
     * @param  ID_TEXT $id The content ID of what this posting will be for
     * @param  boolean $allow_reviews Whether to accept reviews
     * @param  Tempcode $post_url URL where form submit will go
     * @param  ?string $post_warning The default post to use (null: standard courtesy warning)
     * @param  ?Tempcode $hidden Hidden form fields for commenting form (null: none)
     * @return Tempcode Posting form
     */
    public function get_posting_form(string $type, string $id, bool $allow_reviews, object $post_url, ?string $post_warning, ?object $hidden = null) : object
    {
        require_lang('comcode');

        require_javascript('editing');
        require_javascript('checking');
        require_javascript('plupload');
        require_css('widget_plupload');

        $emoticons = $GLOBALS['FORUM_DRIVER']->get_emoticon_chooser();

        $comment_text = get_option('comment_text');

        if ($hidden === null) {
            $hidden = new Tempcode();
            $hidden->attach(form_input_hidden('_block_id', 'non_block'));
        }

        if ($post_warning === null) {
            $post_warning = do_lang('POST_WARNING');
        }

        if (addon_installed('captcha')) {
            require_code('captcha');
            $use_captcha = use_captcha();
            if ($use_captcha) {
                generate_captcha();
            }
        } else {
            $use_captcha = false;
        }

        $title = do_lang_tempcode($allow_reviews ? 'POST_REVIEW' : 'MAKE_COMMENT');

        $join_bits = new Tempcode();
        if (is_guest()) {
            list($login_url, , $join_url) = get_login_url();
            $join_bits = do_lang_tempcode('JOIN_OR_LOGIN', escape_html(is_object($join_url) ? $join_url->evaluate() : $join_url), escape_html(is_object($login_url) ? $login_url->evaluate() : $login_url));
        }

        $reviews_rating_criteria = [];
        foreach ($this->reviews_rating_criteria as $review_title) {
            $reviews_rating_criteria[] = [
                'REVIEW_TITLE' => $review_title,
            ];
        }

        if ($this->is_threaded) {
            $post_warning = do_lang('THREADED_REPLY_NOTICE', $post_warning);
        }

        return do_template('COMMENTS_POSTING_FORM', [
            '_GUID' => 'c87025f81ee64c885f0ac545efa5f16c',
            'TITLE' => $title,
            'HIDDEN' => $hidden,
            'JOIN_BITS' => $join_bits,
            'TYPE' => $type,
            'ID' => $id,
            'USE_CAPTCHA' => $use_captcha,
            'GET_EMAIL' => false,
            'EMAIL_OPTIONAL' => true,
            'GET_TITLE' => (get_option('comment_topic_subject') == '1'),
            'TITLE_OPTIONAL' => true,
            'DEFAULT_TITLE' => '',
            'POST_WARNING' => $post_warning,
            'RULES_TEXT' => $comment_text,
            'ATTACHMENTS' => null,
            'ATTACH_SIZE_FIELD' => null,
            'TRUE_ATTACHMENT_UI' => false,
            'EMOTICONS' => $emoticons,
            'REVIEWS' => $allow_reviews,
            'REVIEW_RATING_CRITERIA' => $reviews_rating_criteria,
            'EXPAND_TYPE' => 'contract',
            'DISPLAY' => 'block',
            'FIRST_POST_URL' => '',
            'FIRST_POST' => '',
            'COMMENT_URL' => $post_url,
            'ANALYTIC_EVENT_CATEGORY' => null,
        ]);
    }
}
