<?php

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Library of functions for gradebook - both public and internal
 *
 * @package    core
 * @subpackage grade
 * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

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

/** Include essential files */
require_once($CFG->libdir . '/grade/constants.php');

require_once($CFG->libdir . '/grade/grade_category.php');
require_once($CFG->libdir . '/grade/grade_item.php');
require_once($CFG->libdir . '/grade/grade_grade.php');
require_once($CFG->libdir . '/grade/grade_scale.php');
require_once($CFG->libdir . '/grade/grade_outcome.php');

/////////////////////////////////////////////////////////////////////
///// Start of public API for communication with modules/blocks /////
/////////////////////////////////////////////////////////////////////

/**
 * Submit new or update grade; update/create grade_item definition. Grade must have userid specified,
 * rawgrade and feedback with format are optional. rawgrade NULL means 'Not graded', missing property
 * or key means do not change existing.
 *
 * Only following grade item properties can be changed 'itemname', 'idnumber', 'gradetype', 'grademax',
 * 'grademin', 'scaleid', 'multfactor', 'plusfactor', 'deleted' and 'hidden'. 'reset' means delete all current grades including locked ones.
 *
 * Manual, course or category items can not be updated by this function.
 * @access public
 * @global object
 * @global object
 * @global object
 * @param string $source source of the grade such as 'mod/assignment'
 * @param int $courseid id of course
 * @param string $itemtype type of grade item - mod, block
 * @param string $itemmodule more specific then $itemtype - assignment, forum, etc.; maybe NULL for some item types
 * @param int $iteminstance instance it of graded subject
 * @param int $itemnumber most probably 0, modules can use other numbers when having more than one grades for each user
 * @param mixed $grades grade (object, array) or several grades (arrays of arrays or objects), NULL if updating grade_item definition only
 * @param mixed $itemdetails object or array describing the grading item, NULL if no change
 */
function grade_update($source, $courseid, $itemtype, $itemmodule, $iteminstance, $itemnumber, $grades=NULL, $itemdetails=NULL) {
    global $USER, $CFG, $DB;

    // only following grade_item properties can be changed in this function
    $allowed = array('itemname', 'idnumber', 'gradetype', 'grademax', 'grademin', 'scaleid', 'multfactor', 'plusfactor', 'deleted', 'hidden');
    // list of 10,5 numeric fields
    $floats  = array('grademin', 'grademax', 'multfactor', 'plusfactor');

    // grade item identification
    $params = compact('courseid', 'itemtype', 'itemmodule', 'iteminstance', 'itemnumber');

    if (is_null($courseid) or is_null($itemtype)) {
        debugging('Missing courseid or itemtype');
        return GRADE_UPDATE_FAILED;
    }

    if (!$grade_items = grade_item::fetch_all($params)) {
        // create a new one
        $grade_item = false;

    } else if (count($grade_items) == 1){
        $grade_item = reset($grade_items);
        unset($grade_items); //release memory

    } else {
        debugging('Found more than one grade item');
        return GRADE_UPDATE_MULTIPLE;
    }

    if (!empty($itemdetails['deleted'])) {
        if ($grade_item) {
            if ($grade_item->delete($source)) {
                return GRADE_UPDATE_OK;
            } else {
                return GRADE_UPDATE_FAILED;
            }
        }
        return GRADE_UPDATE_OK;
    }

/// Create or update the grade_item if needed

    if (!$grade_item) {
        if ($itemdetails) {
            $itemdetails = (array)$itemdetails;

            // grademin and grademax ignored when scale specified
            if (array_key_exists('scaleid', $itemdetails)) {
                if ($itemdetails['scaleid']) {
                    unset($itemdetails['grademin']);
                    unset($itemdetails['grademax']);
                }
            }

            foreach ($itemdetails as $k=>$v) {
                if (!in_array($k, $allowed)) {
                    // ignore it
                    continue;
                }
                if ($k == 'gradetype' and $v == GRADE_TYPE_NONE) {
                    // no grade item needed!
                    return GRADE_UPDATE_OK;
                }
                $params[$k] = $v;
            }
        }
        $grade_item = new grade_item($params);
        $grade_item->insert();

    } else {
        if ($grade_item->is_locked()) {
            // no notice() here, test returned value instead!
            return GRADE_UPDATE_ITEM_LOCKED;
        }

        if ($itemdetails) {
            $itemdetails = (array)$itemdetails;
            $update = false;
            foreach ($itemdetails as $k=>$v) {
                if (!in_array($k, $allowed)) {
                    // ignore it
                    continue;
                }
                if (in_array($k, $floats)) {
                    if (grade_floats_different($grade_item->{$k}, $v)) {
                        $grade_item->{$k} = $v;
                        $update = true;
                    }

                } else {
                    if ($grade_item->{$k} != $v) {
                        $grade_item->{$k} = $v;
                        $update = true;
                    }
                }
            }
            if ($update) {
                $grade_item->update();
            }
        }
    }

/// reset grades if requested
    if (!empty($itemdetails['reset'])) {
        $grade_item->delete_all_grades('reset');
        return GRADE_UPDATE_OK;
    }

/// Some extra checks
    // do we use grading?
    if ($grade_item->gradetype == GRADE_TYPE_NONE) {
        return GRADE_UPDATE_OK;
    }

    // no grade submitted
    if (empty($grades)) {
        return GRADE_UPDATE_OK;
    }

/// Finally start processing of grades
    if (is_object($grades)) {
        $grades = array($grades->userid=>$grades);
    } else {
        if (array_key_exists('userid', $grades)) {
            $grades = array($grades['userid']=>$grades);
        }
    }

/// normalize and verify grade array
    foreach($grades as $k=>$g) {
        if (!is_array($g)) {
            $g = (array)$g;
            $grades[$k] = $g;
        }

        if (empty($g['userid']) or $k != $g['userid']) {
            debugging('Incorrect grade array index, must be user id! Grade ignored.');
            unset($grades[$k]);
        }
    }

    if (empty($grades)) {
        return GRADE_UPDATE_FAILED;
    }

    $count = count($grades);
    if ($count > 0 and $count < 200) {
        list($uids, $params) = $DB->get_in_or_equal(array_keys($grades), SQL_PARAMS_NAMED, $start='uid');
        $params['gid'] = $grade_item->id;
        $sql = "SELECT * FROM {grade_grades} WHERE itemid = :gid AND userid $uids";

    } else {
        $sql = "SELECT * FROM {grade_grades} WHERE itemid = :gid";
        $params = array('gid'=>$grade_item->id);
    }

    $rs = $DB->get_recordset_sql($sql, $params);

    $failed = false;

    while (count($grades) > 0) {
        $grade_grade = null;
        $grade       = null;

        foreach ($rs as $gd) {

            $userid = $gd->userid;
            if (!isset($grades[$userid])) {
                // this grade not requested, continue
                continue;
            }
            // existing grade requested
            $grade       = $grades[$userid];
            $grade_grade = new grade_grade($gd, false);
            unset($grades[$userid]);
            break;
        }

        if (is_null($grade_grade)) {
            if (count($grades) == 0) {
                // no more grades to process
                break;
            }

            $grade       = reset($grades);
            $userid      = $grade['userid'];
            $grade_grade = new grade_grade(array('itemid'=>$grade_item->id, 'userid'=>$userid), false);
            $grade_grade->load_optional_fields(); // add feedback and info too
            unset($grades[$userid]);
        }

        $rawgrade       = false;
        $feedback       = false;
        $feedbackformat = FORMAT_MOODLE;
        $usermodified   = $USER->id;
        $datesubmitted  = null;
        $dategraded     = null;

        if (array_key_exists('rawgrade', $grade)) {
            $rawgrade = $grade['rawgrade'];
        }

        if (array_key_exists('feedback', $grade)) {
            $feedback = $grade['feedback'];
        }

        if (array_key_exists('feedbackformat', $grade)) {
            $feedbackformat = $grade['feedbackformat'];
        }

        if (array_key_exists('usermodified', $grade)) {
            $usermodified = $grade['usermodified'];
        }

        if (array_key_exists('datesubmitted', $grade)) {
            $datesubmitted = $grade['datesubmitted'];
        }

        if (array_key_exists('dategraded', $grade)) {
            $dategraded = $grade['dategraded'];
        }

        // update or insert the grade
        if (!$grade_item->update_raw_grade($userid, $rawgrade, $source, $feedback, $feedbackformat, $usermodified, $dategraded, $datesubmitted, $grade_grade)) {
            $failed = true;
        }
    }

    if ($rs) {
        $rs->close();
    }

    if (!$failed) {
        return GRADE_UPDATE_OK;
    } else {
        return GRADE_UPDATE_FAILED;
    }
}

/**
 * Updates outcomes of user
 * Manual outcomes can not be updated.
 *
 * @access public
 * @param string $source source of the grade such as 'mod/assignment'
 * @param int $courseid id of course
 * @param string $itemtype 'mod', 'block'
 * @param string $itemmodule 'forum, 'quiz', etc.
 * @param int $iteminstance id of the item module
 * @param int $userid ID of the graded user
 * @param array $data array itemnumber=>outcomegrade
 * @return boolean returns true if grade items were found and updated successfully
 */
function grade_update_outcomes($source, $courseid, $itemtype, $itemmodule, $iteminstance, $userid, $data) {
    if ($items = grade_item::fetch_all(array('itemtype'=>$itemtype, 'itemmodule'=>$itemmodule, 'iteminstance'=>$iteminstance, 'courseid'=>$courseid))) {
        $result = true;
        foreach ($items as $item) {
            if (!array_key_exists($item->itemnumber, $data)) {
                continue;
            }
            $grade = $data[$item->itemnumber] < 1 ? null : $data[$item->itemnumber];
            $result = ($item->update_final_grade($userid, $grade, $source) && $result);
        }
        return $result;
    }
    return false; //grade items not found
}

/**
 * Returns grading information for given activity - optionally with users grades
 * Manual, course or category items can not be queried.
 *
 * @access public
 * @global object
 * @param int $courseid id of course
 * @param string $itemtype 'mod', 'block'
 * @param string $itemmodule 'forum, 'quiz', etc.
 * @param int $iteminstance id of the item module
 * @param array|int $userid_or_ids optional id of the graded user or array of ids; if userid not used, returns only information about grade_item
 * @return array Array of grade information objects (scaleid, name, grade and locked status, etc.) indexed with itemnumbers
 */
function grade_get_grades($courseid, $itemtype, $itemmodule, $iteminstance, $userid_or_ids=null) {
    global $CFG;

    $return = new stdClass();
    $return->items    = array();
    $return->outcomes = array();

    $course_item = grade_item::fetch_course_item($courseid);
    $needsupdate = array();
    if ($course_item->needsupdate) {
        $result = grade_regrade_final_grades($courseid);
        if ($result !== true) {
            $needsupdate = array_keys($result);
        }
    }

    if ($grade_items = grade_item::fetch_all(array('itemtype'=>$itemtype, 'itemmodule'=>$itemmodule, 'iteminstance'=>$iteminstance, 'courseid'=>$courseid))) {
        foreach ($grade_items as $grade_item) {
            $decimalpoints = null;

            if (empty($grade_item->outcomeid)) {
                // prepare information about grade item
                $item = new stdClass();
                $item->itemnumber = $grade_item->itemnumber;
                $item->scaleid    = $grade_item->scaleid;
                $item->name       = $grade_item->get_name();
                $item->grademin   = $grade_item->grademin;
                $item->grademax   = $grade_item->grademax;
                $item->gradepass  = $grade_item->gradepass;
                $item->locked     = $grade_item->is_locked();
                $item->hidden     = $grade_item->is_hidden();
                $item->grades     = array();

                switch ($grade_item->gradetype) {
                    case GRADE_TYPE_NONE:
                        continue;

                    case GRADE_TYPE_VALUE:
                        $item->scaleid = 0;
                        break;

                    case GRADE_TYPE_TEXT:
                        $item->scaleid   = 0;
                        $item->grademin   = 0;
                        $item->grademax   = 0;
                        $item->gradepass  = 0;
                        break;
                }

                if (empty($userid_or_ids)) {
                    $userids = array();

                } else if (is_array($userid_or_ids)) {
                    $userids = $userid_or_ids;

                } else {
                    $userids = array($userid_or_ids);
                }

                if ($userids) {
                    $grade_grades = grade_grade::fetch_users_grades($grade_item, $userids, true);
                    foreach ($userids as $userid) {
                        $grade_grades[$userid]->grade_item =& $grade_item;

                        $grade = new stdClass();
                        $grade->grade          = $grade_grades[$userid]->finalgrade;
                        $grade->locked         = $grade_grades[$userid]->is_locked();
                        $grade->hidden         = $grade_grades[$userid]->is_hidden();
                        $grade->overridden     = $grade_grades[$userid]->overridden;
                        $grade->feedback       = $grade_grades[$userid]->feedback;
                        $grade->feedbackformat = $grade_grades[$userid]->feedbackformat;
                        $grade->usermodified   = $grade_grades[$userid]->usermodified;
                        $grade->datesubmitted  = $grade_grades[$userid]->get_datesubmitted();
                        $grade->dategraded     = $grade_grades[$userid]->get_dategraded();

                        // create text representation of grade
                        if ($grade_item->gradetype == GRADE_TYPE_TEXT or $grade_item->gradetype == GRADE_TYPE_NONE) {
                            $grade->grade          = null;
                            $grade->str_grade      = '-';
                            $grade->str_long_grade = $grade->str_grade;

                        } else if (in_array($grade_item->id, $needsupdate)) {
                            $grade->grade          = false;
                            $grade->str_grade      = get_string('error');
                            $grade->str_long_grade = $grade->str_grade;

                        } else if (is_null($grade->grade)) {
                            $grade->str_grade      = '-';
                            $grade->str_long_grade = $grade->str_grade;

                        } else {
                            $grade->str_grade = grade_format_gradevalue($grade->grade, $grade_item);
                            if ($grade_item->gradetype == GRADE_TYPE_SCALE or $grade_item->get_displaytype() != GRADE_DISPLAY_TYPE_REAL) {
                                $grade->str_long_grade = $grade->str_grade;
                            } else {
                                $a = new stdClass();
                                $a->grade = $grade->str_grade;
                                $a->max   = grade_format_gradevalue($grade_item->grademax, $grade_item);
                                $grade->str_long_grade = get_string('gradelong', 'grades', $a);
                            }
                        }

                        // create html representation of feedback
                        if (is_null($grade->feedback)) {
                            $grade->str_feedback = '';
                        } else {
                            $grade->str_feedback = format_text($grade->feedback, $grade->feedbackformat);
                        }

                        $item->grades[$userid] = $grade;
                    }
                }
                $return->items[$grade_item->itemnumber] = $item;

            } else {
                if (!$grade_outcome = grade_outcome::fetch(array('id'=>$grade_item->outcomeid))) {
                    debugging('Incorect outcomeid found');
                    continue;
                }

                // outcome info
                $outcome = new stdClass();
                $outcome->itemnumber = $grade_item->itemnumber;
                $outcome->scaleid    = $grade_outcome->scaleid;
                $outcome->name       = $grade_outcome->get_name();
                $outcome->locked     = $grade_item->is_locked();
                $outcome->hidden     = $grade_item->is_hidden();

                if (empty($userid_or_ids)) {
                    $userids = array();
                } else if (is_array($userid_or_ids)) {
                    $userids = $userid_or_ids;
                } else {
                    $userids = array($userid_or_ids);
                }

                if ($userids) {
                    $grade_grades = grade_grade::fetch_users_grades($grade_item, $userids, true);
                    foreach ($userids as $userid) {
                        $grade_grades[$userid]->grade_item =& $grade_item;

                        $grade = new stdClass();
                        $grade->grade          = $grade_grades[$userid]->finalgrade;
                        $grade->locked         = $grade_grades[$userid]->is_locked();
                        $grade->hidden         = $grade_grades[$userid]->is_hidden();
                        $grade->feedback       = $grade_grades[$userid]->feedback;
                        $grade->feedbackformat = $grade_grades[$userid]->feedbackformat;
                        $grade->usermodified   = $grade_grades[$userid]->usermodified;

                        // create text representation of grade
                        if (in_array($grade_item->id, $needsupdate)) {
                            $grade->grade     = false;
                            $grade->str_grade = get_string('error');

                        } else if (is_null($grade->grade)) {
                            $grade->grade = 0;
                            $grade->str_grade = get_string('nooutcome', 'grades');

                        } else {
                            $grade->grade = (int)$grade->grade;
                            $scale = $grade_item->load_scale();
                            $grade->str_grade = format_string($scale->scale_items[(int)$grade->grade-1]);
                        }

                        // create html representation of feedback
                        if (is_null($grade->feedback)) {
                            $grade->str_feedback = '';
                        } else {
                            $grade->str_feedback = format_text($grade->feedback, $grade->feedbackformat);
                        }

                        $outcome->grades[$userid] = $grade;
                    }
                }

                if (isset($return->outcomes[$grade_item->itemnumber])) {
                    // itemnumber duplicates - lets fix them!
                    $newnumber = $grade_item->itemnumber + 1;
                    while(grade_item::fetch(array('itemtype'=>$itemtype, 'itemmodule'=>$itemmodule, 'iteminstance'=>$iteminstance, 'courseid'=>$courseid, 'itemnumber'=>$newnumber))) {
                        $newnumber++;
                    }
                    $outcome->itemnumber    = $newnumber;
                    $grade_item->itemnumber = $newnumber;
                    $grade_item->update('system');
                }

                $return->outcomes[$grade_item->itemnumber] = $outcome;

            }
        }
    }

    // sort results using itemnumbers
    ksort($return->items, SORT_NUMERIC);
    ksort($return->outcomes, SORT_NUMERIC);

    return $return;
}

///////////////////////////////////////////////////////////////////
///// End of public API for communication with modules/blocks /////
///////////////////////////////////////////////////////////////////



///////////////////////////////////////////////////////////////////
///// Internal API: used by gradebook plugins and Moodle core /////
///////////////////////////////////////////////////////////////////

/**
 * Returns course gradebook setting
 *
 * @global object
 * @param int $courseid
 * @param string $name of setting, maybe null if reset only
 * @param string $default
 * @param bool $resetcache force reset of internal static cache
 * @return string value, NULL if no setting
 */
function grade_get_setting($courseid, $name, $default=null, $resetcache=false) {
    global $DB;

    static $cache = array();

    if ($resetcache or !array_key_exists($courseid, $cache)) {
        $cache[$courseid] = array();

    } else if (is_null($name)) {
        return null;

    } else if (array_key_exists($name, $cache[$courseid])) {
        return $cache[$courseid][$name];
    }

    if (!$data = $DB->get_record('grade_settings', array('courseid'=>$courseid, 'name'=>$name))) {
        $result = null;
    } else {
        $result = $data->value;
    }

    if (is_null($result)) {
        $result = $default;
    }

    $cache[$courseid][$name] = $result;
    return $result;
}

/**
 * Returns all course gradebook settings as object properties
 *
 * @global object
 * @param int $courseid
 * @return object
 */
function grade_get_settings($courseid) {
    global $DB;

     $settings = new stdClass();
     $settings->id = $courseid;

    if ($records = $DB->get_records('grade_settings', array('courseid'=>$courseid))) {
        foreach ($records as $record) {
            $settings->{$record->name} = $record->value;
        }
    }

    return $settings;
}

/**
 * Add/update course gradebook setting
 *
 * @global object
 * @param int $courseid
 * @param string $name of setting
 * @param string value, NULL means no setting==remove
 * @return void
 */
function grade_set_setting($courseid, $name, $value) {
    global $DB;

    if (is_null($value)) {
        $DB->delete_records('grade_settings', array('courseid'=>$courseid, 'name'=>$name));

    } else if (!$existing = $DB->get_record('grade_settings', array('courseid'=>$courseid, 'name'=>$name))) {
        $data = new stdClass();
        $data->courseid = $courseid;
        $data->name     = $name;
        $data->value    = $value;
        $DB->insert_record('grade_settings', $data);

    } else {
        $data = new stdClass();
        $data->id       = $existing->id;
        $data->value    = $value;
        $DB->update_record('grade_settings', $data);
    }

    grade_get_setting($courseid, null, null, true); // reset the cache
}

/**
 * Returns string representation of grade value
 *
 * @param float $value grade value
 * @param object $grade_item - by reference to prevent scale reloading
 * @param bool $localized use localised decimal separator
 * @param int $displaytype type of display - GRADE_DISPLAY_TYPE_REAL, GRADE_DISPLAY_TYPE_PERCENTAGE, GRADE_DISPLAY_TYPE_LETTER
 * @param int $decimals number of decimal places when displaying float values
 * @return string
 */
function grade_format_gradevalue($value, &$grade_item, $localized=true, $displaytype=null, $decimals=null) {
    if ($grade_item->gradetype == GRADE_TYPE_NONE or $grade_item->gradetype == GRADE_TYPE_TEXT) {
        return '';
    }

    // no grade yet?
    if (is_null($value)) {
        return '-';
    }

    if ($grade_item->gradetype != GRADE_TYPE_VALUE and $grade_item->gradetype != GRADE_TYPE_SCALE) {
        //unknown type??
        return '';
    }

    if (is_null($displaytype)) {
        $displaytype = $grade_item->get_displaytype();
    }

    if (is_null($decimals)) {
        $decimals = $grade_item->get_decimals();
    }

    switch ($displaytype) {
        case GRADE_DISPLAY_TYPE_REAL:
            return grade_format_gradevalue_real($value, $grade_item, $decimals, $localized);

        case GRADE_DISPLAY_TYPE_PERCENTAGE:
            return grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized);

        case GRADE_DISPLAY_TYPE_LETTER:
            return grade_format_gradevalue_letter($value, $grade_item);

        case GRADE_DISPLAY_TYPE_REAL_PERCENTAGE:
            return grade_format_gradevalue_real($value, $grade_item, $decimals, $localized) . ' (' .
                    grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized) . ')';

        case GRADE_DISPLAY_TYPE_REAL_LETTER:
            return grade_format_gradevalue_real($value, $grade_item, $decimals, $localized) . ' (' .
                    grade_format_gradevalue_letter($value, $grade_item) . ')';

        case GRADE_DISPLAY_TYPE_PERCENTAGE_REAL:
            return grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized) . ' (' .
                    grade_format_gradevalue_real($value, $grade_item, $decimals, $localized) . ')';

        case GRADE_DISPLAY_TYPE_LETTER_REAL:
            return grade_format_gradevalue_letter($value, $grade_item) . ' (' .
                    grade_format_gradevalue_real($value, $grade_item, $decimals, $localized) . ')';

        case GRADE_DISPLAY_TYPE_LETTER_PERCENTAGE:
            return grade_format_gradevalue_letter($value, $grade_item) . ' (' .
                    grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized) . ')';

        case GRADE_DISPLAY_TYPE_PERCENTAGE_LETTER:
            return grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized) . ' (' .
                    grade_format_gradevalue_letter($value, $grade_item) . ')';
        default:
            return '';
    }
}

/**
 * @todo Document this function
 */
function grade_format_gradevalue_real($value, $grade_item, $decimals, $localized) {
    if ($grade_item->gradetype == GRADE_TYPE_SCALE) {
        if (!$scale = $grade_item->load_scale()) {
            return get_string('error');
        }

        $value = $grade_item->bounded_grade($value);
        return format_string($scale->scale_items[$value-1]);

    } else {
        return format_float($value, $decimals, $localized);
    }
}
/**
 * @todo Document this function
 */
function grade_format_gradevalue_percentage($value, $grade_item, $decimals, $localized) {
    $min = $grade_item->grademin;
    $max = $grade_item->grademax;
    if ($min == $max) {
        return '';
    }
    $value = $grade_item->bounded_grade($value);
    $percentage = (($value-$min)*100)/($max-$min);
    return format_float($percentage, $decimals, $localized).' %';
}
/**
 * @todo Document this function
 */
function grade_format_gradevalue_letter($value, $grade_item) {
    $context = get_context_instance(CONTEXT_COURSE, $grade_item->courseid);
    if (!$letters = grade_get_letters($context)) {
        return ''; // no letters??
    }

    if (is_null($value)) {
        return '-';
    }

    $value = grade_grade::standardise_score($value, $grade_item->grademin, $grade_item->grademax, 0, 100);
    $value = bounded_number(0, $value, 100); // just in case
    foreach ($letters as $boundary => $letter) {
        if ($value >= $boundary) {
            return format_string($letter);
        }
    }
    return '-'; // no match? maybe '' would be more correct
}


/**
 * Returns grade options for gradebook category menu
 *
 * @param int $courseid
 * @param bool $includenew include option for new category (-1)
 * @return array of grade categories in course
 */
function grade_get_categories_menu($courseid, $includenew=false) {
    $result = array();
    if (!$categories = grade_category::fetch_all(array('courseid'=>$courseid))) {
        //make sure course category exists
        if (!grade_category::fetch_course_category($courseid)) {
            debugging('Can not create course grade category!');
            return $result;
        }
        $categories = grade_category::fetch_all(array('courseid'=>$courseid));
    }
    foreach ($categories as $key=>$category) {
        if ($category->is_course_category()) {
            $result[$category->id] = get_string('uncategorised', 'grades');
            unset($categories[$key]);
        }
    }
    if ($includenew) {
        $result[-1] = get_string('newcategory', 'grades');
    }
    $cats = array();
    foreach ($categories as $category) {
        $cats[$category->id] = $category->get_name();
    }
    collatorlib::asort($cats);

    return ($result+$cats);
}

/**
 * Returns grade letters array used in context
 *
 * @param object $context object or null for defaults
 * @return array of grade_boundary=>letter_string
 */
function grade_get_letters($context=null) {
    global $DB;

    if (empty($context)) {
        //default grading letters
        return array('93'=>'A', '90'=>'A-', '87'=>'B+', '83'=>'B', '80'=>'B-', '77'=>'C+', '73'=>'C', '70'=>'C-', '67'=>'D+', '60'=>'D', '0'=>'F');
    }

    static $cache = array();

    if (array_key_exists($context->id, $cache)) {
        return $cache[$context->id];
    }

    if (count($cache) > 100) {
        $cache = array(); // cache size limit
    }

    $letters = array();

    $contexts = get_parent_contexts($context);
    array_unshift($contexts, $context->id);

    foreach ($contexts as $ctxid) {
        if ($records = $DB->get_records('grade_letters', array('contextid'=>$ctxid), 'lowerboundary DESC')) {
            foreach ($records as $record) {
                $letters[$record->lowerboundary] = $record->letter;
            }
        }

        if (!empty($letters)) {
            $cache[$context->id] = $letters;
            return $letters;
        }
    }

    $letters = grade_get_letters(null);
    $cache[$context->id] = $letters;
    return $letters;
}


/**
 * Verify new value of idnumber - checks for uniqueness of new idnumbers, old are kept intact
 *
 * @global object
 * @param string idnumber string (with magic quotes)
 * @param int $courseid id numbers are course unique only
 * @param object $grade_item is item idnumber
 * @param object $cm used for course module idnumbers and items attached to modules
 * @return boolean true means idnumber ok
 */
function grade_verify_idnumber($idnumber, $courseid, $grade_item=null, $cm=null) {
    global $DB;

    if ($idnumber == '') {
        //we allow empty idnumbers
        return true;
    }

    // keep existing even when not unique
    if ($cm and $cm->idnumber == $idnumber) {
        if ($grade_item and $grade_item->itemnumber != 0) {
            // grade item with itemnumber > 0 can't have the same idnumber as the main
            // itemnumber 0 which is synced with course_modules
            return false;
        }
        return true;
    } else if ($grade_item and $grade_item->idnumber == $idnumber) {
        return true;
    }

    if ($DB->record_exists('course_modules', array('course'=>$courseid, 'idnumber'=>$idnumber))) {
        return false;
    }

    if ($DB->record_exists('grade_items', array('courseid'=>$courseid, 'idnumber'=>$idnumber))) {
        return false;
    }

    return true;
}

/**
 * Force final grade recalculation in all course items
 *
 * @global object
 * @param int $courseid
 */
function grade_force_full_regrading($courseid) {
    global $DB;
    $DB->set_field('grade_items', 'needsupdate', 1, array('courseid'=>$courseid));
}

/**
 * Forces regrading of all site grades - usualy when chanign site setings
 * @global object
 * @global object
 */
function grade_force_site_regrading() {
    global $CFG, $DB;
    $DB->set_field('grade_items', 'needsupdate', 1);
}

/**
 * Recover a user's grades from grade_grades_history
 * @param int $userid the user ID whose grades we want to recover
 * @param int $courseid the relevant course
 * @return bool true if successful or false if there was an error or no grades could be recovered
 */
function grade_recover_history_grades($userid, $courseid) {
    global $CFG, $DB;

    if ($CFG->disablegradehistory) {
        debugging('Attempting to recover grades when grade history is disabled.');
        return false;
    }

    //Were grades recovered? Flag to return.
    $recoveredgrades = false;

    //Check the user is enrolled in this course
    //Dont bother checking if they have a gradeable role. They may get one later so recover
    //whatever grades they have now just in case.
    $course_context = get_context_instance(CONTEXT_COURSE, $courseid);
    if (!is_enrolled($course_context, $userid)) {
        debugging('Attempting to recover the grades of a user who is deleted or not enrolled. Skipping recover.');
        return false;
    }

    //Check for existing grades for this user in this course
    //Recovering grades when the user already has grades can lead to duplicate indexes and bad data
    //In the future we could move the existing grades to the history table then recover the grades from before then
    $sql = "SELECT gg.id
              FROM {grade_grades} gg
              JOIN {grade_items} gi ON gi.id = gg.itemid
             WHERE gi.courseid = :courseid AND gg.userid = :userid";
    $params = array('userid' => $userid, 'courseid' => $courseid);
    if ($DB->record_exists_sql($sql, $params)) {
        debugging('Attempting to recover the grades of a user who already has grades. Skipping recover.');
        return false;
    } else {
        //Retrieve the user's old grades
        //have history ID as first column to guarantee we a unique first column
        $sql = "SELECT h.id, gi.itemtype, gi.itemmodule, gi.iteminstance as iteminstance, gi.itemnumber, h.source, h.itemid, h.userid, h.rawgrade, h.rawgrademax,
                       h.rawgrademin, h.rawscaleid, h.usermodified, h.finalgrade, h.hidden, h.locked, h.locktime, h.exported, h.overridden, h.excluded, h.feedback,
                       h.feedbackformat, h.information, h.informationformat, h.timemodified, itemcreated.tm AS timecreated
                  FROM {grade_grades_history} h
                  JOIN (SELECT itemid, MAX(id) AS id
                          FROM {grade_grades_history}
                         WHERE userid = :userid1
                      GROUP BY itemid) maxquery ON h.id = maxquery.id AND h.itemid = maxquery.itemid
                  JOIN {grade_items} gi ON gi.id = h.itemid
                  JOIN (SELECT itemid, MAX(timemodified) AS tm
                          FROM {grade_grades_history}
                         WHERE userid = :userid2 AND action = :insertaction
                      GROUP BY itemid) itemcreated ON itemcreated.itemid = h.itemid
                 WHERE gi.courseid = :courseid";
        $params = array('userid1' => $userid, 'userid2' => $userid , 'insertaction' => GRADE_HISTORY_INSERT, 'courseid' => $courseid);
        $oldgrades = $DB->get_records_sql($sql, $params);

        //now move the old grades to the grade_grades table
        foreach ($oldgrades as $oldgrade) {
            unset($oldgrade->id);

            $grade = new grade_grade($oldgrade, false);//2nd arg false as dont want to try and retrieve a record from the DB
            $grade->insert($oldgrade->source);

            //dont include default empty grades created when activities are created
            if (!is_null($oldgrade->finalgrade) || !is_null($oldgrade->feedback)) {
                $recoveredgrades = true;
            }
        }
    }

    //Some activities require manual grade synching (moving grades from the activity into the gradebook)
    //If the student was deleted when synching was done they may have grades in the activity that haven't been moved across
    grade_grab_course_grades($courseid, null, $userid);

    return $recoveredgrades;
}

/**
 * Updates all final grades in course.
 *
 * @param int $courseid
 * @param int $userid if specified, try to do a quick regrading of grades of this user only
 * @param object $updated_item the item in which
 * @return boolean true if ok, array of errors if problems found (item id is used as key)
 */
function grade_regrade_final_grades($courseid, $userid=null, $updated_item=null) {

    $course_item = grade_item::fetch_course_item($courseid);

    if ($userid) {
        // one raw grade updated for one user
        if (empty($updated_item)) {
            print_error("cannotbenull", 'debug', '', "updated_item");
        }
        if ($course_item->needsupdate) {
            $updated_item->force_regrading();
            return array($course_item->id =>'Can not do fast regrading after updating of raw grades');
        }

    } else {
        if (!$course_item->needsupdate) {
            // nothing to do :-)
            return true;
        }
    }

    $grade_items = grade_item::fetch_all(array('courseid'=>$courseid));
    $depends_on = array();

    // first mark all category and calculated items as needing regrading
    // this is slower, but 100% accurate
    foreach ($grade_items as $gid=>$gitem) {
        if (!empty($updated_item) and $updated_item->id == $gid) {
            $grade_items[$gid]->needsupdate = 1;

        } else if ($gitem->is_course_item() or $gitem->is_category_item() or $gitem->is_calculated()) {
            $grade_items[$gid]->needsupdate = 1;
        }

        // construct depends_on lookup array
        $depends_on[$gid] = $grade_items[$gid]->depends_on();
    }

    $errors = array();
    $finalids = array();
    $gids     = array_keys($grade_items);
    $failed = 0;

    while (count($finalids) < count($gids)) { // work until all grades are final or error found
        $count = 0;
        foreach ($gids as $gid) {
            if (in_array($gid, $finalids)) {
                continue; // already final
            }

            if (!$grade_items[$gid]->needsupdate) {
                $finalids[] = $gid; // we can make it final - does not need update
                continue;
            }

            $doupdate = true;
            foreach ($depends_on[$gid] as $did) {
                if (!in_array($did, $finalids)) {
                    $doupdate = false;
                    continue; // this item depends on something that is not yet in finals array
                }
            }

            //oki - let's update, calculate or aggregate :-)
            if ($doupdate) {
                $result = $grade_items[$gid]->regrade_final_grades($userid);

                if ($result === true) {
                    $grade_items[$gid]->regrading_finished();
                    $grade_items[$gid]->check_locktime(); // do the locktime item locking
                    $count++;
                    $finalids[] = $gid;

                } else {
                    $grade_items[$gid]->force_regrading();
                    $errors[$gid] = $result;
                }
            }
        }

        if ($count == 0) {
            $failed++;
        } else {
            $failed = 0;
        }

        if ($failed > 1) {
            foreach($gids as $gid) {
                if (in_array($gid, $finalids)) {
                    continue; // this one is ok
                }
                $grade_items[$gid]->force_regrading();
                $errors[$grade_items[$gid]->id] = 'Probably circular reference or broken calculation formula'; // TODO: localize
            }
            break; // oki, found error
        }
    }

    if (count($errors) == 0) {
        if (empty($userid)) {
            // do the locktime locking of grades, but only when doing full regrading
            grade_grade::check_locktime_all($gids);
        }
        return true;
    } else {
        return $errors;
    }
}

/**
 * Refetches data from all course activities
 * @param int $courseid the course ID
 * @param string $modname limit the grade fetch to a single module type
 * @param int $userid limit the grade fetch to a single user
 * @return void
 */
function grade_grab_course_grades($courseid, $modname=null, $userid=0) {
    global $CFG, $DB;

    if ($modname) {
        $sql = "SELECT a.*, cm.idnumber as cmidnumber, m.name as modname
                  FROM {".$modname."} a, {course_modules} cm, {modules} m
                 WHERE m.name=:modname AND m.visible=1 AND m.id=cm.module AND cm.instance=a.id AND cm.course=:courseid";
        $params = array('modname'=>$modname, 'courseid'=>$courseid);

        if ($modinstances = $DB->get_records_sql($sql, $params)) {
            foreach ($modinstances as $modinstance) {
                grade_update_mod_grades($modinstance, $userid);
            }
        }
        return;
    }

    if (!$mods = get_plugin_list('mod') ) {
        print_error('nomodules', 'debug');
    }

    foreach ($mods as $mod => $fullmod) {
        if ($mod == 'NEWMODULE') {   // Someone has unzipped the template, ignore it
            continue;
        }

        // include the module lib once
        if (file_exists($fullmod.'/lib.php')) {
            // get all instance of the activity
            $sql = "SELECT a.*, cm.idnumber as cmidnumber, m.name as modname
                      FROM {".$mod."} a, {course_modules} cm, {modules} m
                     WHERE m.name=:mod AND m.visible=1 AND m.id=cm.module AND cm.instance=a.id AND cm.course=:courseid";
            $params = array('mod'=>$mod, 'courseid'=>$courseid);

            if ($modinstances = $DB->get_records_sql($sql, $params)) {
                foreach ($modinstances as $modinstance) {
                    grade_update_mod_grades($modinstance, $userid);
                }
            }
        }
    }
}

/**
 * Force full update of module grades in central gradebook
 *
 * @global object
 * @global object
 * @param object $modinstance object with extra cmidnumber and modname property
 * @param int $userid
 * @return boolean success
 */
function grade_update_mod_grades($modinstance, $userid=0) {
    global $CFG, $DB;

    $fullmod = $CFG->dirroot.'/mod/'.$modinstance->modname;
    if (!file_exists($fullmod.'/lib.php')) {
        debugging('missing lib.php file in module ' . $modinstance->modname);
        return false;
    }
    include_once($fullmod.'/lib.php');

    $updategradesfunc = $modinstance->modname.'_update_grades';
    $updateitemfunc   = $modinstance->modname.'_grade_item_update';

    if (function_exists($updategradesfunc) and function_exists($updateitemfunc)) {
        //new grading supported, force updating of grades
        $updateitemfunc($modinstance);
        $updategradesfunc($modinstance, $userid);

    } else {
        // mudule does not support grading??
    }

    return true;
}

/**
 * Remove grade letters for given context
 *
 * @param context $context
 * @param bool $showfeedback
 */
function remove_grade_letters($context, $showfeedback) {
    global $DB, $OUTPUT;

    $strdeleted = get_string('deleted');

    $DB->delete_records('grade_letters', array('contextid'=>$context->id));
    if ($showfeedback) {
        echo $OUTPUT->notification($strdeleted.' - '.get_string('letters', 'grades'), 'notifysuccess');
    }
}

/**
 * Remove all grade related course data - history is kept
 *
 * @param int $courseid
 * @param bool $showfeedback print feedback
 */
function remove_course_grades($courseid, $showfeedback) {
    global $DB, $OUTPUT;

    $fs = get_file_storage();
    $strdeleted = get_string('deleted');

    $course_category = grade_category::fetch_course_category($courseid);
    $course_category->delete('coursedelete');
    $fs->delete_area_files(get_context_instance(CONTEXT_COURSE, $courseid)->id, 'grade', 'feedback');
    if ($showfeedback) {
        echo $OUTPUT->notification($strdeleted.' - '.get_string('grades', 'grades').', '.get_string('items', 'grades').', '.get_string('categories', 'grades'), 'notifysuccess');
    }

    if ($outcomes = grade_outcome::fetch_all(array('courseid'=>$courseid))) {
        foreach ($outcomes as $outcome) {
            $outcome->delete('coursedelete');
        }
    }
    $DB->delete_records('grade_outcomes_courses', array('courseid'=>$courseid));
    if ($showfeedback) {
        echo $OUTPUT->notification($strdeleted.' - '.get_string('outcomes', 'grades'), 'notifysuccess');
    }

    if ($scales = grade_scale::fetch_all(array('courseid'=>$courseid))) {
        foreach ($scales as $scale) {
            $scale->delete('coursedelete');
        }
    }
    if ($showfeedback) {
        echo $OUTPUT->notification($strdeleted.' - '.get_string('scales'), 'notifysuccess');
    }

    $DB->delete_records('grade_settings', array('courseid'=>$courseid));
    if ($showfeedback) {
        echo $OUTPUT->notification($strdeleted.' - '.get_string('settings', 'grades'), 'notifysuccess');
    }
}

/**
 * Called when course category deleted - cleanup gradebook
 *
 * @global object
 * @param int $categoryid course category id
 * @param int $newparentid empty means everything deleted, otherwise id of category where content moved
 * @param bool $showfeedback print feedback
 */
function grade_course_category_delete($categoryid, $newparentid, $showfeedback) {
    global $DB;

    $context = get_context_instance(CONTEXT_COURSECAT, $categoryid);
    $DB->delete_records('grade_letters', array('contextid'=>$context->id));
}

/**
 * Does gradebook cleanup when module uninstalled.
 *
 * @global object
 * @global object
 * @param string $modname
 */
function grade_uninstalled_module($modname) {
    global $CFG, $DB;

    $sql = "SELECT *
              FROM {grade_items}
             WHERE itemtype='mod' AND itemmodule=?";

    // go all items for this module and delete them including the grades
    $rs = $DB->get_recordset_sql($sql, array($modname));
    foreach ($rs as $item) {
        $grade_item = new grade_item($item, false);
        $grade_item->delete('moduninstall');
    }
    $rs->close();
}

/**
 * Deletes all user data from gradebook.
 * @param $userid
 */
function grade_user_delete($userid) {
    if ($grades = grade_grade::fetch_all(array('userid'=>$userid))) {
        foreach ($grades as $grade) {
            $grade->delete('userdelete');
        }
    }
}

/**
 * Purge course data when user unenrolled.
 * @param $userid
 */
function grade_user_unenrol($courseid, $userid) {
    if ($items = grade_item::fetch_all(array('courseid'=>$courseid))) {
        foreach ($items as $item) {
            if ($grades = grade_grade::fetch_all(array('userid'=>$userid, 'itemid'=>$item->id))) {
                foreach ($grades as $grade) {
                    $grade->delete('userdelete');
                }
            }
        }
    }
}

/**
 * Grading cron job
 *
 * @global object
 * @global object
 */
function grade_cron() {
    global $CFG, $DB;

    $now = time();

    $sql = "SELECT i.*
              FROM {grade_items} i
             WHERE i.locked = 0 AND i.locktime > 0 AND i.locktime < ? AND EXISTS (
                SELECT 'x' FROM {grade_items} c WHERE c.itemtype='course' AND c.needsupdate=0 AND c.courseid=i.courseid)";

    // go through all courses that have proper final grades and lock them if needed
    $rs = $DB->get_recordset_sql($sql, array($now));
    foreach ($rs as $item) {
        $grade_item = new grade_item($item, false);
        $grade_item->locked = $now;
        $grade_item->update('locktime');
    }
    $rs->close();

    $grade_inst = new grade_grade();
    $fields = 'g.'.implode(',g.', $grade_inst->required_fields);

    $sql = "SELECT $fields
              FROM {grade_grades} g, {grade_items} i
             WHERE g.locked = 0 AND g.locktime > 0 AND g.locktime < ? AND g.itemid=i.id AND EXISTS (
                SELECT 'x' FROM {grade_items} c WHERE c.itemtype='course' AND c.needsupdate=0 AND c.courseid=i.courseid)";

    // go through all courses that have proper final grades and lock them if needed
    $rs = $DB->get_recordset_sql($sql, array($now));
    foreach ($rs as $grade) {
        $grade_grade = new grade_grade($grade, false);
        $grade_grade->locked = $now;
        $grade_grade->update('locktime');
    }
    $rs->close();

    //TODO: do not run this cleanup every cron invocation
    // cleanup history tables
    if (!empty($CFG->gradehistorylifetime)) {  // value in days
        $histlifetime = $now - ($CFG->gradehistorylifetime * 3600 * 24);
        $tables = array('grade_outcomes_history', 'grade_categories_history', 'grade_items_history', 'grade_grades_history', 'scale_history');
        foreach ($tables as $table) {
            if ($DB->delete_records_select($table, "timemodified < ?", array($histlifetime))) {
                mtrace("    Deleted old grade history records from '$table'");
            }
        }
    }
}

/**
 * Resel all course grades
 *
 * @param int $courseid
 * @return bool success
 */
function grade_course_reset($courseid) {

    // no recalculations
    grade_force_full_regrading($courseid);

    $grade_items = grade_item::fetch_all(array('courseid'=>$courseid));
    foreach ($grade_items as $gid=>$grade_item) {
        $grade_item->delete_all_grades('reset');
    }

    //refetch all grades
    grade_grab_course_grades($courseid);

    // recalculate all grades
    grade_regrade_final_grades($courseid);
    return true;
}

/**
 * Convert number to 5 decimalfloat, empty string or null db compatible format
 * (we need this to decide if db value changed)
 *
 * @param mixed $number
 * @return mixed float or null
 */
function grade_floatval($number) {
    if (is_null($number) or $number === '') {
        return null;
    }
    // we must round to 5 digits to get the same precision as in 10,5 db fields
    // note: db rounding for 10,5 is different from php round() function
    return round($number, 5);
}

/**
 * Compare two float numbers safely. Uses 5 decimals php precision. Nulls accepted too.
 * Used for skipping of db updates
 *
 * @param float $f1
 * @param float $f2
 * @return bool true if different
 */
function grade_floats_different($f1, $f2) {
    // note: db rounding for 10,5 is different from php round() function
    return (grade_floatval($f1) !== grade_floatval($f2));
}

/**
 * Compare two float numbers safely. Uses 5 decimals php precision.
 *
 * Do not use rounding for 10,5 at the database level as the results may be
 * different from php round() function.
 *
 * @since 2.0
 * @param float $f1
 * @param float $f2
 * @return bool true if the values should be considered as the same grades
 */
function grade_floats_equal($f1, $f2) {
    return (grade_floatval($f1) === grade_floatval($f2));
}
