<?php
// This file is part of the Studyplan plugin for Moodle
//
// 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 <https://www.gnu.org/licenses/>.

/**
 * Collect, process and display information about gradable items
 * @package    local_treestudyplan
 * @copyright  2023 P.M. Kuipers
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

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

require_once($CFG->libdir . '/externallib.php');
require_once($CFG->libdir . '/gradelib.php');
require_once($CFG->dirroot . '/course/lib.php');

use dml_exception;
use grade_grade;
use grade_item;
use grade_scale;
use grade_outcome;
use stdClass;

/**
 * Preload, cache and return gradable objects.
 */
class preloader {
    /**
     * @var stdClass[]
     */
    private static $courses = [];
    /**
     * @var grade_scale[]
     */
    private static $scales = [];
    /**
     * @var grade_outcome[]
     */
    private static $outcomes = [];
    /**
     * @var grade_item[]
     */
    private static $gradeitems = [];
    /**
     * @var grade_grade[]
     */
    private static $gradegrades = [];
    /**
     * @var grade_item[]
     */
    private static $coursecollection = [];
    /**
     * @var int[][]
     */
    private static $gradegrademap = [];

    /**
     * @var stdClass[][]
     */
    private static $studyitemmap = [];

    /**
     * @var stdClass[]
     */
    private static $categories = [];

    /**
     * Return an object containing id's for a specific course
     *
     * @param int $courseid Id of the course to get lists of collected data for.
     * @return object
     */
    public static function coursecollection($courseid) {
        if (!array_key_exists($courseid, self::$coursecollection)) {
            self::$coursecollection[$courseid] = new stdClass();
            self::$coursecollection[$courseid]->gradeitems = [];
            self::$coursecollection[$courseid]->gradegrades = [];
        }
        return self::$coursecollection[$courseid];
    }

    /**
     * Preload grades and grade items for all grades and users in the plan.
     *
     * @param studyplan $plan The studyplan to preload for
     * @return void
     */
    public static function preload_all(studyplan $plan) {
        $userids = $plan->find_linked_userids();

        $ag = $plan->aggregator();
        self::preload_courses($plan);
        if ($ag->use_corecompletioninfo() || $ag->use_manualactivityselection()) {
            self::preload_grade_items($plan);
            self::preload_grade_grades($plan, $userids);
        }
        if ($ag->use_corecompletioninfo()) {
            preloader_core::preload_all($plan, $userids);
        }

        if ($ag->use_coursecompetencies()) {
            preloader_competency::preload_all($plan, $userids);
        }
    }

    /**
     * Preload grades and grade items for all grades and users in the plan.
     *
     * @param studyplan $plan The studyplan to preload for
     * @return void
     */
    public static function preload_map(studyplan $plan) {

        $ag = $plan->aggregator();
        self::preload_courses($plan);
        if ($ag->use_manualactivityselection()) {
            self::preload_grade_items($plan);
        }
        if ($ag->use_corecompletioninfo()) {
            preloader_core::preload_map($plan);
        }

        if ($ag->use_coursecompetencies()) {
            preloader_competency::preload_map($plan);
        }
    }

    /**
     * Preload grades and grade items for all grades and users in the plan.
     *
     * @param studyplan $plan The studyplan to preload for
     * @param int[]|int $userids Userid or Array of userids to preload for
     * @return void
     */
    public static function preload_for_users(studyplan $plan, $userids) {
        if (!is_array($userids)) {
            $userids = [$userids];
        }

        $ag = $plan->aggregator();
        self::preload_courses($plan);
        if ($ag->use_manualactivityselection()) {
            self::preload_grade_items($plan);
            self::preload_grade_grades($plan, $userids);
        }
        if ($ag->use_corecompletioninfo()) {
            preloader_core::preload_for_users($plan, $userids);
        }
        if ($ag->use_coursecompetencies()) {
            preloader_competency::preload_for_users($plan, $userids);
        }
    }

    /**
     * Retrieve course record from cache or DB
     *
     * @param int $id Course id
     * @return stdClass Course record
     */
    public static function get_course(int $id): stdClass {
        if (array_key_exists($id, self::$courses)) {
            // Return from cache.
            return self::$courses[$id];
        } else {
            // Retrieve from database and cache.
            $o = get_course($id);
            self::$courses[$id] = $o;
            return $o;
        }
    }

    /**
     * Retrieve grade item from cache or db
     *
     * @param int $id Record ID
     * @return grade_item
     */
    public static function get_grade_item(int $id): grade_item {
        if (array_key_exists($id, self::$gradeitems)) {
            // Return from cache.
            return self::$gradeitems[$id];
        } else {
            // Retrieve from database and cache.
            $o = grade_item::fetch(["id" => $id]);
            if (!is_object($o)) {
                throw new dml_exception("Cannot retrieve grade_item", null, "Item id is '{$id}'");
            }
            self::$gradeitems[$id] = $o;
            return $o;
        }
    }

    /**
     * Retrieve grade_scale from cache or db
     *
     * @param int $id Record ID
     * @return grade_scale
     */
    public static function get_grade_scale(int $id) {
        if (array_key_exists($id, self::$scales)) {
            // Return from cache.
            return self::$scales[$id];
        } else {
            // Retrieve from database and cache.
            $o = grade_scale::fetch(["id" => $id]);
            if (!is_object($o)) {
                throw new dml_exception("Cannot retrieve grade_scale", null, "Item id is '{$id}'");
            }
            self::$scales[$id] = $o;
            return $o;
        }
    }

    /**
     * Retrieve grade_outcome from cache or db
     *
     * @param int $id Record ID
     * @return grade_outcome
     */
    public static function get_grade_outcome(int $id) {
        if (array_key_exists($id, self::$outcomes)) {
            // Return from cache.
            return self::$outcomes[$id];
        } else {
            // Retrieve from database and cache.
            $o = grade_outcome::fetch(["id" => $id]);
            if (!is_object($o)) {
                throw new dml_exception("Cannot retrieve grade_outcome", null, "Item id is '{$id}'");
            }
            self::$outcomes[$id] = $o;
            return $o;
        }
    }

    /**
     * Retrieve grade grade from cache or db
     *
     * @param int $id Record ID
     * @return grade_grade
     * @throws dml_exception if record not found in database
     */
    public static function get_grade_grade(int $id) {
        if (array_key_exists($id, self::$gradegrades)) {
            // Return from cache.
            return self::$gradegrades[$id];
        } else {
            // Retrieve from database and cache.
            $o = grade_grade::fetch(["id" => $id]);
            if (!is_object($o)) {
                throw new dml_exception("Cannot retrieve grade_grade", null, "Item id is '{$id}'");
            }
            self::$gradegrades[$id] = $o;
            return $o;
        }
    }

    /**
     * Get category by id from cache or DB
     *
     * @param int|string $id
     * @return stdClass
     */
    public static function get_category($id) {
        global $DB;
        $id = intval($id); // In case it is given as a string.
        if (!array_key_exists($id, self::$categories)) {
            $cat = $DB->get_record("course_categories", ["id" => $id]);
            self::$categories[$id] = $cat;
            // Load parents if needed.
            $path = explode("/", $cat->path);
            array_shift($path); // Strip empty first item.
            array_pop($path); // Strip last item, which is self.

            /*  Given that most parents should already be loaded,
                loading each missing parent should not be significantly slower
                than combining loading all missing parents into one db call. */
            foreach ($path as $i) {
                $i = intval($i);
                if (!isset(self::$categories[$i])) {
                    $cat2 = $DB->get_record("course_categories", ["id" => $i]);
                    self::$categories[$i] = $cat2;
                }
            }
        }
        return self::$categories[$id];
    }

    /**
     * Get final result for grade item by user id
     *
     * @param grade_item $gi Grade item
     * @param int $userid User id
     * @throws dml_exception if record not found in database
     * @return grade_grade
     */
    public static function find_grade_item_final(grade_item $gi, int $userid) {
        $itemid = $gi->id;
        if (array_key_exists($itemid, self::$gradegrademap) && array_key_exists($userid, self::$gradegrademap[$itemid])) {
            $ggid = self::$gradegrademap[$itemid][$userid];
            if (!empty($ggid)) {
                return self::get_grade_grade($ggid);
            } else {
                // Was preloaded, but no record. Return null like grade_item::get_final() does.
                return null;
            }
        } else {
            // Get from database and store in map.
            $gg = $gi->get_final($userid);
            if (is_array($gg)) {
                $gg = $gg[0];
            }
            if (!isset(self::$gradegrademap[$itemid])) {
                self::$gradegrademap[$itemid] = [];
            }
            self::$gradegrademap[$itemid][$userid] = $gg->id;
            self::$gradegrades[$gg->id] = $gg;
            return $gg;
        }
    }

    /**
     * Retrieve all grade items for a given course
     *
     * @param int $courseid
     * @param array $filter - Same filters as can be applied to grade_items::fetch()
     * @return grade_item[]
     */
    public static function find_course_grade_items($courseid, $filter = []) {
        $cc = self::coursecollection($courseid);
        // Retrieve from database if needed.
        if (count($cc->gradeitems) == 0) {
            // Retrieve directly.
            $items = grade_item::fetch_all(["courseid" => $courseid]);
            foreach ($items as $item) {
                // Append to cache.
                $cc->gradeitems[] = $item->id;
                self::$gradeitems[$item->id] = $item;
            }
        }
        $list = [];
        foreach ($cc->gradeitems as $id) {
            // List and filter.
            $gi = self::$gradeitems[$id];
            $pass = true;
            foreach ($filter as $key => $value) {
                if (property_exists($gi, $key)) {
                    if ($gi->$key != $value) {
                        $pass = false;
                    }
                }
            }
            if ($pass) {
                $list[] = $gi;
            }
        }
        return $list;
    }


    /**
     * Retrieve link information for a specific studyitem
     *
     * @param int $itemid Of StudyItem
     * @return stdClass[] Database records with $o->gradeitem added
     */
    public static function find_studyitem_links($itemid) {
        global $DB;
        if (!array_key_exists($itemid, self::$studyitemmap)) {
            self::$studyitemmap[$itemid] = [];

            // Preload study item linked grades.
            $sql = "SELECT DISTINCT ts.*
                FROM {local_treestudyplan_gradeinc} ts
                INNER JOIN {local_treestudyplan_item} ti ON ti.id = ts.studyitem_id
                WHERE ti.id = :itemid
                ";
            $rs = $DB->get_recordset_sql($sql, ["itemid" => $itemid]);
            foreach ($rs as $r) {
                self::$studyitemmap[$itemid][] = $r;
            }
            $rs->close();
        }
        return self::$studyitemmap[$itemid];
    }

    /**
     * Preload all courses associated with a studyplan
     *
     * @param studyplan $plan The studyplan to preload for
     * @return void
     */
    public static function preload_courses(studyplan $plan) {
        global $DB;

        $sql = "SELECT DISTINCT c.*
                FROM {course} c
                INNER JOIN {local_treestudyplan_item} ti ON ti.course_id = c.id
                INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                WHERE p.id = :studyplanid
                ORDER BY c.sortorder ASC
                ";
        $rs = $DB->get_recordset_sql($sql, ["studyplanid" => $plan->id()]);
        foreach ($rs as $r) {
            self::$courses[$r->id] = $r; // Store object in cache.
        }

        $rs->close();
        self::preload_course_categories();
    }

    /**
     * Preload all grade items associated with a studyplan
     *
     * @param studyplan $plan The studyplan to preload for
     * @return void
     */
    public static function preload_grade_items(studyplan $plan) {
        global $DB;

        // Preload grade items.
        $sql = "SELECT DISTINCT gi.*
                FROM {grade_items} gi
                INNER JOIN {local_treestudyplan_item} ti ON ti.course_id = gi.courseid
                INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                WHERE p.id = :studyplanid
                ORDER BY gi.courseid ASC, gi.sortorder ASC
                ";
        $rs = $DB->get_recordset_sql($sql, ["studyplanid" => $plan->id()]);
        foreach ($rs as $r) {
            $gi = new grade_item($r, false); // Create new item from record.
            self::$gradeitems[$gi->id] = $gi; // Store in cache.
            self::coursecollection($r->courseid)->gradeitems[] = $gi->id; // Add id to course's id list.
        }
        $rs->close();

        // Preload grade scales.
        $sql = "SELECT DISTINCT s.*
                FROM {scale} s
                INNER JOIN {grade_items} gi ON gi.scaleid = s.id
                INNER JOIN {local_treestudyplan_item} ti ON ti.course_id = gi.courseid
                INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                WHERE p.id = :studyplanid
                ";
        $rs = $DB->get_recordset_sql($sql, ["studyplanid" => $plan->id()]);
        foreach ($rs as $r) {
            $gs = new grade_scale($r, false); // Create new item from record.
            self::$scales[$gs->id] = $gs; // Store in cache.
        }
        $rs->close();

        // Preload grade outcomes.
        $sql = "SELECT DISTINCT o.*
                FROM {grade_outcomes} o
                INNER JOIN {grade_items} gi ON gi.outcomeid = o.id
                INNER JOIN {local_treestudyplan_item} ti ON ti.course_id = gi.courseid
                INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                WHERE p.id = :studyplanid
                ";
        $rs = $DB->get_recordset_sql($sql, ["studyplanid" => $plan->id()]);
        foreach ($rs as $r) {
            $go = new grade_outcome($r, false); // Create new item from record.
            self::$outcomes[$go->id] = $go; // Store in cache.
        }
        $rs->close();

        // Preload study item linked grades.
        $sql = "SELECT DISTINCT ts.*
                FROM {local_treestudyplan_gradeinc} ts
                INNER JOIN {local_treestudyplan_item} ti ON ti.id = ts.studyitem_id
                INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                WHERE p.id = :studyplanid
                ";
        $rs = $DB->get_recordset_sql($sql, ["studyplanid" => $plan->id()]);
        foreach ($rs as $r) {
            if (!array_key_exists($r->studyitem_id, self::$studyitemmap)) {
                self::$studyitemmap[$r->studyitem_id] = [];
            }
            self::$studyitemmap[$r->studyitem_id][] = $r;
        }
        $rs->close();
    }

    /**
     * Preload all grades for the specified users
     *
     * @param studyplan $plan The studyplan to preload for
     * @param array|int $userids Id's of the user to load the data for
     * @return void
     */
    public static function preload_grade_grades(studyplan $plan, $userids) {
        global $DB;
        if (is_numeric($userids)) {
            $userids = [$userids];
        }

        if (count($userids) > 0) {
            [$insql, $params] = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED, 'uid');
            $params["studyplanid"] = $plan->id();

            $sql = "SELECT gg.*, gi.courseid AS courseid
                    FROM {grade_grades} gg
                    INNER JOIN {grade_items} gi ON gg.itemid = gi.id
                    INNER JOIN {local_treestudyplan_item} ti ON ti.course_id = gi.courseid
                    INNER JOIN {local_treestudyplan_line} tl ON tl.id = ti.line_id
                    INNER JOIN {local_treestudyplan_page} tp ON tp.id = tl.page_id
                    INNER JOIN {local_treestudyplan} p ON p.id = tp.studyplan_id
                    WHERE p.id = :studyplanid
                    AND gg.userid {$insql}
                    ORDER BY gi.courseid ASC, gi.sortorder ASC
                    ";

            $rs = $DB->get_recordset_sql($sql, $params);
            foreach ($rs as $r) {
                $gg = new grade_grade($r, false); // Create new item from record.
                self::$gradegrades[$gg->id] = $gg; // Store in cache.
                self::coursecollection($r->courseid)->gradegrades[] = $gg->id; // Add id to course's id list.

                // Add to user result map for itemids.
                if (!array_key_exists($r->itemid, self::$gradegrademap)) {
                    self::$gradegrademap[$r->itemid] = [];
                }
                self::$gradegrademap[$r->itemid][$r->userid] = $gg->id;
            }
            $rs->close();
        }
    }

    /**
     * Preload all categories related to preloaded courses
     *
     * @return void
     */
    public static function preload_course_categories() {
        global $DB;
        if (count(self::$courses) > 0) {
            $courseids = array_keys(self::$courses);
            [$insql, $params] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, "course");
            $sql = "SELECT DISTINCT cat.*
                    FROM {course_categories} cat
                    INNER JOIN {course} c ON c.category = cat.id
                    WHERE c.id {$insql}
                    ";
            $rs = $DB->get_recordset_sql($sql, $params);
            $parentids = [];
            foreach ($rs as $r) {
                self::$categories[$r->id] = $r;
                $path = explode("/", $r->path);
                array_shift($path); // Strip empty first item.
                array_pop($path); // Strip last item, which is self.
                foreach ($path as $i) {
                    $parentids[] = intval($i);
                }
            }
            $rs->close();
            // Determine which categories still should be loaded.
            $toload = array_diff($parentids, array_keys(self::$categories));
            if (count($toload) > 0) {
                // Prepare sql IN statement for all parent ids we need to load.
                [$insql, $params] = $DB->get_in_or_equal($toload, SQL_PARAMS_NAMED, "parent");

                $sql = "SELECT cat.*
                        FROM  {course_categories} cat
                        WHERE cat.id {$insql}
                        ";
                $rs = $DB->get_recordset_sql($sql, $params);
                foreach ($rs as $r) {
                    self::$categories[$r->id] = $r;
                }
                $rs->close();
            }
        }
    }
}
