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

/**
 * Aggregate course results based on failed/completed states for grades
 * @package    local_treestudyplan
 * @copyright  2023 P.M. Kuipers
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace local_treestudyplan\local\aggregators;

use local_treestudyplan\courseinfo;
use local_treestudyplan\gradeinfo;
use local_treestudyplan\studyitem;
use local_treestudyplan\completion;
use local_treestudyplan\preloader;

/**
 * Aggregate course results based on failed/completed states for grades
 */
class bistate_aggregator extends \local_treestudyplan\aggregator {
    /** @var bool */
    public const DEPRECATED = false;
    /** @var stdClass */
    private $agcfg = null;

    /**
     * Retrieve or initialize current config object
     * @return stdClass
     */
    private function cfg() {
        if (empty($this->agcfg)) {
            $this->agcfg = (object)[
                'thresh_excellent' => 1.0, // Minimum fraction that must be completed to aggregate as excellent (usually 1.0).
                'thresh_good' => 0.8, // Minimum fraction that must be completed to aggregate as good.
                'thresh_completed' => 0.66, // Minimum fraction that must be completed to aggregate as completed.
                'use_failed' => true, // Support failed completion yes/no.
                'thresh_progress' => 0.33, // Deprecated!
                'accept_pending_as_submitted' => false, // Also count ungraded but submitted .
            ];
        }
        return $this->agcfg;
    }

    /**
     * Create new instance of aggregation method
     * @param string $configstr Aggregation configuration string
     */
    public function __construct($configstr) {
        // Allow public constructor for testing purposes.
        $this->initialize($configstr);
    }

    /**
     * Initialize the aggregation method
     * @param string $configstr Aggregation configuration string
     */
    protected function initialize($configstr) {
        // First initialize with the defaults.
        foreach (["thresh_excellent", "thresh_good", "thresh_completed", "thresh_progress" ] as $key) {
            $val = intval(get_config('local_treestudyplan', "bistate_{$key}"));
            if ($val >= 0 && $val <= 100) {
                $this->cfg()->$key = floatval($val) / 100;
            }
        }
        foreach (["use_failed", "accept_pending_as_submitted"] as $key) {
            $this->cfg()->$key = boolval(get_config('local_treestudyplan', "bistate_{$key}"));
        }

        // Next, decode json.
        $config = \json_decode($configstr, true);

        if (is_array($config)) {
            // Copy all valid config settings to this item.
            foreach (["thresh_excellent", "thresh_good", "thresh_completed", "thresh_progress" ] as $key) {
                if (array_key_exists($key, $config)) {
                    $val = $config[$key];
                    if ($val >= 0 && $val <= 100) {
                        $this->cfg()->$key = floatval($val) / 100;
                    }
                }
            }
            foreach (["use_failed", "accept_pending_as_submitted"] as $key) {
                if (array_key_exists($key, $config)) {
                    $this->cfg()->$key = boolval($config[$key]);
                }
            }
        }
    }

    /**
     * Return the current configuration string.
     * @return string Configuration string
     */
    public function config_string() {
        return json_encode([
            "thresh_excellent" => 100 * $this->cfg()->thresh_excellent,
            "thresh_good" => 100 * $this->cfg()->thresh_good,
            "thresh_completed" => 100 * $this->cfg()->thresh_completed,
            "thresh_progress" => 100 * $this->cfg()->thresh_progress,
            "use_failed" => $this->cfg()->use_failed,
            "accept_pending_as_submitted" => $this->cfg()->accept_pending_as_submitted,
        ]);
    }

    /**
     * Determine if aggregation method wants to select gradables
     * @return bool True if aggregation method needs gradables to be selected
     */
    public function select_gradables() {
        return true;
    }
    /**
     * Determine if aggregation method is deprecated
     * @return bool True if aggregation method is deprecated
     */
    public function deprecated() {
        return self::DEPRECATED;
    }

    /**
     * Determine if the aggregation method uses manual activity selection,
     * @return bool True if the aggregation method uses manual activity selection
     */
    public function use_manualactivityselection() {
        return true;
    }

    /**
     * Determine if Aggregation method makes use of "required grades" in a course/module.
     * @return bool True if Aggregation method makes use of "required grades" in a course/module.
     */
    public function use_required_grades() {
        return true;
    }

    /**
     * Aggregate completed/failed goals into one outcome
     * @param int[] $completions List of completions (completion class constants)
     * @param array $required List of completions indexes that are marked as required
     * @return int Aggregated completion as completion class constant
     */
    public function aggregate_binary_goals(array $completions, array $required = []) {
        // Function is public to allow access for the testing code.

        // Return te following conditions.
        // Possible states:.
        // - completion::EXCELLENT  - At least $threshexcellent fraction of goals are complete and all required goals are met.
        // - completion::GOOD       - At least $threshgood fraction of goals are complete and all required goals are met.
        // - completion::COMPLETED  - At least $threshcomplete fraction of goals are completed and all required goals are met.
        // - completion::FAILED     - At least $threshprogress fraction of goals is not failed.
        // - completion::INCOMPLETE - No goals have been started.
        // - completion::PROGRESS   - All other states.

        $total = count($completions);
        $completed = 0;
        $progress = 0;
        $failed = 0;
        $started = 0;

        $totalrequired = 0;
        $requiredmet = 0;

        $minprogress = ($this->cfg()->accept_pending_as_submitted) ? completion::PENDING : completion::PROGRESS;

        foreach ($completions as $index => $c) {
            $completed += ($c >= completion::COMPLETED) ? 1 : 0;
            $progress  += ($c >= $minprogress) ? 1 : 0;
            $failed    += ($c <= completion::FAILED) ? 1 : 0;

            if (in_array($index, $required)) {
                 // Not using count($required) to prevent nonexistant indices in the required list from messing things up.
                $totalrequired += 1;
                if ($c >= completion::COMPLETED) {
                    $requiredmet += 1;
                }
            }
        }
        $started = $progress + $failed;
        $allrequiredmet = ($requiredmet >= $totalrequired);

        $fractioncompleted = ($total > 0) ? (floatval($completed) / floatval($total)) : 0.0;

        if ($total == 0) {
            return completion::INCOMPLETE;
        }
        if ($fractioncompleted >= $this->cfg()->thresh_excellent && $allrequiredmet) {
            return completion::EXCELLENT;
        } else if ($fractioncompleted >= $this->cfg()->thresh_good && $allrequiredmet) {
            return completion::GOOD;
        } else if ($fractioncompleted >= $this->cfg()->thresh_completed && $allrequiredmet) {
            return completion::COMPLETED;
        } else if ($started == 0) {
            return completion::INCOMPLETE;
        } else {
            return completion::PROGRESS;
        }
    }

    /**
     * Aggregate all completions in a course into one final course completion
     * Possible states:
     *   completion::EXCELLENT  - Completed with excellent results
     *   completion::GOOD       - Completed with good results
     *   completion::COMPLETED  - Completed
     *   completion::PROGRESS   - Started, but not completed yey
     *   completion::FAILED     - Failed
     *   completion::INCOMPLETE - Not yet started
     * @param courseinfo $courseinfo Courseinfo object for the course to check
     * @param studyitem $studyitem Studyitem object for the course to check
     * @param int $userid Id of user to check this course for
     * @return int Aggregated completion as completion class constant
     */
    public function aggregate_course(courseinfo $courseinfo, studyitem $studyitem, $userid) {
        $course = $courseinfo->course();
        $coursefinished = ($course->enddate) ? ($course->enddate < time()) : false;
        // Note: studyitem condition config is not used in this aggregator.
        // Loop through all associated gradables and count the totals, completed, etc..
        $completions = [];
        $required = [];
        foreach (gradeinfo::list_studyitem_gradables($studyitem) as $gi) {
            $completions[] = $this->grade_completion($gi, $userid);
            if ($gi->is_required()) {
                // If it's a required grade .
                // Also add it's index in the completion list to the list of required grades .
                $required[] = count($completions) - 1;
            }
        }

        // Combine the aquired completions into one.
        $result = self::aggregate_binary_goals($completions, $required);
        if ($this->cfg()->use_failed && $result == completion::PROGRESS && $coursefinished) {
            return completion::FAILED;
        } else {
            return $result;
        }
    }

    /**
     * Aggregate juncton/filter inputs into one final junction outcome
     * @param int[] $completion List of completion inputs
     * @param studyitem $studyitem Studyitem object for the junction
     * @param int $userid Id of user to check completion for
     * @return int Aggregated completion as completion class constant
     */
    public function aggregate_junction(array $completion, studyitem $studyitem, $userid = 0) {
        // Aggregate multiple incoming states into one junction or finish.
        // Possible states:.
        // - completion::EXCELLENT  - All incoming states are excellent.
        // - completion::GOOD       - All incoming states are at least good.
        // - completion::COMPLETED  - All incoming states are at least completed.
        // - completion::FAILED     - All incoming states are failed.
        // - completion::INCOMPLETE - All incoming states are incomplete.
        // - completion::PROGRESS   - All other states.

        $method = strtoupper($studyitem->conditions()); // One of ANY or ALL.

        // First count all states.
        $statecount = completion::count_states($completion);
        $total = count($completion);

        if ($method == "ANY") {
            if ($statecount[completion::EXCELLENT] >= 1) {
                return completion::EXCELLENT;
            } else if ($statecount[completion::GOOD] >= 1) {
                return completion::GOOD;
            } else if ($statecount[completion::COMPLETED] >= 1) {
                return completion::COMPLETED;
            } else if ($statecount[completion::PROGRESS] >= 1) {
                return completion::PROGRESS;
            } else if ($statecount[completion::FAILED] >= 1) {
                return completion::FAILED;
            } else {
                return completion::INCOMPLETE;
            }
        } else { /* default value of ALL */
            if ($total == $statecount[completion::EXCELLENT]) {
                return completion::EXCELLENT;
            } else if (
                $total == (
                     $statecount[completion::EXCELLENT]
                    + $statecount[completion::GOOD]
                )
            ) {
                return completion::GOOD;
            } else if (
                $total == (
                    $statecount[completion::EXCELLENT]
                    + $statecount[completion::GOOD]
                    + $statecount[completion::COMPLETED]
                )
            ) {
                return completion::COMPLETED;
            } else if ($statecount[completion::FAILED]) {
                return completion::FAILED;
            } else if ($total == $statecount[completion::INCOMPLETE]) {
                return completion::INCOMPLETE;
            } else {
                return completion::PROGRESS;
            }
        }
    }

    /**
     * Determine completion for a single grade and user
     * @param gradeinfo $gradeinfo Gradeinfo object for grade to check
     * @param mixed $userid Id of user to check completion for
     * @return int Aggregated completion as completion class constant
     */
    public function grade_completion(gradeinfo $gradeinfo, $userid) {
        global $DB;
        $table = "local_treestudyplan_gradecfg";
        $gradeitem = $gradeinfo->get_gradeitem();
        $grade = preloader::find_grade_item_final($gradeitem, $userid);
        $course = preloader::get_course($gradeitem->courseid); // Fetch course from cache.
        $coursefinished = ($course->enddate) ? ($course->enddate < time()) : false;

        if (!is_object($grade) || empty($grade)) {
            return completion::INCOMPLETE;
        } else if ($grade->finalgrade === null) {
            // On assignments, grade NULL means a submission has not yet been graded, .
            // But on quizes this can also mean a quiz might have been started.
            // Therefor, we treat a NULL result as a reason to check the relevant gradingscanner for presence of pending items.

            // Since we want old results to be visible until a pending item was graded, we only use this state here.
            // Pending items are otherwise expressly indicated by the "pendingsubmission" field in the user model.
            if ($gradeinfo->get_gradingscanner()->pending($userid)) {
                return completion::PENDING;
            } else {
                return completion::INCOMPLETE;
            }
        } else {
            // First determine if we have a grade_config for this scale or this maximum grade.
            $finalgrade = $grade->finalgrade;
            $scale = $gradeinfo->get_scale();
            if (isset($scale)) {
                $gradecfg = $DB->get_record($table, ["scale_id" => $scale->id]);
            } else if ($gradeitem->grademin == 0) {
                $gradecfg = $DB->get_record($table, ["grade_points" => $gradeitem->grademax]);
            } else {
                $gradecfg = null;
            }

            // For point grades, a provided grade pass overrides the defaults in the gradeconfig.
            // For scales, the configuration in the gradeconfig is leading.

            if ($gradecfg && (isset($scale) || $gradeitem->gradepass == 0)) {
                // If so, we need to know if the grade is .
                if ($finalgrade >= $gradecfg->min_completed) {
                    // Return completed if completed.
                    return completion::COMPLETED;
                } else if ($this->cfg()->use_failed && $coursefinished) {
                    // Return failed if failed is enabled and the grade is less than the minimum grade for progress.
                    return completion::FAILED;
                } else {
                    return completion::PROGRESS;
                }
            } else if ($gradeitem->gradepass > 0) {
                $range = floatval($gradeitem->grademax - $gradeitem->grademin);
                // If no gradeconfig and gradepass is set, use that one to determine config.
                if ($finalgrade >= $gradeitem->gradepass) {
                    return completion::COMPLETED;
                } else if ($this->cfg()->use_failed && $coursefinished) {
                    // Return failed if failed is enabled and the grade is 1, while there are at leas 3 states.
                    return completion::FAILED;
                } else {
                    return completion::PROGRESS;
                }
            } else {
                // Blind assumptions if nothing is provided.
                // Over 55% of range is completed.
                // If range >= 3 and failed is enabled, assume that this means failed.
                $g = floatval($finalgrade - $gradeitem->grademin);
                $range = floatval($gradeitem->grademax - $gradeitem->grademin);
                $score = $g / $range;

                if ($score > 0.55) {
                    return completion::COMPLETED;
                } else if ($this->use_failed && $coursefinished) {
                    // Return failed if failed is enabled and the grade is 1, while there are at leas 3 states.
                    return completion::FAILED;
                } else {
                    return completion::PROGRESS;
                }
            }
        }
    }
}
