module api_modules.classroom_compliance.score;

import handy_httpd.components.optional;
import ddbc;
import slf4d;
import std.datetime;

import api_modules.classroom_compliance.util;
import api_modules.classroom_compliance.score_eval;
import db;

enum ScorePeriod : string {
    WEEK = "week"
}

private struct ScoringParameters {
    Expr expr;
    const DateRange dateRange;
}

/**
 * Gets an associative array that maps student ids to their (optional) scores.
 * Scores are calculated based on aggregate statistics from their entries. A
 * student's score will be empty if they do not have any data available, or are
 * absent for all dates within the grading period.
 * Params:
 *   conn = The database connection.
 *   classId = The id of the class to filter by.
 *   date = The date to calculate scores for.
 * Returns: A map of scores.
 */
Optional!double[ulong] getScores(
    Connection conn,
    ulong classId,
    Date date
) {
    Optional!double[ulong] scores;
    auto optScoringParams = getScoringParameters(conn, classId, date);
    if (optScoringParams.isNull) {
        warnF!"Unable to obtain scoring parameters for class %d on %s."(classId, date.toISOExtString());
        return scores;
    }
    const ScoringParameters params = optScoringParams.value;
    debugF!"Calculating scores for class %d using expression (%s) from %s to %s."(
        classId,
        params.expr.toString(),
        params.dateRange.from,
        params.dateRange.to
    );

    const query = "
    SELECT
        student_id,
        COUNT(id) AS entry_count,
        SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
        SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
        SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
        SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
        SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
    FROM classroom_compliance_entry
    WHERE
        date >= ?
        AND date <= ?
        AND class_id = ?
    GROUP BY student_id
    ";
    PreparedStatement ps = conn.prepareStatement(query);
    scope(exit) ps.close();
    ps.setDate(1, params.dateRange.from);
    ps.setDate(2, params.dateRange.to);
    ps.setUlong(3, classId);
    foreach (DataSetReader r; ps.executeQuery()) {
        ulong studentId = r.getUlong(1);
        uint entryCount = r.getUint(2);
        uint absenceCount = r.getUint(3);
        uint phoneNonComplianceCount = r.getUint(4);
        uint behaviorGoodCount = r.getUint(5);
        uint behaviorMediocreCount = r.getUint(6);
        uint behaviorPoorCount = r.getUint(7);
        scores[studentId] = calculateScore(
            params.expr,
            entryCount,
            absenceCount,
            phoneNonComplianceCount,
            behaviorGoodCount,
            behaviorMediocreCount,
            behaviorPoorCount
        );
    }
    return scores;
}

private Optional!ScoringParameters getScoringParameters(Connection conn, ulong classId, in Date date) {
    const q = "SELECT score_expression, score_period FROM classroom_compliance_class WHERE id = ?";
    PreparedStatement ps = conn.prepareStatement(q);
    scope(exit) ps.close();
    ps.setUlong(1, classId);
    ResultSet rs = ps.executeQuery();
    scope(exit) rs.close();
    if (!rs.first()) return Optional!ScoringParameters.empty;
    string expr = rs.getString(1);
    string period = rs.getString(2);
    return Optional!ScoringParameters.of(ScoringParameters(
        parseExpression(expr),
        getDateRangeFromPeriodAndDate(date, period)
    ));
}

private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period) {
    if (period == ScorePeriod.WEEK) {
        Date friday = date;
        if (date.dayOfWeek() == DayOfWeek.sun) {
            friday = date - days(2);
        } else {
            int diff = DayOfWeek.fri - date.dayOfWeek();
            friday = date + days(diff);
        }
        Date monday = friday - days(4);
        return DateRange(monday, friday);
    } else {
        throw new Exception("Unsupported period: " ~ period);
    }
}

/**
 * Calculates the score for a particular student, using the formula defined
 * by the class.
 * Returns: The score, or an empty optional if there isn't enough data.
 */
private Optional!double calculateScore(
    in Expr scoreExpression,
    uint entryCount,
    uint absenceCount,
    uint phoneNonComplianceCount,
    uint behaviorGoodCount,
    uint behaviorMediocreCount,
    uint behaviorPoorCount
) {
    if (
        entryCount == 0
        || entryCount <= absenceCount
    ) return Optional!double.empty;

    const uint presentCount = entryCount - absenceCount;

    // Phone subscore:
    uint phoneCompliantCount;
    if (presentCount < phoneNonComplianceCount) {
        phoneCompliantCount = 0;
    } else {
        phoneCompliantCount = presentCount - phoneNonComplianceCount;
    }
    double phoneScore = phoneCompliantCount / cast(double) presentCount;

    double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
    double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
    double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount;
    double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore);

    return Optional!double.of(scoreExpression.eval([
        "phone": phoneScore,
        "behavior": typicalBehaviorScore,
        "behavior_good": behaviorGoodScore,
        "behavior_mediocre": behaviorMediocreScore,
        "behavior_poor": behaviorPoorScore
    ]));
}
