// add tooltip to assignments
// Schoology uses a jQuery plugin called tipsy to do its native tooltips; we use the same for consistency
// https://github.com/MartinMartimeo/tipsy
// we use an ancient jquery version because our upstream library does

"use strict";

(async function () {
    // Wait for loader.js to finish running
    while (!window.splusLoaded) {
        await new Promise(resolve => setTimeout(resolve, 10));
    }
    await loadDependencies("materials", ["all"]);
})();

(async function () {
    let classId = window.location.pathname.match(/\/course\/(\d+)\/materials/)[1]; // ID of current course ("section"), as a string

    pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL("lib/js/pdf.worker.js");
    pdfjsLib.disableWorker = true;

    function createPDFThumbnail(filePath, callback, imgWidth = null, imgHeight = null) {
        if (typeof pdfjsLib === 'undefined') {
            throw Error("pdf.js is not loaded");
        }

        pdfjsLib.getDocument(filePath).then(function (pdf) {
            pdf.getPage(1).then(function (page) {
                var canvas = document.createElement("canvas");
                var viewport = page.getViewport(1.0);
                var context = canvas.getContext('2d');

                if (imgWidth) {
                    viewport = page.getViewport(imgWidth / viewport.width);
                } else if (imgHeight) {
                    viewport = page.getViewport(imgHeight / viewport.height);
                }

                canvas.height = viewport.height;
                canvas.width = viewport.width;

                page.render({
                    canvasContext: context,
                    viewport: viewport
                }).then(function () {
                    callback(canvas.toDataURL(), pdf.numPages);
                }).catch(function (e) {
                    Logger.error("Unexpected PDF render error: " + e);
                });
            }).catch(function () {
                Logger.log("could not open page 1 of document " + filePath + ". Not a pdf ?");
            });
        }).catch(function () {
            Logger.log("could not find or open document " + filePath + ". Not a pdf ?");
        });
    }

    // utility function
    function escapeForHtml(text) {
        let tempElem = document.createElement("span");
        $(tempElem).text(text);
        return tempElem.innerText;
    }

    /**
     * Wraps the given base string in syntax for an HTML element.
     * @param {string} baseString The HTML syntax string to wrap.
     * @param {string} wrapperTag The tag name of the wrapper element to use.
     * @param {Object.<string,any>} wrapperProps Properties to add to the wrapper element (they will be quoted). Escaping will not be performed; it is the caller's responsibility
     * @returns The given HTML syntax wrapped in a tag as specified.
     */
    function wrapHtml(baseString, wrapperTag, wrapperProps) {
        let resultString = "<" + wrapperTag;
        if (wrapperProps) {
            for (let prop in wrapperProps) {
                resultString += ` ${prop}="${wrapperProps[prop]}"`;
            }
        }
        resultString += ">";
        resultString += baseString;
        resultString += "</" + wrapperTag + ">";

        return resultString;
    }

    let gradeLoadHooks = [];
    let userId;
    let immediateGradeLoadInvoke = false;
    let loadedGradeContainer = null;

    function registerGradeLoadHook(loadHook) {
        if (immediateGradeLoadInvoke) {
            loadHook(loadedGradeContainer);
        } else {
            gradeLoadHooks.push(loadHook);
        }
    }

    // FIXME the container element in question (the list of assignments) can be recreated; we should handle this case

    let courseProfileMaterials = document.getElementById("course-profile-materials");

    // data handler from page context, logic is there
    // tr.type-document
    async function processDocumentElement(value) {
        let loadedTextHolder = document.createElement("span");
        loadedTextHolder.id = "tooltip-holder-" + value.id;
        loadedTextHolder.classList.add("type-document");
        loadedTextHolder.dataset.domElementId = value.id;
        loadedTextHolder.dataset.tooltipHtml = "Loading...";

        // title already has a tooltip (full filename), so we'll use the filesize instead
        // if there's no filesize (e.g. because this document isn't a file), we won't create the tooltip, so no problems
        $(value).find(".attachments-file-name>a").tipsy({ gravity: "w", html: true, title: () => loadedTextHolder.dataset.tooltipHtml });

        // blank because null becomes "null" in the dataset, which is truthy; blank is falsey
        let documentUrlFromApi = "";
        let materialId = value.id.match(/\d+/)[0];
        let documentInfoFromApi = await fetchApiJson(`/sections/${classId}/documents/${materialId}`);

        if (!documentInfoFromApi.attachments.files || !documentInfoFromApi.attachments.files.file[0]) {
            // dynamic nonfile (probably link) element
            // abort creation of tooltip
            value.dataset.schoologyPlusProcessedTooltip = true;
            return;
        }
        let fileData = documentInfoFromApi.attachments.files.file[0];
        if (fileData.converted_download_path && (fileData.converted_filemime == "application/pdf" || new URL(fileData.converted_download_path).pathname.endsWith(".pdf"))) {
            // get the URL of the doc we want
            // it's an unauthenticated CDN url that expires (experimentation), returned as a redirect
            // unfortunately we need permissions for the extra domain
            documentUrlFromApi = (await fetchWithApiAuthentication(fileData.converted_download_path)).url;
        } else {
            value.dataset.schoologyPlusProcessedTooltip = true;
            return;
        }

        loadedTextHolder.dataset.docInfo = JSON.stringify(documentInfoFromApi);
        loadedTextHolder.dataset.docUrl = documentUrlFromApi;

        createPDFThumbnail(documentUrlFromApi, (data, numPages) => {
            loadedTextHolder.dataset.tooltipHtml = `<img src="${data}" /><p style="text-align:center">${numPages} pages</p>`
        }, 200);

        value.dataset.schoologyPlusProcessedTooltip = true;
    }

    // assignment tooltips
    // tr.type-assignment
    function processAssignmentElement(value) {
        let loadedGrade = "Loading...";
        $(value).find(".item-title>a").tipsy({ gravity: "n", html: true, title: () => loadedGrade });

        let assignmentId = value.id.match(/\d+/)[0];

        registerGradeLoadHook(function (grades) {
            let innerContent = "";

            if (!grades.grades[assignmentId]) {
                innerContent = "<span class=\"error-message\">No data found</span>";
            } else {
                let assignment = grades.grades[assignmentId];
                if (assignment.exception == 1) {
                    // excused
                    innerContent += "<span class=\"exception-excused\">Excused</span>";
                } else if (assignment.exception == 2) {
                    // incomplete
                    innerContent += "<span class=\"exception-incomplete\">Incomplete</span>";
                } else if (assignment.exception == 3) {
                    // missing
                    innerContent += "<span class=\"exception-missing\">Missing</span>";
                } else if (assignment.grade !== null) {
                    // normal grade status, has a grade
                    let gradeElem = wrapHtml(escapeForHtml((!assignment.max_points && assignment.grade >= 0 ? "+" : "") + assignment.grade), "span", { class: "tooltip-grade-numerator" });
                    if (assignment.max_points) {
                        gradeElem += " <span class=\"tooltip-horiz-divider\">/</span> ";
                        gradeElem += wrapHtml(escapeForHtml(assignment.max_points, "span", { class: "tooltip-grade-denominator" }));
                    } else {
                        gradeElem += " pts";
                    }
                    innerContent += wrapHtml(gradeElem, "span", { class: "tooltip-grade-info" });
                } else {
                    // normal grade status, ungraded by teacher
                    innerContent += "<span>No Grade</span>";
                }

                // strange denominator display circumstances
                if (assignment.exception || assignment.grade === null) {
                    let ptStr = !assignment.max_points ? "EC" : `${assignment.max_points} pts`;
                    innerContent += ` <span class=\"exception-max-pts-info\">(${ptStr})</span>`;
                }

                if (grades.gradeDrops[assignmentId]) {
                    // assignment dropped
                    innerContent = wrapHtml("Dropped", "span", { class: "tooltip-dropped-indicator" }) + " " + wrapHtml(innerContent, "span", { class: "tooltip-dropped-gradeinfo" });
                }

                innerContent = wrapHtml(innerContent, "p");

                let footerElements = [];
                if (assignment.category_id && grades.categories[assignment.category_id]) {
                    footerElements.push(wrapHtml(escapeForHtml(grades.categories[assignment.category_id].title), "span", { class: "tooltip-category" }));
                }
                if (grades.dropboxes[assignmentId]) {
                    let validSubmissions = grades.dropboxes[assignmentId].filter(x => !x.draft);
                    if (validSubmissions.length == 0) {
                        footerElements.push(wrapHtml("Not Submitted", "span", { class: "tooltip-not-submitted" }));
                    } else {
                        let submittedStatus = "Submitted";
                        if (validSubmissions[validSubmissions.length - 1].late) {
                            submittedStatus += " (Late)";
                            submittedStatus = wrapHtml(submittedStatus, "span", { class: "tooltip-submitted-late" });
                        } else {
                            submittedStatus = wrapHtml(submittedStatus, "span", { class: "tooltip-submitted-ontime" });
                        }
                        footerElements.push(submittedStatus);
                    }
                }

                if (footerElements.length > 0) {
                    innerContent += wrapHtml(footerElements.join("<span class=\"tooltip-horiz-divider\"> | </span>"), "p", { class: "tooltip-footer" });
                }
            }

            loadedGrade = wrapHtml(innerContent, "div", { class: "schoologyplus-tooltip assignment-tooltip" });
        });

        value.dataset.schoologyPlusProcessedTooltip = true;
    }

    for (let docElem of courseProfileMaterials.querySelectorAll("tr.type-document")) {
        processDocumentElement(docElem);
    }

    for (let assignElem of courseProfileMaterials.querySelectorAll("tr.type-assignment")) {
        processAssignmentElement(assignElem);
    }

    let materialsMutationObserver = new MutationObserver(function (mutationRecords) {
        let reprocessElements = false;

        for (let mutateRecord of mutationRecords) {
            if (!mutateRecord.addedNodes) {
                continue;
            }

            reprocessElements = true;
            break;
        }

        if (!reprocessElements) {
            return;
        }

        // hack because apparently we can't trust this MutationObserver to catch everything

        for (let addedNode of courseProfileMaterials.querySelectorAll("tr.type-assignment, tr.type-document")) {
            if (addedNode.dataset.schoologyPlusProcessedTooltip) {
                continue;
            }

            if (addedNode.nodeName == "TR") {
                if (addedNode.classList.contains("type-document")) {
                    processDocumentElement(addedNode);
                } else if (addedNode.classList.contains("type-assignment")) {
                    processAssignmentElement(addedNode);
                }
            }
        }
    });

    userId = getUserId();

    // watch for new material DOM elements, and process them as they're added
    // needs a corresponding handler in the pagescript
    materialsMutationObserver.observe(courseProfileMaterials, { childList: true, subtree: true });


    // load grade information for this class, call grade hooks

    // this object contains ALL grades from this Schoology account, "keyed" by annoying IDs
    // they're filtered to this section though
    let myGrades = await fetchApiJson(`/users/${userId}/grades?section_id=${classId}`);

    let thisClassGrades = myGrades.section[0];

    // start building our return object, which will follow a nicer format
    loadedGradeContainer = {};
    loadedGradeContainer.categories = {};
    loadedGradeContainer.grades = {};
    loadedGradeContainer.assignments = {};
    loadedGradeContainer.dropboxes = {};
    loadedGradeContainer.gradeDrops = {};

    if (thisClassGrades) {

        // gradebook categories
        for (let gradeCategory of thisClassGrades.grading_category) {
            loadedGradeContainer.categories[gradeCategory.id] = gradeCategory;
            Object.freeze(gradeCategory);
        }

        Object.freeze(loadedGradeContainer.categories);

        // assignment grades
        // period is an array of object
        // period[x].assignment is an array of grade objects (the ones we want to enumerate)
        for (let assignment of thisClassGrades.period.reduce((accum, curr) => accum.concat(curr.assignment), [])) {
            loadedGradeContainer.grades[assignment.assignment_id] = assignment;
            Object.freeze(assignment);
        }

        Object.freeze(loadedGradeContainer.grades);

    }

    let missingAssignmentCt = 0;
    let missingAssignmentErrorCt = 0;

    // assignments
    // need a separate API call
    let ourAssignments = await fetchApiJson(`/sections/${classId}/assignments`);

    // for some reason (TODO why) that call doesn't always return everything
    // since we only use the assignments collection here (internally), no need to add the entire remainder off of grades
    // just if it's a strange grade status (exception or ungraded), we'll pull assignment details
    // also only obtain it if there's a material entry in the DOM for it, otherwise there's no point
    // TODO does the DOM-existance check ever come into play such that we don't load data for an item which is dynamically loaded later?
    let missingAssignmentIds = Object.keys(loadedGradeContainer.grades).filter(x => ourAssignments.assignment.findIndex(y => y.id == x) < 0).filter(x => document.getElementById("n-" + x));
    for (let missingAssignment of missingAssignmentIds) {
        missingAssignmentCt++;
        let fetchRes = await fetchApi(`/sections/${classId}/assignments/${missingAssignment}`);
        if (fetchRes.ok) {
            ourAssignments.assignment.push(await fetchRes.json());
        } else {
            missingAssignmentErrorCt++;
        }
    }

    if (missingAssignmentCt > 0) {
        Logger.log(`Fetched ${missingAssignmentCt} assignment(s) (${missingAssignmentErrorCt} error(s)) missing from summary API call`);
    }

    for (let assignment of ourAssignments.assignment) {
        loadedGradeContainer.assignments[assignment.id] = assignment;
        Object.freeze(assignment);

        // string "0" or "1" from API
        // for performance reasons, don't fetch info for all dropboxes, just those without grade
        // assignments by default have dropboxes, but if teachers don't mean for them to be there, they end up being meaningless "Not Submitted"
        // thus, if the assignment has a normal grade, don't fetch submission status here
        // improves performance because it's one NETWORK API call PER ASSIGNMENT, and it blocks all tooltips until all assignments are done loading
        // especially visible in large classes
        let gradeInfo = loadedGradeContainer.grades[assignment.id];
        if (+assignment.allow_dropbox && (!gradeInfo || gradeInfo.grade === null || gradeInfo.exception)) {
            // "dropboxes," or places to submit documents via Schoology
            // another API call
            loadedGradeContainer.dropboxes[assignment.id] = (await fetchApiJson(`/sections/${classId}/submissions/${assignment.id}/${userId}`)).revision;
            Object.freeze(loadedGradeContainer.dropboxes[assignment.id]);
        }
    }

    Object.freeze(loadedGradeContainer.assignments);
    Object.freeze(loadedGradeContainer.dropboxes);

    // grade drops
    // unfortunately it doesn't look like the API returns grade drop status, so we have to scrape it from the gradebook
    let ourGradebookHtml = await (await fetch(`https://${Setting.getValue("defaultDomain")}/course/${classId}/student_grades`)).text();
    let ourGradebookParser = new DOMParser();
    let ourGradebookDoc = ourGradebookParser.parseFromString(ourGradebookHtml, "text/html");

    let containerDiv = ourGradebookDoc.querySelector(".gradebook-course-grades");
    if (containerDiv) {
        // in some courses you can't see your grades (annoying), so we'll get a 404 page and the element won't be there (or if loading fails for any other reason)
        for (let gradeItemRow of containerDiv.querySelectorAll(".item-row")) {
            loadedGradeContainer.gradeDrops[gradeItemRow.dataset.id.match(/\d+/)[0]] = gradeItemRow.classList.contains("dropped");
        }
    }

    Object.freeze(loadedGradeContainer.gradeDrops);
    Object.freeze(loadedGradeContainer);

    Logger.log("Assignment data loaded, creating tooltips");

    immediateGradeLoadInvoke = true;

    // call our event listeners
    for (let eventHook of gradeLoadHooks) {
        eventHook(loadedGradeContainer);
    }
})().catch(reason => {
    Logger.error("Error running materials page modification script: ", reason);
});

Logger.debug("Finished loading materials.js");