<?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/>.

/**
 * Webservice related to courses
 * @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');

use local_treestudyplan\courseinfo;
use local_treestudyplan\associationservice;
use local_treestudyplan\local\helpers\webservicehelper;
use local_treestudyplan\completionscanner;
use local_treestudyplan\gradingscanner;
use core_course_category;
use moodle_exception;
use core_external\external_api;
use core_external\external_function_parameters;
use core_external\external_description;
use core_external\external_value;
use core_external\external_multiple_structure;
use core_external\external_single_structure;
use core\context;
use core\context\system as context_system;
use core\context\course as context_course;
use core\context\coursecat as context_coursecat;
use local_treestudyplan\local\helpers\formatter;

/**
 * Webservice related to courses
 */
class courseservice extends external_api {
    /**
     * Capability required to edit study plans
     * @var string
     */
    const CAP_EDIT = "local/treestudyplan:editstudyplan";
    /**
     * Capability required to view studyplans (for other users)
     * @var string
     */
    const CAP_VIEW = "local/treestudyplan:viewuserreports";

    /**
     * Get the topmost categories for the specicied user.
     * Most of the work is offloaded to an SQL query in the interest of speed,
     * but moodle functions are used to double check access permissions.
     * @param int $userid Id of the user
     * @param string $capability Capability the user must posess in the category to include it in the list.
     * @return array of core_course_category
     */
    public static function user_tops($userid = null, $capability = 'moodle/category:viewcourselist') {
        global $DB, $USER;
        if ($userid == null) {
            $userid = $USER->id;
        }
        $tops = [];

        if (has_capability($capability, context_system::instance(), $userid)) {
            if ($capability == 'moodle/category:viewcourselist') {
                // We are now just looking for the visible main level categories.
                // Add all categories of depth = 1.
                $rs = $DB->get_recordset("course_categories", ["depth" => 1], 'sortorder');
                foreach ($rs as $rcat) {
                    // Get the category, and double check if the category is visible to the current user.
                    // Just in case it is a hidden category and the user does not have the viewhidden permission.
                    $cat = \core_course_category::get($rcat->id, \IGNORE_MISSING, false, $userid);
                    if ($cat !== null) {
                        // Register the category.
                        array_push($tops, $cat);
                    }
                }
                $rs->close();
            } else {
                // Context is system, but system may not be visible.
                // Return the top visible categories for this user.
                // Recurses only once.
                return self::user_tops($userid);
            }
        } else {
            // We need to search for the permissions on an individial context level.
            // This part finds all top categories with a certain permission that are also visible for the user.

            $sql = "SELECT DISTINCT ctx.*, cat.sortorder FROM {context} ctx
                    INNER JOIN {role_assignments} ra ON ra.contextid = ctx.id
                    INNER JOIN {role_capabilities} rc ON ra.roleid = rc.roleid
                    LEFT JOIN {course_categories} cat ON ctx.instanceid = cat.id
                    WHERE ( ctx.contextlevel = :ctxl_coursecat )
                    AND ra.userid = :userid AND rc.capability = :capability
                    ORDER BY ctx.depth ASC, cat.sortorder ASC";

            // Use recordset to handle the eventuality of a really big and complex moodle setup.
            $recordset = $DB->get_records_sql(
                $sql,
                [
                    "userid" => $userid,
                    "capability" => $capability,
                    "ctxl_coursecat" => \CONTEXT_COURSECAT,
                ]
            );
            $contextids = [];
            foreach ($recordset as $r) {
                // Get the paths as an array.
                $parents = explode("/", $r->path);
                // Strip the first item, since it is an empty one.
                array_shift($parents);
                // Strip the last item, since it refers to self.
                array_pop($parents);
                // Figure out if any of the remaining parent contexts are already contexts with permission.
                $intersect = array_intersect($contextids, $parents);
                if (count($intersect) == 0) {
                    // Double check permissions according to the moodle capability system.
                    $ctx = context::instance_by_id($r->id);
                    if (is_object($ctx) && has_capability($capability, $ctx, $userid)) {
                        // Get the category, and double check if the category is visible to the current user.
                        // Just in case it is a hidden category and the user does not have the viewhidden permission.
                        $cat = \core_course_category::get($r->instanceid, \IGNORE_MISSING, false, $userid);
                        if ($cat !== null) {
                            // Register the context id in the list now, since we know the category is really visible.
                            array_push($contextids, $r->id);
                            // Register the category.
                            array_push($tops, $cat);
                        } else {
                            // The category is not visible. Add the first known visible subcategories.
                            $children = self::get_first_visible_children($r->id, $userid);
                            foreach ($children as $cat) {
                                array_push($tops, $cat);
                            }
                        }
                    }
                }
            }
        }

        return $tops;
    }

    /**
     * Find the top-most child categories for a given category that are visible.
     *
     * @param int $parentid The category to search for
     * @param int $userid The id of the user to determine visibility for
     * @return array of \core_course_category
     */
    private static function get_first_visible_children($parentid, $userid) {
        global $DB;
        $capability = 'moodle/category:viewcourselist';

        $tops = [];
        $pathlike = $DB->sql_like('ctx.path', ':pathsearch');

        $sql = "SELECT ctx.*, cat.sortorder FROM {context} ctx
                INNER JOIN {role_assignments} ra ON ra.contextid = ctx.id
                INNER JOIN {role_capabilities} rc ON ra.roleid = rc.roleid
                LEFT JOIN {course_categories} cat ON ctx.instanceid = cat.id
                WHERE ( ctx.contextlevel = :ctxl_coursecat )
                AND ra.userid = :userid AND rc.capability = :capability
                AND {$pathlike}
                ORDER BY ctx.depth ASC, cat.sortorder ASC";

        // Use recordset to handle the eventuality of a really big and complex moodle setup.
        $recordset = $DB->get_recordset_sql(
            $sql,
            [
                "userid" => $userid,
                "capability" => $capability,
                "ctxl_coursecat" => \CONTEXT_COURSECAT,
                "pathsearch" => "%/{$parentid}/%",
            ]
        );

        $contextids = [];
        foreach ($recordset as $r) {
            // Get the paths as an array.
            $parents = explode("/", $r->path);
            // Strip the first item, since it is an empty one.
            array_shift($parents);
            // Strip the last item, since it refers to self.
            array_pop($parents);
            // Figure out if any of the remaining parent contexts are already contexts with permission.
            $intersect = array_intersect($contextids, $parents);
            if (count($intersect) == 0) {
                // Double check permissions according to the moodle capability system.
                $ctx = context::instance_by_id($r->id);
                if (is_object($ctx) && has_capability($capability, $ctx, $userid)) {
                    // Get the category, and double check if the category is visible to the current user.
                    // Just in case it is a hidden category and the user does not have the viewhidden permission.
                    $cat = \core_course_category::get($r->instanceid, \IGNORE_MISSING, false, $userid);
                    if ($cat !== null) {
                        // Register the context id in the list now, since we know the category is really visible.
                        array_push($contextids, $r->id);
                        // Register the category.
                        array_push($tops, $cat);
                    }
                }
            }
        }
        $recordset->close();
        return $tops;
    }

    /**
     * Return value description for map_categories function
     */
    public static function map_categories_parameters(): external_function_parameters {
        return new external_function_parameters([
            "studyplan_id" => new external_value(PARAM_INT, 'ID of studyplan to map the categories for', VALUE_DEFAULT),
         ]);
    }

    /**
     * Parameter description for map_categories function
     */
    public static function map_categories_returns(): external_description {
        return new external_multiple_structure(static::map_category_structure(false));
    }

    /**
     * Structure description for category map, used in a number of return descriptions
     * @param bool $lazy
     * @param int $value
     */
    protected static function map_category_structure($lazy = false, $value = VALUE_REQUIRED) {
        $s = [
            "id" => new external_value(PARAM_INT, 'course category id'),
            "context_id" => new external_value(PARAM_INT, 'course category context id'),
            "category" => contextinfo::structure(VALUE_OPTIONAL),
            "haschildren" => new external_value(PARAM_BOOL, 'true if the category has child categories'),
            "hascourses" => new external_value(PARAM_BOOL, 'true if the category contains courses'),
            "studyplancount" => new external_value(PARAM_INT, 'number of linked studyplans', VALUE_OPTIONAL),
        ];

        if (!$lazy > 0) {
            $s["courses"] = new external_multiple_structure(
                new external_single_structure(
                    [
                        "id" => new external_value(PARAM_INT, 'linked course id'),
                        "fullname" => new external_value(PARAM_RAW, 'linked course name'),
                        "shortname" => new external_value(PARAM_RAW, 'linked course shortname'),
                    ],
                    'referenced course information',
                    $value
                )
            );

            $s["children"] = new external_multiple_structure(
                static::map_category_structure(true)
            );
        }
        return  new external_single_structure($s, "CourseCat info", $value);
    }

    /**
     * Get a category map, and optionally specify a root category to search for
     * User's top category will be used if none specified
     * @param int $studyplanid Optional id of the studyplan to whose context to limit the search to (if so configures)
     * @return array
     */
    public static function map_categories($studyplanid = 0) {
        // Determine top categories from provided context.

        if ($studyplanid > 0 && \get_config("local_treestudyplan", "limitcourselist")) {
            $studyplan = studyplan::find_by_id($studyplanid);
            $context = $studyplan->context();
            if ($context->contextlevel == \CONTEXT_SYSTEM) {
                $children = self::user_tops();
            } else if ($context->contextlevel == \CONTEXT_COURSECAT) {
                $cat = \core_course_category::get($context->instanceid, \MUST_EXIST, true);
                if (is_object($cat) && $cat->is_uservisible()) {
                    $children = [$cat];
                } else {
                    $ci = new contextinfo($context);
                    $contextname = $ci->pathstr();
                    throw new moodle_exception("error:cannotviewcategory", "local_treestudyplan", '', $contextname);
                }
            } else {
                $children = [];
            }
        } else {
            $children = self::user_tops();
            if (count($children) == 0) {
                throw new moodle_exception("error:nocategoriesvisible", "local_treestudyplan");
            }
        }

        $list = [];
        foreach ($children as $cat) {
            $list[] = static::map_category($cat, false);
        }
        return $list;
    }

    /**
     * Return value description for get_category function
     */
    public static function get_category_parameters(): external_function_parameters {
        return new external_function_parameters([
            "id" => new external_value(PARAM_INT, 'id of category'),
        ]);
    }

    /**
     * Parameter description for get_category function
     */
    public static function get_category_returns(): external_description {
        return static::map_category_structure(false);
    }

    /**
     * Get category info by id
     * @param mixed $id
     * @return \core_course_category
     */
    public static function get_category($id) {
        $cat = \core_course_category::get($id);
        if (!is_object($cat)) {
            throw new moodle_exception("unknownerror");
        }
        return static::map_category($cat);
    }

    /**
     * Create a category map, given a specific category
     * @param stdClass|core_course_category $cat The category to scan
     * @param bool $lazy If lazy loading, do not scan child categories
     * @return array
     */
    protected static function map_category($cat, $lazy = false) {
        global $DB;

        if ($cat->id > 0) {
            $catcontext = context_coursecat::instance($cat->id);
        } else {
            $catcontext = context_system::instance();
        }
        $path = trim($cat->path, '/'); // Kill leading slash.
        $path = explode('/', $path);
        $model = [
            "id" => $cat->id,
            "context_id" => $catcontext->id,
            "category" => [
                "name" => formatter::format_string($cat->name),
                "path" => $path,
            ],
        ];

        if (!$lazy) {
            $model["courses"] = [];
            $rs = $DB->get_recordset("course", ["category" => $cat->id, "visible" => 1], "sortorder");
            foreach ($rs as $course) {
                $model["courses"][] = [
                        'id' => $course->id,
                        'fullname' => formatter::format_string($course->fullname),
                        'shortname' => $course->shortname,
                ];
            }
            $rs->close();
            $model["hascourses"] = count($model["courses"]) > 0;

            $model["children"] = [];
            $rs = $DB->get_recordset("course_categories", ["parent" => $cat->id, "visible" => 1], "sortorder");
            foreach ($rs as $child) {
                $model["children"][] = static::map_category($child, true);
            }
            $rs->close();
            $model["haschildren"] = count($model["children"]) > 0;
        } else {
            $model["hascourses"] = $DB->count_records("course", ["category" => $cat->id, "visible" => 1]) > 0;
            $model["haschildren"] = $DB->count_records("course_categories", ["parent" => $cat->id, "visible" => 1]) > 0;
        }

        return $model;
    }

    /**
     * List all user visible categories the current user has a given capability for.
     * @param mixed $capability
     * @return array
     */
    public static function categories_by_capability($capability) {
        global $USER;
        // List the categories in which the user has a specific capability.
        $list = [];
        $parents = self::user_tops($USER->id, $capability);

        foreach ($parents as $parent) {
            array_push($list, $parent);
            // For optimization purposes, we include all its children now, since they will have inherited the permission.
            $list = array_merge($list, self::recursive_child_categories($parent));
        }

        return $list;
    }

    /**
     * Recursively create a list of all categories unter a specified parent
     * @param core_course_category $parent
     * @return core_course_category[]
     */
    protected static function recursive_child_categories(core_course_category $parent) {
        $list = [];
        $children = $parent->get_children();
        foreach ($children as $child) {
            $list[] = $child;
            if ($child->get_children_count() > 0) {
                $list = array_merge($list, self::recursive_child_categories($child));
            }
        }
        return $list;
    }

    /**
     * Return value description for list_available_categories function
     */
    public static function list_available_categories_parameters(): external_function_parameters {
        return new external_function_parameters([
            "operation" => new external_value(PARAM_TEXT, 'type of operation ["view"|"edit"]', VALUE_DEFAULT),
            "refcontext_id" => new external_value(PARAM_INT, 'id of reference context', VALUE_DEFAULT),
        ]);
    }

    /**
     * Parameter description for list_available_categories function
     */
    public static function list_available_categories_returns(): external_description {
        return new external_multiple_structure(static::map_category_structure(true));
    }

    /**
     * List all categories available to the current user for editing or viewing studyplans
     * @param string $operation The operation to scan usage for [edit, view]
     * @param int $refctxid Reference context id
     * @return array
     */
    public static function list_available_categories($operation = 'edit', $refctxid = 0) {
        global $DB;
        if ($operation == "edit") {
            $capability = self::CAP_EDIT;
        } else { // Operation == "view" || default.
            $capability = self::CAP_VIEW;
        }

        // Get the context ids of all categories the user has access to view and wich have the given permission.
        $contextids = [];
        $tops = self::user_tops(null, $capability);
        foreach ($tops as $cat) {
            $ctx = context_coursecat::instance($cat->id);
            $contextids[] = $ctx->id;
        }

        // Now get an overview of the number of study plans in a given context.
        $contextcounts = [];
        $insertctxs = [];
        $rs = $DB->get_recordset_sql("SELECT DISTINCT context_id, COUNT(*) as num FROM {local_treestudyplan}
                                         GROUP BY context_id");
        foreach ($rs as $r) {
            // Build the counts.
            $contextcounts[$r->context_id] = $r->num;
            // Add any of the categories containing studyplans to the list.
            $ctx = context::instance_by_id($r->context_id);
            if (is_object($ctx) && has_capability($capability, $ctx) && !in_array($r->context_id, $contextids)) {
                $insertctxs[] = $ctx;
            }
        }

        $rs->close();

        $cats = [];

        // If the reference context id is not in the list, push it there if the user has proper permissions in that context.
        if ($refctxid > 1 && !in_array($refctxid, $contextids)) {
            try {
                // Get the context.
                $refctx = context::instance_by_id($refctxid);
                // Double check permissions.
                if (is_object($refctx) && has_capability($capability, $refctx)) {
                    $insertctxs[] = $refctx;
                }
            } catch (\dml_missing_record_exception $x) {
                 $refctx = null;
            }
        }

        foreach ($insertctxs as $ictx) {
            // Place this context and all relevant direct parents in the correct spots.
            $ipath = $ictx->get_parent_context_ids(true);
            $found = false;
            foreach ($ipath as $i => $pid) {
                $idx = array_search($pid, $contextids);
                if ($idx !== false) {
                    $contextids = array_merge(
                        array_slice($contextids, 0, $idx + 1),
                        array_reverse(array_slice($ipath, 0, $i)),
                        array_slice($contextids, $idx + 1, count($contextids) - 1)
                    );

                    $found = true;
                    break;
                }
            }
            if (!$found) {
                array_unshift($contextids, $ictx->id);
            }
        }

        // Now translate this to the list of categories.
        foreach ($contextids as $ctxid) {
            try {
                $ctx = context::instance_by_id($ctxid);
                if (is_object($ctx) && $ctx->contextlevel == CONTEXT_SYSTEM) {
                    $cat = \core_course_category::top();
                } else if (is_object($ctx) && $ctx->contextlevel == CONTEXT_COURSECAT) {
                    $cat = \core_course_category::get($ctx->instanceid, \MUST_EXIST, false);
                } else {
                    $cat = null;
                }

                if ($cat != null) {
                    $cats[$cat->id] = $cat;
                    // In edit mode, also include direct children of the currently selected context.
                    if ($operation == "edit" && $ctxid == $refctxid) {
                        // Include direct children for navigation purposes.
                        foreach ($cat->get_children() as $ccat) {
                            $ccatctx = context_coursecat::instance($ccat->id);
                            if (!in_array($ccatctx->id, $contextids)) {
                                $cats[$ccat->id] = $ccat;
                            }
                        }
                    }
                }
            } catch (\dml_missing_record_exception $x) {
                 $ctx = null;
            }
        }

        // And finally build the proper models, including studyplan count in the category context.
        $list = [];
        foreach ($cats as $cat) {
            $count = 0;
            $ctxid = $cat->get_context()->id;
            if (array_key_exists($ctxid, $contextcounts)) {
                $count = $contextcounts[$ctxid];
            }

            $o = static::map_category($cat, true);
            if ($cat->id == 0) {
                $syscontext = context_system::instance();
                $o['category']['name'] = formatter::format_string($syscontext->get_context_name(false, false));
                $o['category']['path'] = [$o['category']['name']];
            } else {
                $path = explode("/", $cat->path);
                array_shift($path);
                $catpath = [];
                foreach ($path as $pid) {
                    $p = $cats[intval($pid)];
                    $catpath[] = formatter::format_string($p->name);
                }
                $o['category']['path'] = $catpath;
            }
            $o["studyplancount"] = $count;
            $list[] = $o;
        }
        return $list;
    }

    /**************************************
     *
     * Progress scanners for teacherview
     *
     **************************************/

    /**
     * Return value description for scan_grade_progress function
     * @return external_function_parameters
     */
    public static function scan_grade_progress_parameters(): external_function_parameters {
        return new external_function_parameters([
            "gradeitemid" => new external_value(PARAM_INT, 'Grade item ID to scan progress for', VALUE_DEFAULT),
            "studyplanid" => new external_value(PARAM_INT, 'Study plan id to check progress in', VALUE_DEFAULT),
        ]);
    }

    /**
     * Parameter description for scan_grade_progress function
     */
    public static function scan_grade_progress_returns(): external_description {
        return gradingscanner::structure(VALUE_REQUIRED);
    }

    /**
     * Scan grade item for progress statistics
     * @param mixed $gradeitemid Grade item id
     * @param mixed $studyplanid Id of studyitem the grade is selected in
     * @return array
     */
    public static function scan_grade_progress($gradeitemid, $studyplanid) {
        // Verify access to the study plan.
        $o = studyplan::find_by_id($studyplanid);
        webservicehelper::require_capabilities(self::CAP_VIEW, $o->context());

        // Retrieve grade item.
        $gi = \grade_item::fetch(["id" => $gradeitemid]);

        if (!is_object($gi)) {
            throw new moodle_exception("unknownerror");
        }

        // Validate course is linked to studyplan.
        $courseid = $gi->courseid;
        if (!$o->course_linked($courseid)) {
            throw new \webservice_access_exception(
                "Course {$courseid} linked to grade item {$gradeitemid} is not linked to studyplan {$o->id()}"
            );
        }

        $scanner = new gradingscanner($gi);
        return $scanner->model();
    }

    /**
     * Return value description for scan_completion_progress function
     */
    public static function scan_completion_progress_parameters(): external_function_parameters {
        return new external_function_parameters([
            "criteriaid" => new external_value(PARAM_INT, 'CriteriaID to scan progress for', VALUE_DEFAULT),
            "studyplanid" => new external_value(PARAM_INT, 'Study plan id to check progress in', VALUE_DEFAULT),
            "courseid" => new external_value(PARAM_INT, 'Course id of criteria', VALUE_DEFAULT),
        ]);
    }

    /**
     * Parameter description for scan_completion_progress function
     */
    public static function scan_completion_progress_returns(): external_description {
        return completionscanner::structure(VALUE_REQUIRED);
    }

    /**
     * Scan criterium for progress statistics
     * @param mixed $criteriaid Id of criterium
     * @param mixed $studyitemid Id of studyplan relevant to this criteria
     * @return array
     */
    public static function scan_completion_progress($criteriaid, $studyitemid) {
        // Verify access to the study plan.
        $item = studyitem::find_by_id($studyitemid);
        $o = $item->studyline()->studyplan();
        webservicehelper::require_capabilities(self::CAP_VIEW, $o->context());

        $crit = \completion_criteria::fetch(["id" => $criteriaid]);
        $scanner = new completionscanner($crit, $studyitemid);
        return $scanner->model();
    }

    /**
     * Return value description for scan_badge_progress function
     */
    public static function scan_badge_progress_parameters(): external_function_parameters {
        return new external_function_parameters([
            "badgeid" => new external_value(PARAM_INT, 'Badge to scan progress for', VALUE_DEFAULT),
            "studyplanid" => new external_value(
                PARAM_INT,
                'Study plan id to limit progress search to (to determine which students to scan)',
                VALUE_DEFAULT
            ),
        ]);
    }

    /**
     * Parameter description for scan_badge_progress function
     */
    public static function scan_badge_progress_returns(): external_description {
        return new external_single_structure([
            "total" => new external_value(PARAM_INT, 'Total number of students scanned'),
            "issued" => new external_value(PARAM_INT, 'Number of issued badges'),
        ]);
    }

    /**
     * Scan badge for completion progress statistica
     * @param mixed $badgeid ID of the badge
     * @param mixed $studyplanid ID of the relevant study plan
     * @return array
     */
    public static function scan_badge_progress($badgeid, $studyplanid) {
        // Check access to the study plan.
        $o = studyplan::find_by_id($studyplanid);
        webservicehelper::require_capabilities(self::CAP_VIEW, $o->context());

        // Validate that badge is linked to studyplan.
        if (!$o->badge_linked($badgeid)) {
            throw new \webservice_access_exception("Badge {$badgeid} is not linked to studyplan {$o->id()}");
        }

        // Get badge info.
        $badge = new \core_badges\badge($badgeid);
        $badgeinfo = new badgeinfo($badge);

        // Get the connected users.
        $students = associationservice::all_associated($studyplanid);
        // Just get the user ids.
        $studentids = array_map(function ($a) {
            return $a["id"];
        }, $students);

        return [
            "total" => count($studentids),
            "issued" => $badgeinfo->count_issued($studentids),
        ];
    }

    /****************************
     *                          *
     * search courses           *
     *                          *
     ****************************/

    /**
     * Parameter description for webservice function search_courses
     */
    public static function search_courses_parameters(): external_function_parameters {
        return new external_function_parameters([
            "search" => new external_value(PARAM_TEXT, 'search string', VALUE_DEFAULT),
            "studyplanid" => new external_value(
                PARAM_INT,
                'Study plan id to limit progress search to (to determine which students to scan)',
                VALUE_DEFAULT
            ),
        ]);
    }

    /**
     * Return value description for webservice function search_courses
     */
    public static function search_courses_returns(): external_description {
        return  new external_multiple_structure(new external_single_structure([
            "id" => new external_value(PARAM_INT, 'course category id'),
            "context_id" => new external_value(PARAM_INT, 'course category context id'),
            "category" => contextinfo::structure(VALUE_OPTIONAL),
            "haschildren" => new external_value(PARAM_BOOL, 'true if the category has child categories'),
            "hascourses" => new external_value(PARAM_BOOL, 'true if the category contains courses'),
            "studyplancount" => new external_value(PARAM_INT, 'number of linked studyplans', VALUE_OPTIONAL),
            "courses" => new external_multiple_structure(
                courseinfo::simple_structure()
            ),
        ], "CourseCat info"));
    }

    /**
     * List all available site badges to drag into a studyplan page
     * @param string $search Search string to use
     * @param int $studyplanid Id of a potential root studyplan
     * @return array
     */
    public static function search_courses($search = "", $studyplanid = 0) {
        webservicehelper::system_context(); // Validate system context for this call.
        global $DB;

        $rootcats = [];
        // Determine relevant root categories.
        if ($studyplanid > 0 && \get_config("local_treestudyplan", "limitcourselist")) {
            // Speed up category loading by using direct sql query.
            $sql = "SELECT cat.*
                    FROM {local_treestudyplan} s
                    INNER JOIN {context} c ON s.context_id = c.id
                    INNER JOIN {course_categories} cat ON c.instanceid = cat.id
                    WHERE s.id = :studyplanid AND c.contextlevel = :coursecontextlevel
                    LIMIT 1";
            $params = [
                "studyplanid" => $studyplanid,
                "coursecontextlevel" => \CONTEXT_COURSECAT,
            ];
            $cat = $DB->get_record_sql($sql, $params);
            $rootcats[] = $cat;
        } else {
            $rootcats = self::user_tops();
            if (count($rootcats) == 0) {
                throw new moodle_exception("error:nocategoriesvisible", "local_treestudyplan");
            }
        }

        // Create an sql WHERE clause to check if a category should be accessible.
        $inrootparams = [];
        $inrootlikes = [];
        foreach ($rootcats as $rootcat) {
            $inrootlikes[] = $DB->sql_like('cat.path', ":rootpath{$rootcat->id}");
            $inrootparams["rootpath{$rootcat->id}"] = "{$rootcat->path}/%";
            $inrootlikes[] = "cat.id = :rootcatid{$rootcat->id}";
            $inrootparams["rootcatid{$rootcat->id}"] = $rootcat->id;
        }
        $inrootsql = "( " . implode(" OR ", $inrootlikes) . " )";
        unset($inrootlikes); // Cleanup.

        $categories = []; // Stores the retrieved categories.

        if (strlen($search) > 0) { // Only progress if we have an actual search string.
            $search = \core_text::strtolower(trim($search));
            // Search courses directly.
            $sql = "SELECT c.*, cat.id AS catid, cat.name AS catname, cat.path AS catpath
                    FROM {course} c
                    LEFT JOIN {course_categories} cat ON cat.id = c.category
                    WHERE $inrootsql
                        AND ( " . $DB->sql_like('LOWER( c.fullname )', ':search') . "
                            OR " . $DB->sql_like('LOWER( c.shortname )', ':search2') . "
                            OR " . $DB->sql_like('LOWER( c.idnumber )', ':search3') . "
                            )";

            $params = [
                'search' => "%$search%",
                'search2' => "%$search%",
                'search3' => "%$search%",
            ] + $inrootparams;

            $courseids = [];  // Stores retrieved course ids in first search.
            $parentids = [];  // Stores parentids that need to be retrieved.

            $rs = $DB->get_recordset_sql($sql, $params);
            foreach ($rs as $r) {
                if (!array_key_exists($r->catid, $categories)) { // Create category element if needed.
                    // Determine path and parents.
                    $path = trim($r->catpath, '/'); // Kill leading slash.
                    $path = explode('/', $path);
                    $parents = $path; // Array copies like this.
                    array_pop($parents); // Remove own id from parents.
                    array_push($parentids, ...$parents); // And push to the main array using spread operatir.

                    // Create the data structure.
                    $categories[$r->catid] = [
                        "id" => $r->catid,
                        "context_id" => 0, // Irrelevant here.
                        "category" => [
                            "name" => $r->catname,
                            "path" => $path,
                        ],
                        "courses" => [],
                        "hascourses" => true, // Courses will be added in a moment.
                        "children" => [],
                        "haschildren" => false, // Will be changed later if needed.
                    ];
                }
                $courseinfo = new courseinfo($r);
                $courseids[] = $r->id;
                $categories[$r->catid]["courses"][] = $courseinfo->simple_model(); // Add new instance to category list.
            }
            $rs->close();

            // Also search custom field used for display name if one is set.
            $displayfield = get_config("local_treestudyplan", "display_field");
            if (strpos($displayfield, "customfield_") === 0) {
                $fieldname = substr($displayfield, strlen("customfield_"));
                $fieldid = $DB->get_field('customfield_field', 'id', ['shortname' => $fieldname]);

                $sql = "SELECT c.*, cat.id AS catid, cat.name AS catname, cat.path AS CATPATH
                        FROM {customfield_data} cfd
                        INNER JOIN {context} ctx ON ctx.id = cfd.contextid
                        INNER JOIN {course} c on ctx.instanceid = c.id
                        LEFT JOIN {course_categories} cat ON cat.id = c.category
                        WHERE cfd.fieldid = :fieldid AND ctx.contextlevel = :coursecontextlevel
                            AND $inrootsql
                            AND " . $DB->sql_like('LOWER( cfd.value )', ':search');

                // Add condition to exclude already found courses.
                if (count($courseids) > 0) {
                    [$incourseidssql, $incourseidsparams] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, "course");
                    $sql .= "AND NOT( c.id {$incourseidssql} )";
                } else {
                    $incourseidsparams = [];
                }

                // Using [] + [] concat operator below, since that respects keys properly. array_merge() doesn't always.
                $params = [
                    "coursecontextlevel" => \CONTEXT_COURSE,
                    "fieldid" => $fieldid,
                    "search" => "%$search%",
                ] + $incourseidsparams + $inrootparams;

                $rs = $DB->get_recordset_sql($sql, $params);
                foreach ($rs as $r) {
                    if (!in_array($r->id, $courseids)) { // Avoid adding duplicate results.
                        $courseids[] = $r->id; // Add to courseid to list to avoid duplicates.
                        if (!array_key_exists($r->catid, $categories)) { // Create category element if needed.
                            // Determine path and parents.
                            $path = trim($r->catpath, '/'); // Kill leading slash.
                            $path = explode('/', $path);
                            $parents = $path; // Array copies like this.
                            array_pop($parents); // Remove own id from parents.
                            array_push($parentids, ...$parents); // And push to the main array using spread operatir.

                            // Creat the data structure.
                            $categories[$r->catid] = [
                                "id" => $r->catid,
                                "context_id" => 0, // Irrelevant here.
                                "category" => [
                                    "name" => $r->catname,
                                    "path" => $path,
                                ],
                                "courses" => [],
                                "hascourses" => true, // Courses will be added in a moment.
                                "children" => [],
                                "haschildren" => false, // Will be changed later if needed.
                            ];
                        }

                        // Add new instance to category list.
                        $courseinfo = new courseinfo($r);
                        $categories[$r->catid]["courses"][] = $courseinfo->simple_model();
                    }
                }
                $rs->close();
            }
            // Make sure all parents up to root are loaded.
            // Remove already loaded ids from parent id list.
            $parentids = array_diff($parentids, array_keys($categories));

            if (count($parentids) > 0) {
                // Prepare sql IN statement for all parent ids we need to load.
                [$inparentlistsql, $inparentlistparams] = $DB->get_in_or_equal($parentids, SQL_PARAMS_NAMED, "parent");

                $sql = "SELECT cat.id AS catid, cat.name AS catname, cat.path AS catpath
                        FROM  {course_categories} cat
                        WHERE cat.id $inparentlistsql
                            AND $inrootsql
                        ";
                $params = $inparentlistparams + $inrootparams;
                $rs = $DB->get_recordset_sql($sql, $params);
                foreach ($rs as $r) {
                    if (!array_key_exists($r->catid, $categories)) { // Create category element if needed.
                        // Determine path.
                        $path = trim($r->catpath, '/'); // Kill leading slash.
                        $path = explode('/', $path);
                        // Create the data structure if needed.
                        $categories[$r->catid] = [
                            "id" => $r->catid,
                            "context_id" => $r->catid,
                            "category" => [
                                "name" => $r->catname,
                                "path" => $path,
                            ],
                            "courses" => [],
                            "hascourses" => false, // Courses swill be added later.
                            "children" => [],
                            "haschildren" => true,
                        ];
                    }
                }
                $rs->close();
            }
        }

        return $categories;
    }
}
