package com.nisemoe.nise.scheduler

import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.service.CompressJudgements
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jooq.DSLContext
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.time.OffsetDateTime

@Profile("fix:scores")
@Service
class FixOldScores(
    private val dslContext: DSLContext,
    private val osuApi: OsuApi,
    private val circleguardService: CircleguardService,
    private val compressJudgements: CompressJudgements
){

    companion object {

        const val CURRENT_VERSION = 7

    }

    @Value("\${OLD_SCORES_WORKERS:4}")
    private var workers: Int = 6

    @Value("\${OLD_SCORES_PAGE_SIZE:5000}")
    private var pageSize: Int = 5000

    private val logger = LoggerFactory.getLogger(javaClass)

    data class Task(val offset: Int, val limit: Int)

    /**
     * This is the minimum cutoff date for scores to be processed.
     * You can set a really old date (e.g. 2023-01-01) here to process all scores.
     */
    private val minCutoff = OffsetDateTime.of(
        2024, 1, 1, 0, 0, 0, 0, OffsetDateTime.now().offset
    )

    @Scheduled(fixedDelay = 120000, initialDelay = 0)
    fun fixOldScores() {
        val condition = SCORES.REPLAY.isNotNull
            .and(SCORES.VERSION.lessThan(CURRENT_VERSION))
            .and(SCORES.ADDED_AT.greaterOrEqual(minCutoff))

        val totalRows = dslContext.fetchCount(SCORES, condition)

        if(totalRows <= 0) {
            this.logger.debug("Fixing old scores but there are none, total rows: $totalRows")
            return
        }

        val tasks = Channel<Task>(Channel.UNLIMITED)
        val readyForWork = Channel<Unit>(Channel.CONFLATED)

        val numTasks = (totalRows + pageSize - 1) / pageSize
        val taskThreshold = workers * 2
        var activeTasks = 0

        runBlocking {
            launch {
                for (i in 0 until numTasks) {
                    while (activeTasks >= taskThreshold) {
                        readyForWork.receive()
                    }
                    val offset = i * pageSize
                    tasks.send(Task(offset, pageSize))
                    activeTasks++
                }
                tasks.close()
            }

            val workerJobs = List(workers) { workerId ->
                launch(Dispatchers.Default) {
                    for (task in tasks) {
                        val scores = dslContext.selectFrom(SCORES)
                            .where(condition)
                            .orderBy(SCORES.ADDED_AT.desc())
                            .limit(task.limit)
                            .offset(task.offset)
                            .fetchInto(ScoresRecord::class.java)

                        scores.forEach { score ->
                            println("Worker $workerId processing score_id: ${score.id} | Active tasks: $activeTasks")
                            try {
                                processScore(score)
                            } catch(exception: Exception) {
                                logger.error(exception.stackTraceToString())
                            }
                        }
                        activeTasks--
                        readyForWork.send(Unit)
                    }
                }
            }

            workerJobs.joinAll()
        }
    }


    fun processScore(score: ScoresRecord) {

        // Fetch the beatmap file from database
        var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE)
            .from(BEATMAPS)
            .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId))
            .fetchOneInto(String::class.java)

        if(beatmapFile == null) {
            this.logger.warn("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from database")

            beatmapFile = this.osuApi.getBeatmapFile(beatmapId = score.beatmapId!!)

            if(beatmapFile == null) {
                this.logger.error("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from osu!api")
                return
            } else {
                dslContext.update(BEATMAPS)
                    .set(BEATMAPS.BEATMAP_FILE, beatmapFile)
                    .where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId))
                    .execute()
            }
        }

        val processedReplay: CircleguardService.ReplayResponse? = try {
            this.circleguardService.processReplay(
                replayData = score.replay!!.decodeToString(), beatmapData = beatmapFile, mods = score.mods ?: 0
            ).get()
        } catch (e: Exception) {
            this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")
            this.logger.error(e.stackTraceToString())
            return
        }

        if (processedReplay == null || processedReplay.judgements.isEmpty()) {
            this.logger.error("Circleguard returned null and failed to process replay with score_id: ${score.id}")
            return
        }

        val scoreId = dslContext.update(SCORES)
            .set(SCORES.UR, processedReplay.ur)
            .set(SCORES.ADJUSTED_UR, processedReplay.adjusted_ur)
            .set(SCORES.FRAMETIME, processedReplay.frametime)
            .set(SCORES.SNAPS, processedReplay.snaps)
            .set(SCORES.MEAN_ERROR, processedReplay.mean_error)
            .set(SCORES.ERROR_VARIANCE, processedReplay.error_variance)
            .set(SCORES.ERROR_STANDARD_DEVIATION, processedReplay.error_standard_deviation)
            .set(SCORES.MINIMUM_ERROR, processedReplay.minimum_error)
            .set(SCORES.MAXIMUM_ERROR, processedReplay.maximum_error)
            .set(SCORES.ERROR_RANGE, processedReplay.error_range)
            .set(SCORES.ERROR_COEFFICIENT_OF_VARIATION, processedReplay.error_coefficient_of_variation)
            .set(SCORES.ERROR_KURTOSIS, processedReplay.error_kurtosis)
            .set(SCORES.ERROR_SKEWNESS, processedReplay.error_skewness)
            .set(SCORES.SNAPS, processedReplay.snaps)
            .set(SCORES.EDGE_HITS, processedReplay.edge_hits)
            .set(SCORES.KEYPRESSES_TIMES, processedReplay.keypresses_times?.toTypedArray())
            .set(SCORES.KEYPRESSES_MEDIAN, processedReplay.keypresses_median)
            .set(SCORES.KEYPRESSES_MEDIAN_ADJUSTED, processedReplay.keypresses_median_adjusted)
            .set(SCORES.KEYPRESSES_STANDARD_DEVIATION, processedReplay.keypresses_standard_deviation)
            .set(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED, processedReplay.keypresses_standard_deviation_adjusted)
            .set(SCORES.SLIDEREND_RELEASE_TIMES, processedReplay.sliderend_release_times?.toTypedArray())
            .set(SCORES.SLIDEREND_RELEASE_MEDIAN, processedReplay.sliderend_release_median)
            .set(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED, processedReplay.sliderend_release_median_adjusted)
            .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION, processedReplay.sliderend_release_standard_deviation)
            .set(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED, processedReplay.sliderend_release_standard_deviation_adjusted)
            .set(SCORES.JUDGEMENTS, compressJudgements.serialize(processedReplay.judgements))
            .where(SCORES.REPLAY_ID.eq(score.replayId))
            .returningResult(SCORES.ID)
            .fetchOne()?.getValue(SCORES.ID)

        if (scoreId == null) {
            this.logger.debug("Weird, failed to insert score into scores table. At least, it did not return an ID.")
            return
        }

        dslContext.update(SCORES)
            .set(SCORES.VERSION, CURRENT_VERSION)
            .where(SCORES.ID.eq(scoreId))
            .execute()
    }

}