module api_modules.classroom_compliance.api_entry;

import handy_httpd;
import handy_httpd.components.optional;
import ddbc;
import std.datetime;
import std.json;
import std.algorithm : map;
import std.array;
import slf4d;

import api_modules.auth;
import api_modules.classroom_compliance.model;
import api_modules.classroom_compliance.util;
import api_modules.classroom_compliance.score;
import db;
import data_utils;

struct EntriesTableEntryChecklistItem {
    string item;
    bool checked;
    string category;
}

struct EntriesTableEntry {
    Date date;
    ulong createdAt;
    bool absent;
    string comment;
    EntriesTableEntryChecklistItem[] checklistItems;

    JSONValue toJsonObj() const {
        JSONValue obj = JSONValue.emptyObject;
        obj.object["date"] = JSONValue(date.toISOExtString());
        obj.object["createdAt"] = JSONValue(createdAt);
        obj.object["absent"] = JSONValue(absent);
        obj.object["comment"] = JSONValue(comment);
        obj.object["checklistItems"] = JSONValue.emptyArray;
        foreach (ck; checklistItems) {
            JSONValue ckObj = JSONValue.emptyObject;
            ckObj.object["item"] = JSONValue(ck.item);
            ckObj.object["checked"] = JSONValue(ck.checked);
            ckObj.object["category"] = JSONValue(ck.category);
            obj.object["checklistItems"].array ~= ckObj;
        }
        return obj;
    }
}

struct EntriesTableStudentResponse {
    ulong id;
    ulong classId;
    string name;
    ushort deskNumber;
    bool removed;
    EntriesTableEntry[string] entries;
    Optional!double score;

    JSONValue toJsonObj() const {
        JSONValue obj = JSONValue.emptyObject;
        obj.object["id"] = JSONValue(id);
        obj.object["classId"] = JSONValue(classId);
        obj.object["name"] = JSONValue(name);
        obj.object["deskNumber"] = JSONValue(deskNumber);
        obj.object["removed"] = JSONValue(removed);
        JSONValue entriesSet = JSONValue.emptyObject;
        foreach (dateStr, entry; entries) {
            entriesSet.object[dateStr] = entry.toJsonObj();
        }
        obj.object["entries"] = entriesSet;
        if (score.isNull) {
            obj.object["score"] = JSONValue(null);
        } else {
            obj.object["score"] = JSONValue(score.value);
        }
        return obj;
    }
}

struct EntriesTableResponse {
    EntriesTableStudentResponse[] students;
    string[] dates;

    JSONValue toJsonObj() const {
        JSONValue obj = JSONValue.emptyObject;
        obj.object["students"] = JSONValue(students.map!(s => s.toJsonObj()).array);
        obj.object["dates"] = JSONValue(dates);
        return obj;
    }
}

/**
 * Main endpoint that supplies data for the app's "entries" table, which shows
 * all data about all students in a class, usually for a selected week. Here,
 * we need to provide a list of students which will be treated as rows by the
 * table, and then for each student, an entry object for each date in the
 * requested date range.
 * Params:
 *   ctx = The request context.
 */
void getEntries(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    scope(exit) conn.close();
    User user = getUserOrThrow(ctx, conn);
    auto cls = getClassOrThrow(ctx, conn, user);
    DateRange dateRange = parseDateRangeParams(ctx);

    // First prepare a list of all students, including ones which don't have any entries.
    ClassroomComplianceStudent[] students = findAll(
        conn,
        "SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
        &ClassroomComplianceStudent.parse,
        cls.id
    );
    EntriesTableStudentResponse[] studentObjects = students.map!(s => EntriesTableStudentResponse(
        s.id,
        s.classId,
        s.name,
        s.deskNumber,
        s.removed,
        null,
        Optional!double.empty
    )).array;

    const entriesQuery = import("source/api_modules/classroom_compliance/queries/find_entries_by_class.sql");
    PreparedStatement ps = conn.prepareStatement(entriesQuery);
    scope(exit) ps.close();
    ps.setUlong(1, cls.id);
    ps.setDate(2, dateRange.from);
    ps.setDate(3, dateRange.to);
    ResultSet rs = ps.executeQuery();
    scope(exit) rs.close();

    ClassroomComplianceStudent student;
    EntriesTableEntry entry;
    bool hasNextRow = rs.next();

    while (hasNextRow) {
        // Parse the basic data from the query.
        student.id = rs.getUlong(1);
        student.name = rs.getString(2);
        student.classId = cls.id;
        student.deskNumber = rs.getUshort(3);
        student.removed = rs.getBoolean(4);

        entry.date = rs.getDate(5);
        entry.createdAt = rs.getUlong(6);
        entry.absent = rs.getBoolean(7);
        entry.comment = rs.getString(8);

        bool hasChecklistItem = !rs.isNull(9);
        if (hasChecklistItem) {
            entry.checklistItems ~= EntriesTableEntryChecklistItem(
                rs.getString(9),
                rs.getBoolean(10),
                rs.getString(11)
            );
        }

        // Load in the next row, and if it's the end of the result set or a new entry, we save this one.
        hasNextRow = rs.next();
        bool shouldSaveEntry = !hasNextRow || (
            rs.getUlong(1) != student.id ||
            rs.getDate(5) != entry.date
        );
        if (shouldSaveEntry) {
            // Save the data for the current student and entry, including all checklist items.
            // Then proceed to read the next item.
            string dateStr = entry.date.toISOExtString();

            // Find the student object this entry belongs to, then add it to their list.
            bool studentFound = false;
            foreach (ref studentObj; studentObjects) {
                if (studentObj.id == student.id) {
                    studentObj.entries[dateStr] = entry;
                    studentFound = true;
                    break;
                }
            }
            if (!studentFound) {
                // The student isn't in our list of original students from the
                // class, so it's a student who has since moved to another class.
                // Their data should still be shown, so add the student here.
                studentObjects ~= EntriesTableStudentResponse(
                    student.id,
                    student.classId,
                    student.name,
                    student.deskNumber,
                    student.removed,
                    [dateStr: entry],
                    Optional!double.empty
                );
            }

            // Finally, reset the entry's list of checklist items.
            entry.checklistItems = [];
        }
    }

    // Find scores for each student for this timeframe.
    Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to);
    foreach (studentId, score; scores) {
        bool studentFound = false;
        foreach (ref studentObj; studentObjects) {
            if (studentObj.id == studentId) {
                studentObj.score = score;
                studentFound = true;
                break;
            }
        }
        if (!studentFound) {
            throw new Exception("Failed to find student for which a score was calculated.");
        }
    }

    // Prepare the final response to the client:
    EntriesTableResponse response;
    Date d = dateRange.from;
    while (d <= dateRange.to) {
        string dateStr = d.toISOExtString();
        response.dates ~= dateStr;
        d += days(1);
    }
    response.students = studentObjects;
    JSONValue responseObj = response.toJsonObj();
    // Go back and add null to any dates any student is missing an entry for.
    foreach (ref studentObj; responseObj.object["students"].array) {
        foreach (dateStr; response.dates) {
            if (dateStr !in studentObj.object["entries"].object) {
                studentObj.object["entries"].object[dateStr] = JSONValue(null);
            }
        }
    }
    ctx.response.writeBodyString(responseObj.toJSON(), "application/json");
}

/**
 * Endpoint for the user to save changes to any entries they've edited. The
 * user provides a JSON payload containing the updated entries, and we go
 * through and perform updates to the database to match the desired state.
 * Params:
 *   ctx = The request context.
 */
void saveEntries(ref HttpRequestContext ctx) {
    Connection conn = getDb();
    conn.setAutoCommit(false);
    scope(exit) conn.close();
    User user = getUserOrThrow(ctx, conn);
    auto cls = getClassOrThrow(ctx, conn, user);
    if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
    JSONValue bodyContent = ctx.request.readBodyAsJson();
    try {
        foreach (JSONValue studentObj; bodyContent.object["students"].array) {
            ulong studentId = studentObj.object["id"].integer();
            JSONValue entries = studentObj.object["entries"];
            foreach (string dateStr, JSONValue entry; entries.object) {
                // Always start by deleting the existing entry to overwrite it with the new one.
                deleteEntry(conn, cls.id, studentId, dateStr);
                if (!entry.isNull) {
                    insertEntry(conn, cls.id, studentId, dateStr, entry);
                }
            }
        }
        conn.commit();
    } catch (HttpStatusException e) {
        conn.rollback();
        ctx.response.status = e.status;
        ctx.response.writeBodyString(e.message);
    } catch (JSONException e) {
        conn.rollback();
        ctx.response.status = HttpStatus.BAD_REQUEST;
        ctx.response.writeBodyString("Invalid JSON payload.");
        warn(e);
    } catch (Exception e) {
        conn.rollback();
        ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
        ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg);
        error(e);
    }
}

private void deleteEntry(
    Connection conn,
    ulong classId,
    ulong studentId,
    string dateStr
) {
    update(
        conn,
        "DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
        classId, studentId, dateStr
    );
}

private void insertEntry(
    Connection conn,
    ulong classId,
    ulong studentId,
    string dateStr,
    JSONValue payload
) {
    bool absent = payload.object["absent"].boolean;
    string comment = payload.object["comment"].str;
    if (comment is null) comment = "";
    EntriesTableEntryChecklistItem[] checklistItems;
    if ("checklistItems" in payload.object) {
        checklistItems = payload.object["checklistItems"].array
            .map!((obj) {
                EntriesTableEntryChecklistItem ck;
                ck.item = obj.object["item"].str;
                ck.checked = obj.object["checked"].boolean;
                ck.category = obj.object["category"].str;
                return ck;
            })
            .array;
    }
    // If absent, ensure no checklist items may be checked.
    if (absent) {
        foreach (ref ck; checklistItems) {
            ck.checked = false;
        }
    }
    // Do the main insert first.
    const query = "
    INSERT INTO classroom_compliance_entry
    (class_id, student_id, date, absent, comment)
    VALUES (?, ?, ?, ?, ?)";
    PreparedStatement ps = conn.prepareStatement(query);
    scope(exit) ps.close();
    ps.setUlong(1, classId);
    ps.setUlong(2, studentId);
    ps.setString(3, dateStr);
    ps.setBoolean(4, absent);
    ps.setString(5, comment);
    ps.executeUpdate();
    // Now insert checklist items, if any.
    if (checklistItems.length > 0) {
        const ckQuery = "
        INSERT INTO classroom_compliance_entry_checklist_item
        (class_id, student_id, date, item, checked, category)
        VALUES (?, ?, ?, ?, ?, ?)";
        PreparedStatement ckPs = conn.prepareStatement(ckQuery);
        scope(exit) ckPs.close();
        foreach (ck; checklistItems) {
            ckPs.setUlong(1, classId);
            ckPs.setUlong(2, studentId);
            ckPs.setString(3, dateStr);
            ckPs.setString(4, ck.item);
            ckPs.setBoolean(5, ck.checked);
            ckPs.setString(6, ck.category);
            ckPs.executeUpdate();
        }
    }
}
