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

/**
 * Model class for study 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;

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;

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

require_once($CFG->libdir . '/externallib.php');

/**
 * Model class for study items
 */
class studyitem {
    /** @var string */
    public const COURSE = 'course';
    /** @var string */
    public const JUNCTION = 'junction';
    /** @var string */
    public const BADGE = 'badge';
    /** @var string */
    public const FINISH = 'finish';
    /** @var string */
    public const START = 'start';
    /** @var string */
    public const INVALID = 'invalid';

    /** @var string */
    public const TABLE = "local_treestudyplan_item";

    /**
     * Cache retrieved studyitems in this session
     * @var array */
    private static $cache = [];
    /**
     * Holds database record
     * @var stdClass
     */
    private $r;
    /** @var int */
    private $id;

    /** @var courseinfo */
    private $courseinfo = null;
    /** @var studyline */
    private $studyline;
    /** @var aggregator */
    private $aggregator;

    /**
     * Return the context the studyplan is associated to
     */
    public function context(): context {
        return $this->studyline->context();
    }

    /**
     * Return the studyline for this item
     */
    public function studyline(): studyline {
        return $this->studyline;
    }

    /**
     * Return the condition string for this item
     */
    public function conditions(): string {
        if ($this->r->type == self::COURSE) {
            return (!empty($this->r->conditions)) ? $this->r->conditions : "";
        } else {
            return (!empty($this->r->conditions)) ? $this->r->conditions : "ALL";
        }
    }

    /**
     * Find record in database and return management object
     * @param int $id Id of database record
     */
    public static function find_by_id($id): self {
        if (!array_key_exists($id, self::$cache)) {
            self::$cache[$id] = new self($id, null);
        }
        return self::$cache[$id];
    }

    /**
     * Find record in database and return management object
     * @param self $o Id of database record
     */
    private static function cache_update($o) {
        if (!array_key_exists($o->id(), self::$cache)) {
            self::$cache[$o->id()] = $o;
        }
    }


    /**
     * Construct a new model based on study item id
     * @param int|object $r Study item record or id
     * @param studyline|null $line Study line object
     */
    public function __construct($r, $line) {
        global $DB;
        if (is_numeric($r)) {
            $this->id = $r;
            $this->r = $DB->get_record(self::TABLE, ['id' => $this->id], "*", MUST_EXIST);
        } else {
            $this->id = $r->id;
            $this->r = $r;
        }
        if (empty($line)) {
            $this->studyline = studyline::find_by_id($this->r->line_id);
        } else {
            $this->studyline = $line;
        }
        $this->aggregator = $this->studyline()->studyplan()->aggregator();
    }

    /**
     * Return database identifier
     */
    public function id(): int {
        return $this->id;
    }

    /**
     * Return period slot for this item
     */
    public function slot(): int {
        return $this->r->slot;
    }

    /**
     * Return layer (order within a line and slot) for this item
     */
    public function layer(): int {
        return $this->r->layer;
    }

    /**
     * Return study item type (see constants above)
     */
    public function type(): string {
        return $this->r->type;
    }

    /**
     * Return period span
     */
    public function span(): string {
        return $this->r->span;
    }

    /**
     * Return id of linked course (only relevant on COURSE types) or 0 if none
     */
    public function courseid(): int {
        return $this->r->course_id;
    }

    /**
     * Check if a studyitem with the given id exists
     * @param int $id Id of studyplan
     */
    public static function exists($id): bool {
        global $DB;
        return is_numeric($id) && $DB->record_exists(self::TABLE, ['id' => $id]);
    }

    /**
     * Webservice structure for simple info
     * @param int $value Webservice requirement constant
     */
    public static function simple_structure($value = VALUE_REQUIRED): external_description {
        return new external_single_structure([
            "id" => new external_value(PARAM_INT, 'id of study item'),
            "type" => new external_value(PARAM_TEXT, 'shortname of study item'),
            "slot" => new external_value(PARAM_INT, 'slot in the study plan'),
            "layer" => new external_value(PARAM_INT, 'layer in the slot'),
            "span" => new external_value(PARAM_INT, 'how many periods the item spans'),
            "course" => courseinfo::simple_structure(VALUE_OPTIONAL),
            "badge" => badgeinfo::simple_structure(VALUE_OPTIONAL),
        ], "", $value);
    }

    /**
     * Webservice model for editor info
     * @return array Webservice data model
     */
    public function simple_model() {
        global $DB;
        $model = [
            'id' => $this->r->id, // Id is needed in export model because of link references.
            'type' => $this->r->type,
            'slot' => $this->r->slot,
            'layer' => $this->r->layer,
            'span' => $this->r->span,
        ];
        $ci = $this->getcourseinfo();
        if (isset($ci)) {
            $model['course'] = $ci->simple_model();
        }
        if (is_numeric($this->r->badge_id) && $DB->record_exists('badge', ['id' => $this->r->badge_id])) {
            $badge = new \core_badges\badge($this->r->badge_id);
            $badgeinfo = new badgeinfo($badge);
            $model['badge'] = $badgeinfo->simple_model();
        }
        return $model;
    }

    /**
     * Webservice structure for editor info
     * @param int $value Webservice requirement constant
     */
    public static function editor_structure($value = VALUE_REQUIRED): external_description {
        return new external_single_structure([
            "id" => new external_value(PARAM_INT, 'id of study item'),
            "type" => new external_value(PARAM_TEXT, 'shortname of study item'),
            "conditions" => new external_value(PARAM_TEXT, 'conditions for completion'),
            "slot" => new external_value(PARAM_INT, 'slot in the study plan'),
            "layer" => new external_value(PARAM_INT, 'layer in the slot'),
            "span" => new external_value(PARAM_INT, 'how many periods the item spans'),
            "course" => courseinfo::editor_structure(VALUE_OPTIONAL),
            "badge" => badgeinfo::editor_structure(VALUE_OPTIONAL),
            "continuation_id" => new external_value(PARAM_INT, 'id of continued item'),
            "connections" => new external_single_structure([
                'in' => new external_multiple_structure(studyitemconnection::structure()),
                'out' => new external_multiple_structure(studyitemconnection::structure()),
            ]),
        ], "", $value);
    }

    /**
     * Webservice model for editor info
     * @return array Webservice data model
     */
    public function editor_model() {
        return $this->generate_model("editor");
    }


    /**
     * Webservice structure for teacher info
     * @param int $value Webservice requirement constant
     */
    public static function teacher_structure($value = VALUE_REQUIRED): external_description {
        return new external_single_structure([
            "id" => new external_value(PARAM_INT, 'id of study item'),
            "type" => new external_value(PARAM_TEXT, 'shortname of study item'),
            "conditions" => new external_value(PARAM_TEXT, 'conditions for completion'),
            "slot" => new external_value(PARAM_INT, 'slot in the study plan'),
            "layer" => new external_value(PARAM_INT, 'layer in the slot'),
            "span" => new external_value(PARAM_INT, 'how many periods the item spans'),
            "course" => courseinfo::teacher_structure(VALUE_OPTIONAL),
            "badge" => badgeinfo::editor_structure(VALUE_OPTIONAL), // Provides both teacher and editor views.
            "continuation_id" => new external_value(PARAM_INT, 'id of continued item'),
            "connections" => new external_single_structure([
                'in' => new external_multiple_structure(studyitemconnection::structure()),
                'out' => new external_multiple_structure(studyitemconnection::structure()),
            ]),
        ], "", $value);
    }

    /**
     * Webservice model for editor info
     * @return array Webservice data model
     */
    public function teacher_model() {
        return $this->generate_model("teacher");
    }

    /**
     * Create a model for the given type of operation
     * @param string $mode One of [ 'editor', 'export']
     */
    private function generate_model($mode) {
        // Mode parameter is used to geep this function for both editor model and export model.
        // (Export model results in fewer parameters on children, but is otherwise basically the same as this function).
        global $DB;

        $model = [
            'id' => $this->r->id, // Id is needed in export model because of link references.
            'type' => $this->r->type,
            'conditions' => $this->conditions(),
            'slot' => $this->r->slot,
            'layer' => $this->r->layer,
            'span' => $this->r->span,
            'continuation_id' => $this->r->continuation_id,
            'connections' => [
                "in" => [],
                "out" => [],
            ],
        ];
        if ($mode == "export") {
            // Remove slot and layer.
            unset($model["slot"]);
            unset($model["layer"]);
            unset($model["continuation_id"]);
            $model["connections"] = []; // In export mode, connections is just an array of outgoing connections.
        }

        // Add course link if available.
        $ci = $this->getcourseinfo();
        if (isset($ci)) {
            if ($mode == "export") {
                $model['course'] = $ci->shortname();
            } else if ($mode == "teacher") {
                $model['course'] = $ci->teacher_model();
            } else {
                $model['course'] = $ci->editor_model();
            }
        }

        // Add badge info if available.
        if (is_numeric($this->r->badge_id) && $DB->record_exists('badge', ['id' => $this->r->badge_id])) {
            $badge = new \core_badges\badge($this->r->badge_id);
            $badgeinfo = new badgeinfo($badge);
            if ($mode == "export") {
                $model['badge'] = $badgeinfo->name();
            } else if ($mode == "teacher") {
                /*  Also supply a list of linked users, so the badgeinfo can give stats on
                    the amount issued, related to this studyplan.
                    Supplying this parameter adds teacher relevant data.
                */
                $studentids = $this->studyline()->studyplan()->find_linked_userids();
                $model['badge'] = $badgeinfo->editor_model($studentids);
            } else {
                $model['badge'] = $badgeinfo->editor_model();
            }
        }

        if ($mode == "export") {
            // Also export gradables.
            $gradables = gradeinfo::list_studyitem_gradables($this);
            if (count($gradables) > 0) {
                $model["gradables"] = [];
                foreach ($gradables as $g) {
                    $model["gradables"][] = $g->export_model();
                }
            }
        }

        // Add incoming and outgoing connection info.
        $connout = studyitemconnection::find_outgoing($this->id);

        if ($mode == "export") {
            foreach ($connout as $c) {
                $model["connections"][] = $c->to_id();
            }
        } else {
            foreach ($connout as $c) {
                $model['connections']['out'][$c->to_id()] = $c->model();
            }
            $connin = studyitemconnection::find_incoming($this->id);
            foreach ($connin as $c) {
                $model['connections']['in'][$c->from_id()] = $c->model();
            }
        }

        return $model;
    }

    /**
     * Add a new item
     * @param array $fields Properties for study line ['line_id', 'type', 'layer', 'conditions', 'slot',
     *                      'competency_id', 'course_id', 'badge_id', 'continuation_id', 'span']
     */
    public static function add($fields): self {
        global $DB;
        $addable = ['line_id', 'type', 'layer', 'conditions', 'slot',
                    'competency_id', 'course_id', 'badge_id', 'continuation_id', 'span'];
        $info = [ 'layer' => 0 ];
        foreach ($addable as $f) {
            if (array_key_exists($f, $fields)) {
                $info[$f] = $fields[$f];
            }
        }
        $id = $DB->insert_record(self::TABLE, $info);
        $item = self::find_by_id($id);
        if ($item->type() == self::COURSE) {
            // Signal the studyplan that a course has been added so it can be marked for csync cascading.
            $item->studyline()->studyplan()->mark_csync_changed();
        }
        return $item;
    }

    /**
     * Edit study item properties
     * @param array $fields Changed roperties for study line ['conditions', 'course_id', 'continuation_id', 'span']
     */
    public function edit($fields): self {
        global $DB;
        $editable = ['conditions', 'course_id', 'continuation_id', 'span'];

        $info = ['id' => $this->id ];
        foreach ($editable as $f) {
            if (array_key_exists($f, $fields) && isset($fields[$f])) {
                $info[$f] = $fields[$f];
            }
        }

        $DB->update_record(self::TABLE, $info);
        // Reload record after edit.
        $this->r = $DB->get_record(self::TABLE, ['id' => $this->id], "*", MUST_EXIST);
        return $this;
    }

    /**
     * Delete studyitem
     * @param bool $force Force deletion even if item is referenced
     */
    public function delete($force = false) {
        global $DB;

        // Check if this item is referenced in a START item.
        if ($force) {
            // Clear continuation id from any references to this item.
            $records = $DB->get_records(self::TABLE, ['continuation_id' => $this->id]);
            foreach ($records as $r) {
                $r->continuation_id = 0;
                $DB->update_record(self::TABLE, $r);
            }
        }

        if ($DB->count_records(self::TABLE, ['continuation_id' => $this->id]) > 0) {
            return success::fail('Cannot remove: item is referenced by another item');
        } else {
            // Delete al related connections to this item.
            studyitemconnection::clear($this->id);
            // Delete all grade inclusion references to this item.
            $DB->delete_records("local_treestudyplan_gradeinc", ['studyitem_id' => $this->id]);
            // Delete the item itself.
            $DB->delete_records(self::TABLE, ['id' => $this->id]);

            return success::success();
        }
    }

    /**
     * Reposition study items in line, layer and/or slot
     * @param mixed $resequence Array of item info [id, line_id, slot, layer]
     */
    public static function reorder($resequence): success {
        global $DB;

        foreach ($resequence as $sq) {
            // Only change line_id if new line is within the same studyplan page.
            if (
                self::find_by_id($sq['id'])->studyline()->page()->id() ==
                studyline::find_by_id($sq['line_id'])->page()->id()
            ) {
                $DB->update_record(self::TABLE, [
                    'id' => $sq['id'],
                    'line_id' => $sq['line_id'],
                    'slot' => $sq['slot'],
                    'layer' => $sq['layer'],
                ]);
            }
        }

        return success::success();
    }

    /**
     * Find all studyitems associated with a studyline
     * @param studyline $line The studyline to search for
     * @return studyitem[]
     */
    public static function find_studyline_children(studyline $line): array {
        global $DB;
        $list = [];
        $sql = "SELECT *
                FROM {local_treestudyplan_item}
                WHERE line_id = :line_id
                ORDER BY layer";
        $rs = $DB->get_recordset_sql($sql, ['line_id' => $line->id()]);
        foreach ($rs as $r) {
            $item = new self($r, $line);
            self::cache_update($item);
            $list[] = $item;
        }
        $rs->close();
        return $list;
    }

    /**
     * List all studyitems of a given type in a specific period in this line
     * @param studyline $line The studyline to search for
     * @param int $slot The slot to search in
     * @param int|int[] $type The type of items to include
     * @return studyitem[]
     */
    public static function search_studyline_children(studyline $line, $slot, $type): array {
        global $DB;
        [$intype, $inparams] = $DB->get_in_or_equal($type, SQL_PARAMS_NAMED, 'typ');
        $list = [];
        $sql = "SELECT *
                FROM {local_treestudyplan_item}
                WHERE line_id = :line_id
                    AND type {$intype}
                    AND ( slot <= :slota AND ( slot + (span-1) >= :slotb ) )
                ORDER BY layer";
        $params = $inparams + ['line_id' => $line->id(), 'slota' => $slot, 'slotb' => $slot, 'type' => $type];

        $rs = $DB->get_recordset_sql($sql, $params);
        foreach ($rs as $r) {
            $item = new self($r, $line);
            self::cache_update($item);
            $list[] = $item;
        }
        $rs->close();
        return $list;
    }

    /**
     * Webservice structure for linking between plans
     * @param int $value Webservice requirement constant
     */
    private static function link_structure($value = VALUE_REQUIRED): external_description {
        return new external_single_structure([
            "id" => new external_value(PARAM_INT, 'id of study item'),
            "type" => new external_value(PARAM_TEXT, 'type of study item'),
            "completion" => completion::structure(),
            "studyline" => new external_value(PARAM_TEXT, 'reference label of studyline'),
            "studyplan" => new external_value(PARAM_TEXT, 'reference label of studyplan'),
        ], 'basic info of referenced studyitem', $value);
    }

    /**
     * Webservice structure for userinfo
     * @param int $value Webservice requirement constant
     */
    public static function user_structure($value = VALUE_REQUIRED): external_description {
        return new external_single_structure([
            "id"            => new external_value(PARAM_INT, 'id of study item'),
            "type"          => new external_value(PARAM_TEXT, 'type of study item'),
            "completion"    => new external_value(PARAM_TEXT, 'completion state (incomplete|progress|completed|excellent)'),
            "slot"          => new external_value(PARAM_INT, 'slot in the study plan'),
            "layer"         => new external_value(PARAM_INT, 'layer in the slot'),
            "span"          => new external_value(PARAM_INT, 'how many periods the item spans'),
            "course"        => courseinfo::user_structure(VALUE_OPTIONAL),
            "badge"         => badgeinfo::user_structure(VALUE_OPTIONAL),
            "continuation"  => self::link_structure(VALUE_OPTIONAL),
            "connections" => new external_single_structure([
                'in' => new external_multiple_structure(studyitemconnection::structure()),
                'out' => new external_multiple_structure(studyitemconnection::structure()),
            ]),
            "lineenrolled"      => new external_value(PARAM_BOOL, 'student is enrolled in the line this item is in'),
        ], 'Study item info', $value);
    }

    /**
     * Webservice model for user info
     * @param int $userid ID of user to check specific info for
     * @return array Webservice data model
     */
    public function user_model($userid) {
        $model = [
            'id' => $this->r->id,
            'type' => $this->r->type,
            'completion' => completion::label($this->completion($userid)),
            'slot' => $this->r->slot,
            'layer' => $this->r->layer,
            'span' => $this->r->span,
            'connections' => [
                "in" => [],
                "out" => [],
            ],
            "lineenrolled" => $this->studyline()->isenrolled($userid),
        ];

        // Add badge info if available.
        if (badgeinfo::exists($this->r->badge_id)) {
            $badge = new \core_badges\badge($this->r->badge_id);
            $badgeinfo = new badgeinfo($badge);
            $model['badge'] = $badgeinfo->user_model($userid);
        }

        // Add course if available.
        if (courseinfo::exists($this->r->course_id)) {
            $cinfo = $this->getcourseinfo();
            if (is_object($cinfo)) {
                $model['course'] = $cinfo->user_model($userid);
            }
        }

        // Add incoming and outgoing connection info.
        [$connin, $connout] = studyitemconnection::find_all($this->id);
        foreach ($connout as $c) {
            $model['connections']['out'][$c->to_id()] = $c->model();
        }
        foreach ($connin as $c) {
            $model['connections']['in'][$c->from_id()] = $c->model();
        }

        return $model;
    }

    /**
     * Get courseinfo for studyitem if it references a course
     */
    public function getcourseinfo(): ?courseinfo {
        if (empty($this->courseinfo)) {
            try {
                if (!empty($this->r->course_id)) {
                    $course = preloader::get_course($this->r->course_id);
                    $this->courseinfo = new courseinfo($course, $this);
                } else {
                    $this->courseinfo = null;
                }
            } catch (\dml_exception $x) {
                $this->courseinfo = null;
            }
        }
        return $this->courseinfo;
    }

    /**
     * Determine completion for a particular user
     * @param int $userid User id
     * @return int completion:: constant
     */
    public function completion($userid): int {
        global $DB;

        if (strtolower($this->r->type) == 'course') {
            // Determine competency by competency completion.
            $courseinfo = $this->getcourseinfo();
            if (is_object($courseinfo)) {
                return $this->aggregator->aggregate_course($courseinfo, $this, $userid);
            } else {
                return completion::INCOMPLETE;
            }
        } else if (strtolower($this->r->type) == 'start') {
            // Does not need to use aggregator.
            // Either true, or the completion of the reference.
            if (self::exists($this->r->continuation_id)) {
                $citem = self::find_by_id($this->r->continuation_id);
                return $citem->completion($userid);
            } else {
                return completion::COMPLETED;
            }
        } else if (in_array(strtolower($this->r->type), ['junction', 'finish'])) {
            // Completion of the linked items, according to the rule.
            $incomingcompletions = [];
            // Retrieve incoming connections.
            $incoming = $DB->get_records(studyitemconnection::TABLE, ['to_id' => $this->r->id]);
            foreach ($incoming as $conn) {
                $item = self::find_by_id($conn->from_id);
                $incomingcompletions[] = $item->completion($userid);
            }
            return $this->aggregator->aggregate_junction($incomingcompletions, $this, $userid);
        } else if (strtolower($this->r->type) == 'badge') {
            global $DB;
            // Badge awarded.
            if (badgeinfo::exists($this->r->badge_id)) {
                $badge = new \core_badges\badge($this->r->badge_id);
                if ($badge->is_issued($userid)) {
                    if ($badge->can_expire()) {
                        // Get the issued badges and check if any of them have not expired yet.
                        $badgesissued = $DB->get_records(
                            "badge_issued",
                            [
                                "badge_id" => $this->r->badge_id,
                                "user_id" => $userid,
                            ]
                        );
                        $notexpired = false;
                        $now = time();
                        foreach ($badgesissued as $bi) {
                            if ($bi->dateexpire == null || $bi->dateexpire > $now) {
                                $notexpired = true;
                                break;
                            }
                        }
                        return ($notexpired) ? completion::COMPLETED : completion::INCOMPLETE;
                    } else {
                        return completion::COMPLETED;
                    }
                } else {
                    return completion::INCOMPLETE;
                }
            } else {
                return completion::INCOMPLETE;
            }
        } else {
            // Return incomplete for other types.
            return completion::INCOMPLETE;
        }
    }

    /**
     * Duplicate this studyitem
     * @param studyline $newline Study line to add duplicate to
     */
    public function duplicate($newline): self {
        global $DB;
        // Clone the database fields.
        $fields = clone $this->r;
        // Set new line id.
        unset($fields->id);
        $fields->line_id = $newline->id();
        // Create new record with the new data.
        $id = $DB->insert_record(self::TABLE, (array)$fields);
        $new = self::find_by_id($id, $newline);

        // Copy the grading info if relevant.
        $gradables = gradeinfo::list_studyitem_gradables($this);
        foreach ($gradables as $g) {
            gradeinfo::include_grade($g->get_gradeitem()->id, $new->id(), true);
        }
        return $new;
    }

    /**
     * Export essential information for export
     * @return array information model
     */
    public function export_model() {
        return $this->generate_model("export");
    }

    /**
     * Import studyitems from model
     * @param array $model Decoded array
     */
    public static function import_item($model): self {
        unset($model["course_id"]);
        unset($model["competency_id"]);
        unset($model["badge_id"]);
        unset($model["continuation_id"]);
        if (isset($model["course"])) {
            $model["course_id"] = courseinfo::id_from_shortname(($model["course"]));
        }
        if (isset($model["badge"])) {
            $model["badge_id"] = badgeinfo::id_from_name(($model["badge"]));
        }

        $item = self::add($model);

        if (isset($model["course_id"])) {
            // Attempt to import the gradables.
            foreach ($model["gradables"] as $gradable) {
                gradeinfo::import($item, $gradable);
            }
        }

        return $item;
    }
}
