<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Exported post builder class.
 *
 * @package    mod_forum
 * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace mod_forum\local\builders;

defined('MOODLE_INTERNAL') || die();

use mod_forum\local\entities\discussion as discussion_entity;
use mod_forum\local\entities\forum as forum_entity;
use mod_forum\local\entities\post as post_entity;
use mod_forum\local\factories\legacy_data_mapper as legacy_data_mapper_factory;
use mod_forum\local\factories\exporter as exporter_factory;
use mod_forum\local\factories\vault as vault_factory;
use mod_forum\local\factories\manager as manager_factory;
use core_tag_tag;
use moodle_exception;
use renderer_base;
use stdClass;

/**
 * Exported post builder class.
 *
 * This class is an implementation of the builder pattern (loosely). It is responsible
 * for taking a set of related forums, discussions, and posts and generate the exported
 * version of the posts.
 *
 * It encapsulates the complexity involved with exporting posts. All of the relevant
 * additional resources will be loaded by this class in order to ensure the exporting
 * process can happen.
 *
 * See this doc for more information on the builder pattern:
 * https://designpatternsphp.readthedocs.io/en/latest/Creational/Builder/README.html
 *
 * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class exported_posts {
    /** @var renderer_base $renderer Core renderer */
    private $renderer;

    /** @var legacy_data_mapper_factory $legacydatamapperfactory Data mapper factory */
    private $legacydatamapperfactory;

    /** @var exporter_factory $exporterfactory Exporter factory */
    private $exporterfactory;

    /** @var vault_factory $vaultfactory Vault factory */
    private $vaultfactory;

    /** @var rating_manager $ratingmanager Rating manager */
    private $ratingmanager;

    /** @var manager_factory $managerfactory Manager factory */
    private $managerfactory;

    /**
     * Constructor.
     *
     * @param renderer_base $renderer Core renderer
     * @param legacy_data_mapper_factory $legacydatamapperfactory Legacy data mapper factory
     * @param exporter_factory $exporterfactory Exporter factory
     * @param vault_factory $vaultfactory Vault factory
     * @param manager_factory $managerfactory Manager factory
     */
    public function __construct(
        renderer_base $renderer,
        legacy_data_mapper_factory $legacydatamapperfactory,
        exporter_factory $exporterfactory,
        vault_factory $vaultfactory,
        manager_factory $managerfactory
    ) {
        $this->renderer = $renderer;
        $this->legacydatamapperfactory = $legacydatamapperfactory;
        $this->exporterfactory = $exporterfactory;
        $this->vaultfactory = $vaultfactory;
        $this->managerfactory = $managerfactory;
        $this->ratingmanager = $managerfactory->get_rating_manager();
    }

    /**
     * Build the exported posts for a given set of forums, discussions, and posts.
     *
     * This will typically be used for a list of posts in the same discussion/forum however
     * it does support exporting any arbitrary list of posts as long as the caller also provides
     * a unique list of all discussions for the list of posts and all forums for the list of discussions.
     *
     * Increasing the number of different forums being processed will increase the processing time
     * due to processing multiple contexts (for things like capabilities, files, etc). The code attempts
     * to load the additional resources as efficiently as possible but there is no way around some of
     * the additional overhead.
     *
     * Note: Some posts will be removed as part of the build process according to capabilities.
     * A one-to-one mapping should not be expected.
     *
     * @param stdClass $user The user to export the posts for.
     * @param forum_entity[] $forums A list of all forums that each of the $discussions belong to
     * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to
     * @param post_entity[] $posts The list of posts to export.
     * @param bool $includeinlineattachments Whether inline attachments should be included or not.
     * @return stdClass[] List of exported posts in the same order as the $posts array.
     */
    public function build(
        stdClass $user,
        array $forums,
        array $discussions,
        array $posts,
        bool $includeinlineattachments = false
    ): array {
        // Format the forums and discussion to make them more easily accessed later.
        $forums = array_reduce($forums, function($carry, $forum) {
            $carry[$forum->get_id()] = $forum;
            return $carry;
        }, []);
        $discussions = array_reduce($discussions, function($carry, $discussion) {
            $carry[$discussion->get_id()] = $discussion;
            return $carry;
        }, []);

        // Group the posts by discussion and forum so that we can load the resources in
        // batches to improve performance.
        $groupedposts = $this->group_posts_by_discussion($forums, $discussions, $posts);
        // Load all of the resources we need in order to export the posts.
        $authorsbyid = $this->get_authors_for_posts($posts);
        $authorcontextids = $this->get_author_context_ids(array_keys($authorsbyid));
        $attachmentsbypostid = $this->get_attachments_for_posts($groupedposts);
        $inlineattachments = [];
        if ($includeinlineattachments) {
            $inlineattachments = $this->get_inline_attachments_for_posts($groupedposts);
        }
        $groupsbycourseandauthorid = $this->get_author_groups_from_posts($groupedposts);
        $tagsbypostid = $this->get_tags_from_posts($posts);
        $ratingbypostid = $this->get_ratings_from_posts($user, $groupedposts);
        $readreceiptcollectionbyforumid = $this->get_read_receipts_from_posts($user, $groupedposts);
        $exportedposts = [];

        // Export each set of posts per discussion because it's the largest chunks we can
        // break them into due to constraints on capability checks.
        foreach ($groupedposts as $grouping) {
            [
                'forum' => $forum,
                'discussion' => $discussion,
                'posts' => $groupedposts
            ] = $grouping;

            // Exclude posts the user cannot see, such as certain posts in Q and A forums.
            $capabilitymanager = $this->managerfactory->get_capability_manager($forum);
            $groupedposts = array_filter($groupedposts, function($post) use ($capabilitymanager, $user, $discussion) {
                return $capabilitymanager->can_view_post($user, $discussion, $post);
            });

            $forumid = $forum->get_id();
            $courseid = $forum->get_course_record()->id;
            $postsexporter = $this->exporterfactory->get_posts_exporter(
                $user,
                $forum,
                $discussion,
                $groupedposts,
                $authorsbyid,
                $authorcontextids,
                $attachmentsbypostid,
                $groupsbycourseandauthorid[$courseid],
                $readreceiptcollectionbyforumid[$forumid] ?? null,
                $tagsbypostid,
                $ratingbypostid,
                true,
                $inlineattachments
            );
            ['posts' => $exportedgroupedposts] = (array) $postsexporter->export($this->renderer);
            $exportedposts = array_merge($exportedposts, $exportedgroupedposts);
        }

        if (count($forums) == 1 && count($discussions) == 1) {
            // All of the posts belong to a single discussion in a single forum so
            // the exported order will match the given $posts array.
            return $exportedposts;
        } else {
            // Since we grouped the posts by discussion and forum the ordering of the
            // exported posts may be different to the given $posts array so we should
            // sort it back into the correct order for the caller.
            return $this->sort_exported_posts($posts, $exportedposts);
        }
    }

    /**
     * Group the posts by which discussion they belong to in order for them to be processed
     * in chunks by the exporting.
     *
     * Returns a list of groups where each group has a forum, discussion, and list of posts.
     * E.g.
     * [
     *      [
     *          'forum' => <forum_entity>,
     *          'discussion' => <discussion_entity>,
     *          'posts' => [
     *              <post_entity in discussion>,
     *              <post_entity in discussion>,
     *              <post_entity in discussion>
     *          ]
     *      ]
     * ]
     *
     * @param forum_entity[] $forums A list of all forums that each of the $discussions belong to, indexed by id.
     * @param discussion_entity[] $discussions A list of all discussions that each of the $posts belong to, indexed by id.
     * @param post_entity[] $posts The list of posts to process.
     * @return array List of grouped posts. Each group has a discussion, forum, and posts.
     */
    private function group_posts_by_discussion(array $forums, array $discussions, array $posts): array {
        return array_reduce($posts, function($carry, $post) use ($forums, $discussions) {
            $discussionid = $post->get_discussion_id();
            if (!isset($discussions[$discussionid])) {
                throw new moodle_exception('Unable to find discussion with id ' . $discussionid);
            }

            if (isset($carry[$discussionid])) {
                $carry[$discussionid]['posts'][] = $post;
            } else {
                $discussion = $discussions[$discussionid];
                $forumid = $discussion->get_forum_id();

                if (!isset($forums[$forumid])) {
                    throw new moodle_exception('Unable to find forum with id ' . $forumid);
                }

                $carry[$discussionid] = [
                    'forum' => $forums[$forumid],
                    'discussion' => $discussions[$discussionid],
                    'posts' => [$post]
                ];
            }

            return $carry;
        }, []);
    }

    /**
     * Load the list of authors for the given posts.
     *
     * The list of authors will be indexed by the author id.
     *
     * @param post_entity[] $posts The list of posts to process.
     * @return author_entity[]
     */
    private function get_authors_for_posts(array $posts): array {
        $authorvault = $this->vaultfactory->get_author_vault();
        return $authorvault->get_authors_for_posts($posts);
    }

    /**
     * Get the user context ids for each of the authors.
     *
     * @param int[] $authorids The list of author ids to fetch context ids for.
     * @return int[] Context ids indexed by author id
     */
    private function get_author_context_ids(array $authorids): array {
        $authorvault = $this->vaultfactory->get_author_vault();
        return $authorvault->get_context_ids_for_author_ids($authorids);
    }

    /**
     * Load the list of all inline attachments for the posts. The list of attachments will be
     * indexed by the post id.
     *
     * @param array $groupedposts List of posts grouped by discussions.
     * @return stored_file[]
     */
    private function get_inline_attachments_for_posts(array $groupedposts): array {
        $inlineattachmentsbypostid = [];
        $postattachmentvault = $this->vaultfactory->get_post_attachment_vault();
        $postsbyforum = array_reduce($groupedposts, function($carry, $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;

            $forumid = $forum->get_id();
            if (!isset($carry[$forumid])) {
                $carry[$forumid] = [
                    'forum' => $forum,
                    'posts' => []
                ];
            }

            $carry[$forumid]['posts'] = array_merge($carry[$forumid]['posts'], $posts);
            return $carry;
        }, []);

        foreach ($postsbyforum as $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;
            $inlineattachments = $postattachmentvault->get_inline_attachments_for_posts($forum->get_context(), $posts);

            // Have to loop in order to maintain the correct indexes since they are numeric.
            foreach ($inlineattachments as $postid => $attachment) {
                $inlineattachmentsbypostid[$postid] = $attachment;
            }
        }

        return $inlineattachmentsbypostid;
    }

    /**
     * Load the list of all attachments for the posts. The list of attachments will be
     * indexed by the post id.
     *
     * @param array $groupedposts List of posts grouped by discussions.
     * @return stored_file[]
     */
    private function get_attachments_for_posts(array $groupedposts): array {
        $attachmentsbypostid = [];
        $postattachmentvault = $this->vaultfactory->get_post_attachment_vault();
        $postsbyforum = array_reduce($groupedposts, function($carry, $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;

            $forumid = $forum->get_id();
            if (!isset($carry[$forumid])) {
                $carry[$forumid] = [
                    'forum' => $forum,
                    'posts' => []
                ];
            }

            $carry[$forumid]['posts'] = array_merge($carry[$forumid]['posts'], $posts);
            return $carry;
        }, []);

        foreach ($postsbyforum as $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;
            $attachments = $postattachmentvault->get_attachments_for_posts($forum->get_context(), $posts);

            // Have to loop in order to maintain the correct indexes since they are numeric.
            foreach ($attachments as $postid => $attachment) {
                $attachmentsbypostid[$postid] = $attachment;
            }
        }

        return $attachmentsbypostid;
    }

    /**
     * Get the groups for each author of the given posts.
     *
     * The results are grouped by course and then author id because the groups are
     * contextually related to the course, e.g. a single author can be part of two different
     * sets of groups in two different courses.
     *
     * @param array $groupedposts List of posts grouped by discussions.
     * @return array List of groups indexed by forum id and then author id.
     */
    private function get_author_groups_from_posts(array $groupedposts): array {
        $groupsbyauthorid = [];
        $authoridsbycourseid = [];

        // Get the unique list of author ids for each course in the grouped
        // posts. Grouping by course is the largest grouping we can achieve.
        foreach ($groupedposts as $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;
            $course = $forum->get_course_record();
            $courseid = $course->id;

            if (!isset($authoridsbycourseid[$courseid])) {
                $coursemodule = $forum->get_course_module_record();
                $authoridsbycourseid[$courseid] = [
                    'groupingid' => $coursemodule->groupingid,
                    'authorids' => []
                ];
            }

            $authorids = array_map(function($post) {
                return $post->get_author_id();
            }, $posts);

            foreach ($authorids as $authorid) {
                $authoridsbycourseid[$courseid]['authorids'][$authorid] = $authorid;
            }
        }

        // Load each set of groups per course.
        foreach ($authoridsbycourseid as $courseid => $values) {
            ['groupingid' => $groupingid, 'authorids' => $authorids] = $values;
            $authorgroups = groups_get_all_groups(
                $courseid,
                array_keys($authorids),
                $groupingid,
                'g.*, gm.id, gm.groupid, gm.userid'
            );

            if (!isset($groupsbyauthorid[$courseid])) {
                $groupsbyauthorid[$courseid] = [];
            }

            foreach ($authorgroups as $group) {
                // Clean up data returned from groups_get_all_groups.
                $userid = $group->userid;
                $groupid = $group->groupid;

                unset($group->userid);
                unset($group->groupid);
                $group->id = $groupid;

                if (!isset($groupsbyauthorid[$courseid][$userid])) {
                    $groupsbyauthorid[$courseid][$userid] = [];
                }

                $groupsbyauthorid[$courseid][$userid][] = $group;
            }
        }

        return $groupsbyauthorid;
    }

    /**
     * Get the list of tags for each of the posts. The tags will be returned in an
     * array indexed by the post id.
     *
     * @param post_entity[] $posts The list of posts to load tags for.
     * @return array Sets of tags indexed by post id.
     */
    private function get_tags_from_posts(array $posts): array {
        $postids = array_map(function($post) {
            return $post->get_id();
        }, $posts);
        return core_tag_tag::get_items_tags('mod_forum', 'forum_posts', $postids);
    }

    /**
     * Get the list of ratings for each post. The ratings are returned in an array
     * indexed by the post id.
     *
     * @param stdClass $user The user viewing the ratings.
     * @param array $groupedposts List of posts grouped by discussions.
     * @return array Sets of ratings indexed by post id.
     */
    private function get_ratings_from_posts(stdClass $user, array $groupedposts) {
        $ratingsbypostid = [];
        $postsdatamapper = $this->legacydatamapperfactory->get_post_data_mapper();
        $postsbyforum = array_reduce($groupedposts, function($carry, $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;

            $forumid = $forum->get_id();
            if (!isset($carry[$forumid])) {
                $carry[$forumid] = [
                    'forum' => $forum,
                    'posts' => []
                ];
            }

            $carry[$forumid]['posts'] = array_merge($carry[$forumid]['posts'], $posts);
            return $carry;
        }, []);

        foreach ($postsbyforum as $grouping) {
            ['forum' => $forum, 'posts' => $posts] = $grouping;

            if (!$forum->has_rating_aggregate()) {
                continue;
            }

            $items = $postsdatamapper->to_legacy_objects($posts);
            $ratingoptions = (object) [
                'context' => $forum->get_context(),
                'component' => 'mod_forum',
                'ratingarea' => 'post',
                'items' => $items,
                'aggregate' => $forum->get_rating_aggregate(),
                'scaleid' => $forum->get_scale(),
                'userid' => $user->id,
                'assesstimestart' => $forum->get_assess_time_start(),
                'assesstimefinish' => $forum->get_assess_time_finish()
            ];

            $rm = $this->ratingmanager;
            $items = $rm->get_ratings($ratingoptions);

            foreach ($items as $item) {
                $ratingsbypostid[$item->id] = empty($item->rating) ? null : $item->rating;
            }
        }

        return $ratingsbypostid;
    }

    /**
     * Get the read receipt collections for the given viewing user and each forum. The
     * receipt collections will only be loaded for posts in forums that the user is tracking.
     *
     * The receipt collections are returned in an array indexed by the forum ids.
     *
     * @param stdClass $user The user viewing the posts.
     * @param array $groupedposts List of posts grouped by discussions.
     */
    private function get_read_receipts_from_posts(stdClass $user, array $groupedposts) {
        $forumdatamapper = $this->legacydatamapperfactory->get_forum_data_mapper();
        $trackedforums = [];
        $trackedpostids = [];

        foreach ($groupedposts as $group) {
            ['forum' => $forum, 'posts' => $posts] = $group;
            $forumid = $forum->get_id();

            if (!isset($trackedforums[$forumid])) {
                $forumrecord = $forumdatamapper->to_legacy_object($forum);
                $trackedforums[$forumid] = forum_tp_is_tracked($forumrecord, $user);
            }

            if ($trackedforums[$forumid]) {
                foreach ($posts as $post) {
                    $trackedpostids[] = $post->get_id();
                }
            }
        }

        if (empty($trackedpostids)) {
            return [];
        }

        // We can just load a single receipt collection for all tracked posts.
        $receiptvault = $this->vaultfactory->get_post_read_receipt_collection_vault();
        $readreceiptcollection = $receiptvault->get_from_user_id_and_post_ids($user->id, $trackedpostids);
        $receiptsbyforumid = [];

        // Assign the collection to all forums that are tracked.
        foreach ($trackedforums as $forumid => $tracked) {
            if ($tracked) {
                $receiptsbyforumid[$forumid] = $readreceiptcollection;
            }
        }

        return $receiptsbyforumid;
    }

    /**
     * Sort the list of exported posts back into the same order as the given posts.
     * The ordering of the exported posts can often deviate from the given posts due
     * to the process of exporting them so we need to sort them back into the order
     * that the calling code expected.
     *
     * @param post_entity[] $posts The posts in the expected order.
     * @param stdClass[] $exportedposts The list of exported posts in any order.
     * @return stdClass[] Sorted exported posts.
     */
    private function sort_exported_posts(array $posts, array $exportedposts) {
        $postindexes = [];
        foreach (array_values($posts) as $index => $post) {
            $postindexes[$post->get_id()] = $index;
        }

        $sortedexportedposts = [];

        foreach ($exportedposts as $exportedpost) {
            $index = $postindexes[$exportedpost->id];
            $sortedexportedposts[$index] = $exportedpost;
        }

        return $sortedexportedposts;
    }
}
