/**
 * ProjectLaogai
 *
 * Copyright 2019-2020  <seil0@mosad.xyz>
 *
 * This program 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.
 *
 * This program 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 this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
 * MA 02110-1301, USA.
 *
 */

package org.mosad.seil0.projectlaogai.controller.cache

import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.controller.QISPOSParser
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.coursesCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.gradesCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.mensaCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.timetableCacheTime
import org.mosad.seil0.projectlaogai.util.*
import java.io.*
import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap

/**
 * The cacheController reads and updates the cache files.
 * It contains the courseList and mensaMenu object, all timetable objects
 * are located in TimetableController.
 */
class CacheController(private val context: Context) {

    private val className = this.javaClass.name

    init {
        val cal = Calendar.getInstance()
        val currentDay = Calendar.getInstance().get(Calendar.DAY_OF_WEEK)
        val currentTime = System.currentTimeMillis() / 1000

        // check if we need to update the mensa data before displaying it
        cal.time = Date(mensaCacheTime * 1000)

        // if a) it's monday and the last cache update was on sunday or b) the cache is older than 24hr, update blocking
        if ((currentDay == Calendar.MONDAY && cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) || (currentTime - mensaCacheTime) > 86400) {
           Log.i(className, "Update mensa blocking")
           GlobalScope.launch(Dispatchers.Default) {  updateMensaMenu(context).join() }
        }

        // check if we need to update the timetable before displaying it
        cal.time = Date(timetableCacheTime * 1000)

        // if a) it`s monday and the last cache update was not on a sunday or b) the cache is older than 24hr, update blocking
        if ((currentDay == Calendar.MONDAY && cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) || (currentTime - timetableCacheTime) > 86400) {
            Log.i(className, "Updating timetable after sunday!")

            GlobalScope.launch(Dispatchers.Default) {
                val threads = listOf(
                    updateTimetable(Preferences.course.courseName, 0, context),
                    updateTimetable(Preferences.course.courseName, 1, context)
                )
                threads.joinAll()
            }
        }

        updateCourseList(context)

        readStartCache(Preferences.course.courseName) // initially read values from cache

        // check if an update is necessary, not blocking
        if (currentTime - coursesCacheTime > 86400)
            updateCourseList(context)

        if (currentTime - mensaCacheTime > 10800)
            updateMensaMenu(context)

        if (currentTime - timetableCacheTime > 10800)
            TimetableController.update(context)
    }

    companion object {
        private const val className = "CacheController"
        var coursesList = ArrayList<Course>()
        var mensaMenu = MensaMenu()

        /**
         * update the course list, async
         */
        fun updateCourseList(context: Context): Job {
            val file = File(context.filesDir, "courses.json")
            var courseListUp = CoursesList()

            return GlobalScope.launch(Dispatchers.IO) {
                try {
                    courseListUp = TCoRAPIController.getCourseListNEW()
                } catch (ex: Exception) {
                    Log.e(className, "could not load course list from tcor", ex)
                }

                // update coursesList array list
                coursesList = courseListUp.courses

                // save cache file and update time
                save(file, Gson().toJson(courseListUp))
                coursesCacheTime = System.currentTimeMillis() / 1000
                Preferences.save(context)
            }
        }

        /**
         * update the mensa menu, async
         */
        fun updateMensaMenu(context: Context): Job {
            val file = File(context.filesDir, "mensa.json")

            return GlobalScope.launch(Dispatchers.IO) {
                try {
                    mensaMenu = TCoRAPIController.getMensaMenu()
                } catch (ex: Exception) {
                    Log.e(className, "could not load mensa menu from tcor", ex)
                }

                // save cache file and update time
                save(file, Gson().toJson(mensaMenu))
                mensaCacheTime = System.currentTimeMillis() / 1000
                Preferences.save(context)
            }
        }

        /**
         * update the timetable for a week, async
         * @param courseName the course name (e.g AI1)
         * @param week the week to update (0 for the current and so on)
         */
        fun updateTimetable(courseName: String, week: Int, context: Context): Job {
            val file = File(context.filesDir, "timetable-$courseName-$week.json")
            var timetable = TimetableWeek()

            // try to update timetable from tcor, async
            return GlobalScope.launch(Dispatchers.IO) {
                try {
                    timetable = TCoRAPIController.getTimetable(courseName, week)
                } catch (ex: Exception) {
                    Log.e(className, "could not load timetable $courseName[$week] from tcor", ex)
                }

                // update timetable in TTC
                if (TimetableController.timetable.size > week) {
                    TimetableController.timetable[week] = timetable
                } else {
                    TimetableController.timetable.add(timetable)
                }

                // save cache file and update time
                save(file, Gson().toJson(timetable))
                timetableCacheTime = System.currentTimeMillis() / 1000
                Preferences.save(context)
            }
        }

        /**
         * update all additional subject lessons, async
         */
        fun updateAdditionalLessons(context: Context): Job {
            val fileLessons = File(context.filesDir, "additional_lessons.json")

            return GlobalScope.launch(Dispatchers.IO) {
                TimetableController.subjectMap.forEach { (courseName, subjects) ->
                    // update all subjects for a course
                    subjects.forEach {subject ->
                        try {
                            TCoRAPIController.getLessons(courseName, subject, 0).forEach { lesson ->
                                TimetableController.addLesson(courseName, subject, lesson)
                            }
                        } catch (ex: Exception) {
                            Log.e(className, "could not load $courseName: $subject", ex)
                        }
                    }
                }

                save(fileLessons, Gson().toJson(TimetableController.lessonMap))
            }
        }

        /**
         * update the encrypted grades file
         */
        fun updateGrades(context: Context): Job {
            val file = File(context.filesDir, "grades_encrypted")
            val parser = QISPOSParser(context)

            return GlobalScope.launch(Dispatchers.IO) {
                if (parser.checkQISPOSStatus() == 200) {
                    // save cache file and update time
                    saveEncrypted(context, file, Gson().toJson(parser.parseGrades()))
                    gradesCacheTime = System.currentTimeMillis() / 1000
                    Preferences.save(context)
                }
            }
        }

        /**
         * save changes in lessonMap and subjectMap,
         * called on addSubject or removeSubject
         */
        fun saveAdditionalSubjects(context: Context) {
            val fileLessons = File(context.filesDir, "additional_lessons.json")
            val fileSubjects = File(context.filesDir, "additional_subjects.json")

            save(fileLessons, Gson().toJson(TimetableController.lessonMap))
            save(fileSubjects, Gson().toJson(TimetableController.subjectMap))
        }

        private fun save(file: File, text: String) {
            try {
                val writer = BufferedWriter(FileWriter(file))
                writer.write(text)
                writer.close()
            } catch (ex: Exception) {
                Log.e(className, "failed to write file \"${file.absoluteFile}\"", ex)
            }
        }

        private fun saveEncrypted(context: Context, file: File, text: String) {
            val masterKey = MasterKey.Builder(context)
                .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
                .build()
            val encryptedFile = EncryptedFile.Builder(
                context,
                file,
                masterKey,
                EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
            ).build()

            // only write one file at a time
            synchronized(this) {
                if (file.exists()) { file.delete() }

                encryptedFile.openFileOutput().bufferedWriter().use {
                    it.write(text)
                }
            }
        }

    }

    /**
     * read coursesList, mensa (current and next week), timetable (current and next week)
     * @param courseName the course name (e.g AI1)
     */
    private fun readStartCache(courseName: String) {
        try {
            readCoursesList()
        } catch (ex : Exception) {
            Log.e(className, "Error while reading the course list", ex)
        }

        try {
            readMensa()
        } catch (ex : Exception) {
            Log.e(className, "Error while reading the mensa menu", ex)
        }

        try {
            readTimetable(courseName, 0)
        } catch (ex : Exception) {
            Log.e(className, "Error while reading timetable week 0", ex)
        }

        try {
            readTimetable(courseName, 1)
        } catch (ex : Exception) {
            Log.e(className, "Error while reading timetable week 1", ex)
        }

        try {
            readAdditionalSubjects()
        } catch (ex : Exception) {
            Log.e(className, "Error while reading additional subjects", ex)
        }
    }

    /**
     * read the courses list from the cached file
     * add them to the coursesList object
     */
    private fun readCoursesList() {
        val file = File(context.filesDir, "courses.json")

        // make sure the file exists
        if (!file.exists()) {
            runBlocking { updateCourseList(context).join() }
            return
        }

        coursesList = FileReader(file).use {
            GsonBuilder().create().fromJson(it, CoursesList().javaClass).courses
        }
    }

    /**
     * get the MensaMenu object from the cached json,
     * if cache is empty create the cache file
     */
    private fun readMensa() {
        val file = File(context.filesDir, "mensa.json")

        // make sure the file exists
        if (!file.exists()) {
            runBlocking { updateMensaMenu(context).join() }
            return
        }

        mensaMenu = FileReader(file).use {
            GsonBuilder().create().fromJson(it, MensaMenu().javaClass)
        }
    }

    /**
     * read the weeks timetable from the cached file
     * @param courseName the course name (e.g AI1)
     * @param week the week to read (0 for the current and so on)
     */
    private fun readTimetable(courseName: String, week: Int) {
        val file = File(context.filesDir, "timetable-$courseName-$week.json")

        // if the file does not exist, call updateTimetable blocking and return
        if (!file.exists()) {
            runBlocking { updateTimetable(courseName, week, context).join() }
            return
        }

        val timetableObject = FileReader(file).use {
            JsonParser.parseString(BufferedReader(it).readLine()).asJsonObject
        }

        // if its a TimetableCourseWeek object migrate to TimetableWeek TODO remove converting at version 0.8
        val timetable = if(timetableObject.has("meta")) {
            Log.i(Companion.className, "trying to migrate TimetableCourseWeek to TimetableWeek")
            val timetableWC = Gson().fromJson(timetableObject, TimetableCourseWeek().javaClass)
            save(file, Gson().toJson(TimetableWeek(
                timetableWC.meta.weekIndex,
                timetableWC.meta.weekNumberYear,
                timetableWC.timetable.days
            )))
            TimetableWeek(timetableWC.meta.weekIndex, timetableWC.meta.weekNumberYear, timetableWC.timetable.days)
        } else {
            Gson().fromJson(timetableObject, TimetableWeek().javaClass)
        }

        // update timetable in TTC
        if (TimetableController.timetable.size > week) {
            TimetableController.timetable[week] = timetable
        } else {
            TimetableController.timetable.add(timetable)
        }
    }

    private fun readAdditionalSubjects() {
        val fileLessons = File(context.filesDir, "additional_lessons.json")
        val fileSubjects = File(context.filesDir, "additional_subjects.json")

        // make sure the file exists
        if (!fileLessons.exists() || !fileSubjects.exists()) {
            return
        }

        // clear the maps before loading, just to be save
        TimetableController.lessonMap.clear()
        TimetableController.subjectMap.clear()

        // read subjects and lessons from cache file
        FileReader(fileLessons).use {
            TimetableController.lessonMap.putAll(
                GsonBuilder().create()
                    .fromJson(it, object : TypeToken<HashMap<String, Lesson>>() {}.type)
            )
        }

        FileReader(fileSubjects).use {
            TimetableController.subjectMap.putAll(
                GsonBuilder().create()
                    .fromJson(it, HashMap<String, ArrayList<String>>().javaClass)
            )
        }

        // add lessons to timetable
        TimetableController.lessonMap.forEach { (_, lesson) ->
            TimetableController.addLessonToTimetable(lesson)
        }

    }

    /**
     * read the encrypted grades file, don't keep them
     * in CacheController for security reasons
     * @return the grades as SortedMap if the file exists, else a empty SortedMap
     */
    fun readGrades(): SortedMap<String, java.util.ArrayList<GradeSubject>> {
        val file = File(context.filesDir, "grades_encrypted")

        // if the file does not exit, try creating it
        if (!file.exists()) {
            runBlocking { updateGrades(context) }
        }

        if (file.exists()) {
            val masterKey = MasterKey.Builder(context)
                .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
                .build()
            val encryptedFile = EncryptedFile.Builder(
                context,
                file,
                masterKey,
                EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
            ).build()

             val map: HashMap<String, ArrayList<GradeSubject>> = encryptedFile.openFileInput().bufferedReader().use {
                GsonBuilder().create()
                    .fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
            }

            // same sorting as qispos parser
            return map.toSortedMap(compareBy<String>{
                    val oText = it.substringAfter(" ")

                    if (oText.contains("/")) {
                        oText.substringBefore("/").toInt() + 0.5
                    } else {
                        oText.toDouble()
                    }
                }.thenBy { it })
        }

        return sortedMapOf()
    }

}